@crvouga/mockingbird-service-google-maps 1.4.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.
@@ -38,30 +38,30 @@ var createClock = (source = Date.now) => {
38
38
  var Collection = class {
39
39
  sqlite;
40
40
  namespace;
41
- name;
42
- constructor(sqlite, namespace, name) {
41
+ collectionName;
42
+ constructor(sqlite, namespace, collectionName) {
43
43
  this.sqlite = sqlite;
44
44
  this.namespace = namespace;
45
- this.name = name;
45
+ this.collectionName = collectionName;
46
46
  }
47
47
  bumpCollectionSeq() {
48
- const row2 = this.sqlite.prepare("SELECT value FROM mockingbird_sequences WHERE namespace = ? AND name = ? AND kind = 'collection'").get(this.namespace, this.name);
48
+ const row2 = this.sqlite.prepare("SELECT value FROM mockingbird_sequences WHERE namespace = ? AND name = ? AND kind = 'collection'").get(this.namespace, this.collectionName);
49
49
  const next = (row2?.value ?? 0) + 1;
50
50
  this.sqlite.prepare(`INSERT INTO mockingbird_sequences (namespace, name, kind, value) VALUES (?, ?, 'collection', ?)
51
- ON CONFLICT(namespace, name, kind) DO UPDATE SET value = excluded.value`).run(this.namespace, this.name, next);
51
+ ON CONFLICT(namespace, name, kind) DO UPDATE SET value = excluded.value`).run(this.namespace, this.collectionName, next);
52
52
  return next;
53
53
  }
54
54
  nextSequence() {
55
55
  return this.sqlite.transaction(() => this.bumpCollectionSeq());
56
56
  }
57
57
  get(id) {
58
- const row2 = this.sqlite.prepare("SELECT value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.name, id);
58
+ const row2 = this.sqlite.prepare("SELECT value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
59
59
  if (!row2)
60
60
  return void 0;
61
61
  return JSON.parse(row2.value).value;
62
62
  }
63
63
  has(id) {
64
- const row2 = this.sqlite.prepare("SELECT 1 AS ok FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.name, id);
64
+ const row2 = this.sqlite.prepare("SELECT 1 AS ok FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
65
65
  return row2 !== void 0;
66
66
  }
67
67
  /** Insert a new record, assigning it the next sequence number. */
@@ -71,32 +71,32 @@ var Collection = class {
71
71
  const stored = { seq, value };
72
72
  this.sqlite.prepare(`INSERT INTO mockingbird_records (namespace, collection, id, seq, value)
73
73
  VALUES (?, ?, ?, ?, ?)
74
- ON CONFLICT(namespace, collection, id) DO UPDATE SET seq = excluded.seq, value = excluded.value`).run(this.namespace, this.name, id, seq, JSON.stringify(stored));
74
+ ON CONFLICT(namespace, collection, id) DO UPDATE SET seq = excluded.seq, value = excluded.value`).run(this.namespace, this.collectionName, id, seq, JSON.stringify(stored));
75
75
  return stored;
76
76
  });
77
77
  }
78
78
  /** Replace an existing record's value, keeping its position. */
79
79
  update(id, value) {
80
80
  return this.sqlite.transaction(() => {
81
- const row2 = this.sqlite.prepare("SELECT seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.name, id);
81
+ const row2 = this.sqlite.prepare("SELECT seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").get(this.namespace, this.collectionName, id);
82
82
  if (!row2)
83
83
  return void 0;
84
84
  const stored = { seq: row2.seq, value };
85
- this.sqlite.prepare("UPDATE mockingbird_records SET value = ? WHERE namespace = ? AND collection = ? AND id = ?").run(JSON.stringify(stored), this.namespace, this.name, id);
85
+ this.sqlite.prepare("UPDATE mockingbird_records SET value = ? WHERE namespace = ? AND collection = ? AND id = ?").run(JSON.stringify(stored), this.namespace, this.collectionName, id);
86
86
  return stored;
87
87
  });
88
88
  }
89
89
  delete(id) {
90
- const result = this.sqlite.prepare("DELETE FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").run(this.namespace, this.name, id);
90
+ const result = this.sqlite.prepare("DELETE FROM mockingbird_records WHERE namespace = ? AND collection = ? AND id = ?").run(this.namespace, this.collectionName, id);
91
91
  return result.changes > 0;
92
92
  }
93
93
  /** How many records the collection holds, without reading them. */
94
94
  count() {
95
- const row2 = this.sqlite.prepare("SELECT COUNT(*) AS n FROM mockingbird_records WHERE namespace = ? AND collection = ?").get(this.namespace, this.name);
95
+ const row2 = this.sqlite.prepare("SELECT COUNT(*) AS n FROM mockingbird_records WHERE namespace = ? AND collection = ?").get(this.namespace, this.collectionName);
96
96
  return Number(row2?.n ?? 0);
97
97
  }
98
98
  list(options = {}) {
99
- const rows = this.sqlite.prepare("SELECT id, seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ?").all(this.namespace, this.name);
99
+ const rows = this.sqlite.prepare("SELECT id, seq, value FROM mockingbird_records WHERE namespace = ? AND collection = ?").all(this.namespace, this.collectionName);
100
100
  const out = [];
101
101
  for (const row2 of rows) {
102
102
  const stored = JSON.parse(row2.value);
@@ -353,16 +353,18 @@ var createControlPlane = (context) => {
353
353
  uptimeMs: context.wallNow() - context.startedAt,
354
354
  clock: context.clock.state(),
355
355
  namespaces: context.namespaces().length,
356
+ adminUi: `${ADMIN_PREFIX}/ui`,
356
357
  ...context.describe()
357
358
  });
358
359
  }
359
360
  if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
360
361
  return void 0;
361
362
  }
362
- if (context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
363
+ const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
364
+ const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
365
+ if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
363
366
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
364
367
  }
365
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
366
368
  for (const route of routes) {
367
369
  if (route.method !== request.method)
368
370
  continue;
@@ -1117,6 +1119,936 @@ var clearNamespace = (sqlite, namespace) => {
1117
1119
  });
1118
1120
  };
1119
1121
 
1122
+ // ../core/dist/surface.js
1123
+ var defineMock = (runtime) => runtime;
1124
+ var STANDARD_ADMIN_ROUTES = [
1125
+ "GET /",
1126
+ "POST /reset",
1127
+ "GET /namespaces",
1128
+ "GET /clock",
1129
+ "POST /clock",
1130
+ "GET /faults",
1131
+ "POST /faults",
1132
+ "DELETE /faults",
1133
+ "POST /snapshots",
1134
+ "POST /snapshots/:id/restore",
1135
+ "DELETE /snapshots/:id",
1136
+ "GET /timeline",
1137
+ "POST /checkpoints",
1138
+ "POST /branches/:name",
1139
+ "POST /branches/:name/checkout",
1140
+ "GET /requests",
1141
+ "DELETE /requests",
1142
+ "GET /metrics",
1143
+ "DELETE /metrics",
1144
+ "GET /credentials",
1145
+ "PUT /credentials",
1146
+ "DELETE /credentials",
1147
+ "GET /state",
1148
+ "GET /state/:collection",
1149
+ "GET /state/:collection/:id",
1150
+ "POST /state/:collection",
1151
+ "PUT /state/:collection/:id",
1152
+ "PATCH /state/:collection/:id",
1153
+ "DELETE /state/:collection/:id",
1154
+ "GET /ui",
1155
+ "GET /ui/",
1156
+ "GET /ui/manifest"
1157
+ ];
1158
+ var PANEL_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
1159
+ var assertAdminUi = (ui) => {
1160
+ if (!ui?.panels)
1161
+ return;
1162
+ const seen = /* @__PURE__ */ new Set();
1163
+ for (const panel of ui.panels) {
1164
+ if (!PANEL_ID.test(panel.id)) {
1165
+ throw new RangeError(`admin panel id must match ${PANEL_ID}: ${JSON.stringify(panel.id)}`);
1166
+ }
1167
+ if (seen.has(panel.id))
1168
+ throw new RangeError(`duplicate admin panel id ${panel.id}`);
1169
+ seen.add(panel.id);
1170
+ if (panel.title.trim() === "")
1171
+ throw new RangeError(`admin panel ${panel.id} needs a title`);
1172
+ }
1173
+ };
1174
+
1175
+ // ../core/dist/admin-ui.js
1176
+ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
1177
+ switch (char) {
1178
+ case "&":
1179
+ return "&amp;";
1180
+ case "<":
1181
+ return "&lt;";
1182
+ case ">":
1183
+ return "&gt;";
1184
+ case '"':
1185
+ return "&quot;";
1186
+ default:
1187
+ return "&#39;";
1188
+ }
1189
+ });
1190
+ var renderAdminDocument = (service) => {
1191
+ const name = escapeHtml(service);
1192
+ const standard = JSON.stringify(STANDARD_ADMIN_ROUTES);
1193
+ return `<!DOCTYPE html>
1194
+ <html lang="en">
1195
+ <head>
1196
+ <meta charset="utf-8">
1197
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1198
+ <meta name="color-scheme" content="light dark">
1199
+ <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">
1200
+ <title>${name} admin</title>
1201
+ <style>
1202
+ :root {
1203
+ color-scheme: light;
1204
+ --bg: #f6f5f2;
1205
+ --bg-elev: #ffffff;
1206
+ --bg-muted: #eeece6;
1207
+ --bg-inset: #f3f1eb;
1208
+ --line: #e2dfd6;
1209
+ --line-strong: #d0ccc1;
1210
+ --ink: #1c1915;
1211
+ --muted: #5e584f;
1212
+ --faint: #8a8378;
1213
+ --accent: #3d4f3a;
1214
+ --accent-ink: #f7f6f2;
1215
+ --accent-soft: #e5ece2;
1216
+ --ok: #1f7a45;
1217
+ --ok-soft: #e5f4eb;
1218
+ --warn: #9a6700;
1219
+ --warn-soft: #fbf3df;
1220
+ --err: #a33b32;
1221
+ --err-soft: #fdecea;
1222
+ --shadow: 0 1px 2px rgb(28 25 21 / 0.05), 0 12px 32px rgb(28 25 21 / 0.06);
1223
+ --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1224
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
1225
+ --radius: 14px;
1226
+ --header: 60px;
1227
+ --nav: 220px;
1228
+ }
1229
+ :root[data-theme="dark"] {
1230
+ color-scheme: dark;
1231
+ --bg: #141311;
1232
+ --bg-elev: #1e1c19;
1233
+ --bg-muted: #26231f;
1234
+ --bg-inset: #181715;
1235
+ --line: #34302b;
1236
+ --line-strong: #4a453d;
1237
+ --ink: #f4f1ea;
1238
+ --muted: #c4bdb1;
1239
+ --faint: #8f877c;
1240
+ --accent: #c5d5b8;
1241
+ --accent-ink: #1a2118;
1242
+ --accent-soft: #2a3328;
1243
+ --ok: #8fd6a8;
1244
+ --ok-soft: #1a2e22;
1245
+ --warn: #f0cc78;
1246
+ --warn-soft: #332911;
1247
+ --err: #f0a8a2;
1248
+ --err-soft: #3a201e;
1249
+ --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.28);
1250
+ }
1251
+ @media (prefers-color-scheme: dark) {
1252
+ :root:not([data-theme="light"]) {
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
+ }
1275
+ * { box-sizing: border-box; }
1276
+ html, body { margin: 0; height: 100%; }
1277
+ body {
1278
+ font-family: var(--sans);
1279
+ background: var(--bg);
1280
+ color: var(--ink);
1281
+ line-height: 1.45;
1282
+ padding-left: env(safe-area-inset-left);
1283
+ padding-right: env(safe-area-inset-right);
1284
+ -webkit-text-size-adjust: 100%;
1285
+ }
1286
+ button, input, select, textarea { font: inherit; color: inherit; }
1287
+ button { cursor: pointer; }
1288
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1289
+ @media (prefers-reduced-motion: reduce) {
1290
+ * { scroll-behavior: auto !important; transition: none !important; }
1291
+ }
1292
+ .app { min-height: 100%; display: flex; flex-direction: column; }
1293
+ header.top {
1294
+ position: sticky; top: 0; z-index: 5;
1295
+ display: flex; align-items: center; gap: 12px;
1296
+ min-height: var(--header); padding: 10px 16px;
1297
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
1298
+ border-bottom: 1px solid var(--line);
1299
+ backdrop-filter: blur(10px);
1300
+ }
1301
+ .brand { display: flex; flex-direction: column; min-width: 0; margin-right: auto; }
1302
+ .brand strong { font-size: 15px; letter-spacing: -0.01em; }
1303
+ .brand span { color: var(--faint); font-size: 12px; }
1304
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1305
+ label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
1306
+ label.field input, label.field select, .controls input[type="password"] {
1307
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
1308
+ padding: 7px 8px; min-height: 36px;
1309
+ }
1310
+ .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
1311
+ border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
1312
+ background: var(--bg-elev);
1313
+ }
1314
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
1315
+ .btn-quiet { background: transparent; }
1316
+ .btn-danger { color: var(--err); }
1317
+ /* minmax(0, 1fr): a 1fr column will not shrink below its content, so a long
1318
+ record or the full nav row would widen the page past the viewport. */
1319
+ .layout {
1320
+ display: grid; grid-template-columns: var(--nav) minmax(0, 1fr);
1321
+ flex: 1; min-height: 0; min-width: 0;
1322
+ }
1323
+ nav.side {
1324
+ border-right: 1px solid var(--line); padding: 12px;
1325
+ display: flex; flex-direction: column; gap: 4px;
1326
+ background: var(--bg-elev);
1327
+ }
1328
+ nav.side button {
1329
+ text-align: left; border: 0; background: transparent; border-radius: 9px;
1330
+ padding: 9px 10px; min-height: 40px; color: var(--muted);
1331
+ }
1332
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
1333
+ main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
1334
+ .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
1335
+ .lede { margin: 0 0 16px; color: var(--muted); }
1336
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
1337
+ .card {
1338
+ background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
1339
+ padding: 14px 16px; box-shadow: var(--shadow);
1340
+ }
1341
+ .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
1342
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
1343
+ .card .v .clock-time { font-size: 14px; color: var(--muted); }
1344
+ .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
1345
+ .collection {
1346
+ width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
1347
+ border-radius: 10px; padding: 8px 10px; min-height: 44px;
1348
+ }
1349
+ .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
1350
+ .collection small { display: block; color: var(--faint); }
1351
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
1352
+ .chip {
1353
+ font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
1354
+ border-radius: 999px; padding: 4px 8px;
1355
+ }
1356
+ .chip i { color: var(--faint); font-style: normal; }
1357
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
1358
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
1359
+ th { color: var(--faint); font-weight: 600; font-size: 12px; }
1360
+ tr.clickable { cursor: pointer; }
1361
+ tr.clickable:hover { background: var(--bg-muted); }
1362
+ pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
1363
+ pre {
1364
+ margin: 0; white-space: pre-wrap; word-break: break-word;
1365
+ background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
1366
+ }
1367
+ .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
1368
+ .banner {
1369
+ display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
1370
+ background: var(--err-soft); color: var(--err);
1371
+ }
1372
+ .banner.show { display: block; }
1373
+ .empty { color: var(--muted); padding: 24px 8px; }
1374
+ dialog {
1375
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
1376
+ color: var(--ink); width: min(640px, calc(100vw - 24px)); box-shadow: var(--shadow);
1377
+ }
1378
+ dialog::backdrop { background: rgb(20 19 17 / 0.45); }
1379
+ dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
1380
+ textarea {
1381
+ width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
1382
+ border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
1383
+ font-family: var(--mono); font-size: 12px;
1384
+ }
1385
+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
1386
+ .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
1387
+ .badge.ok { background: var(--ok-soft); color: var(--ok); }
1388
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
1389
+ .status-2 { color: var(--ok); }
1390
+ .status-4, .status-5, .status-0 { color: var(--err); }
1391
+ .stack { display: flex; flex-direction: column; gap: 12px; }
1392
+ .route {
1393
+ display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
1394
+ padding: 8px 0; border-bottom: 1px solid var(--line);
1395
+ }
1396
+ .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
1397
+ @media (max-width: 800px) {
1398
+ header.top {
1399
+ align-items: stretch; flex-direction: column;
1400
+ padding-top: max(10px, env(safe-area-inset-top));
1401
+ }
1402
+ input, select, textarea { font-size: 16px; }
1403
+ .controls { width: 100%; }
1404
+ label.field, label.field select, label.field input { flex: 1; min-width: 0; }
1405
+ .layout { grid-template-columns: minmax(0, 1fr); }
1406
+ nav.side {
1407
+ flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
1408
+ border-right: 0; border-bottom: 1px solid var(--line);
1409
+ padding: 8px;
1410
+ }
1411
+ nav.side button { white-space: nowrap; flex: none; }
1412
+ .split { grid-template-columns: minmax(0, 1fr); }
1413
+ main { padding: 14px; }
1414
+ table, thead, tbody, tr, th, td { display: block; }
1415
+ thead { display: none; }
1416
+ tr {
1417
+ border: 1px solid var(--line); border-radius: 12px; margin-bottom: 8px; padding: 6px;
1418
+ background: var(--bg-elev); min-width: 0;
1419
+ }
1420
+ td {
1421
+ border: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px;
1422
+ min-width: 0; overflow-wrap: anywhere;
1423
+ }
1424
+ td::before { content: attr(data-label); color: var(--faint); font-size: 12px; flex: none; }
1425
+ .route { grid-template-columns: 72px 1fr; }
1426
+ .route button { grid-column: 1 / -1; }
1427
+ }
1428
+ </style>
1429
+ </head>
1430
+ <body>
1431
+ <div class="app" data-service="${name}" data-mockingbird-admin>
1432
+ <header class="top">
1433
+ <div class="brand">
1434
+ <strong>${name}</strong>
1435
+ <span>Mockingbird admin</span>
1436
+ </div>
1437
+ <div class="controls">
1438
+ <label class="field">Namespace
1439
+ <select id="namespace" aria-label="Namespace"></select>
1440
+ </label>
1441
+ <label class="field">Admin key
1442
+ <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
1443
+ </label>
1444
+ <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
1445
+ <button class="btn" id="refresh" type="button">Refresh</button>
1446
+ </div>
1447
+ </header>
1448
+ <div class="layout">
1449
+ <nav class="side" id="nav" aria-label="Admin sections">
1450
+ <button type="button" data-view="overview" aria-current="true">Overview</button>
1451
+ <button type="button" data-view="state">State</button>
1452
+ <button type="button" data-view="clock">Clock</button>
1453
+ <button type="button" data-view="faults">Faults</button>
1454
+ <button type="button" data-view="journal">Journal</button>
1455
+ <button type="button" data-view="routes">Routes</button>
1456
+ </nav>
1457
+ <main id="views">
1458
+ <p class="banner" id="banner" role="alert"></p>
1459
+ <section class="view" id="view-overview">
1460
+ <h2>Overview</h2>
1461
+ <p class="lede">Health, clock, and the collections this namespace is holding.</p>
1462
+ <div class="grid" id="overview-cards"></div>
1463
+ <h3>Recent requests</h3>
1464
+ <div id="overview-requests"></div>
1465
+ </section>
1466
+ <section class="view" id="view-state" hidden>
1467
+ <h2>State</h2>
1468
+ <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
1469
+ <div class="split">
1470
+ <div id="collections"></div>
1471
+ <div>
1472
+ <div id="schema"></div>
1473
+ <div class="row-actions">
1474
+ <button class="btn-primary" id="new-record" type="button">New record</button>
1475
+ </div>
1476
+ <div id="records"></div>
1477
+ </div>
1478
+ </div>
1479
+ </section>
1480
+ <section class="view" id="view-clock" hidden>
1481
+ <h2>Clock</h2>
1482
+ <p class="lede">The clock every timestamp in this mock reads.</p>
1483
+ <div class="card" id="clock-readout"></div>
1484
+ <div class="row-actions">
1485
+ <button class="btn" type="button" data-advance="60000">+1m</button>
1486
+ <button class="btn" type="button" data-advance="900000">+15m</button>
1487
+ <button class="btn" type="button" data-advance="3600000">+1h</button>
1488
+ <button class="btn" type="button" data-advance="86400000">+1d</button>
1489
+ <button class="btn" type="button" data-advance="-3600000">\u22121h</button>
1490
+ <button class="btn" id="freeze" type="button">Freeze</button>
1491
+ <button class="btn-quiet" id="clock-reset" type="button">Reset</button>
1492
+ </div>
1493
+ <form id="clock-set" class="row-actions">
1494
+ <label class="field">Set instant
1495
+ <input id="clock-instant" type="text" placeholder="2026-01-01T00:00:00Z or epoch ms" size="32">
1496
+ </label>
1497
+ <button class="btn-primary" type="submit">Set</button>
1498
+ </form>
1499
+ </section>
1500
+ <section class="view" id="view-faults" hidden>
1501
+ <h2>Faults</h2>
1502
+ <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
1503
+ <form id="fault-form" class="card stack">
1504
+ <label class="field">Preset
1505
+ <select id="fault-preset"><option value="">Custom rule</option></select>
1506
+ </label>
1507
+ <label class="field">Status
1508
+ <input id="fault-status" inputmode="numeric" placeholder="503">
1509
+ </label>
1510
+ <label class="field">Path prefix
1511
+ <input id="fault-path" placeholder="/v1">
1512
+ </label>
1513
+ <label class="field">Method
1514
+ <input id="fault-method" placeholder="GET">
1515
+ </label>
1516
+ <div class="dialog-actions">
1517
+ <button class="btn-primary" type="submit">Add fault</button>
1518
+ <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
1519
+ </div>
1520
+ </form>
1521
+ <div id="fault-list"></div>
1522
+ </section>
1523
+ <section class="view" id="view-journal" hidden>
1524
+ <h2>Journal</h2>
1525
+ <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
1526
+ <div class="row-actions">
1527
+ <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
1528
+ </div>
1529
+ <div id="journal"></div>
1530
+ </section>
1531
+ <section class="view" id="view-routes" hidden>
1532
+ <h2>Routes</h2>
1533
+ <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>
1534
+ <div id="route-list"></div>
1535
+ <form id="route-form" class="card stack">
1536
+ <strong id="route-title">Select a route</strong>
1537
+ <label class="field">JSON body
1538
+ <textarea id="route-body" placeholder="{ }"></textarea>
1539
+ </label>
1540
+ <div class="dialog-actions">
1541
+ <button class="btn-primary" type="submit">Send</button>
1542
+ </div>
1543
+ <pre id="route-result">Response will show here.</pre>
1544
+ </form>
1545
+ </section>
1546
+ </main>
1547
+ </div>
1548
+ </div>
1549
+ <dialog id="editor">
1550
+ <form method="dialog" id="editor-form">
1551
+ <strong id="editor-title">Record</strong>
1552
+ <label class="field">Id
1553
+ <input id="editor-id" required>
1554
+ </label>
1555
+ <label class="field">JSON value
1556
+ <textarea id="editor-value"></textarea>
1557
+ </label>
1558
+ <div class="dialog-actions">
1559
+ <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
1560
+ <button class="btn-quiet" type="submit" value="cancel">Cancel</button>
1561
+ <button class="btn-primary" type="submit" value="save">Save</button>
1562
+ </div>
1563
+ </form>
1564
+ </dialog>
1565
+ <script>
1566
+ (() => {
1567
+ const STANDARD = new Set(${standard})
1568
+ const state = {
1569
+ namespace: "default",
1570
+ key: sessionStorage.getItem("mockingbird-admin-key") || "",
1571
+ view: "overview",
1572
+ collection: "",
1573
+ shape: null,
1574
+ route: null,
1575
+ editing: null
1576
+ }
1577
+ const $ = (id) => document.getElementById(id)
1578
+ const banner = $("banner")
1579
+ const keyInput = $("admin-key")
1580
+ keyInput.value = state.key
1581
+ const params = new URLSearchParams(location.search)
1582
+ if (params.get("key")) {
1583
+ state.key = params.get("key")
1584
+ keyInput.value = state.key
1585
+ sessionStorage.setItem("mockingbird-admin-key", state.key)
1586
+ params.delete("key")
1587
+ const next = params.toString()
1588
+ history.replaceState(null, "", location.pathname + (next ? "?" + next : ""))
1589
+ }
1590
+ if (params.get("namespace")) state.namespace = params.get("namespace")
1591
+
1592
+ const showError = (error) => {
1593
+ banner.textContent = error instanceof Error ? error.message : String(error)
1594
+ banner.classList.add("show")
1595
+ }
1596
+ const clearError = () => banner.classList.remove("show")
1597
+
1598
+ async function originGet(path) {
1599
+ const response = await fetch(path, { headers: { accept: "application/json" } })
1600
+ if (!response.ok) throw new Error(response.status + " " + path)
1601
+ return response.json()
1602
+ }
1603
+ async function api(method, path, body) {
1604
+ const headers = { accept: "application/json" }
1605
+ if (state.key) headers["${ADMIN_KEY_HEADER}"] = state.key
1606
+ if (state.namespace) headers["x-mockingbird-namespace"] = state.namespace
1607
+ if (body !== undefined) headers["content-type"] = "application/json"
1608
+ const response = await fetch("${ADMIN_PREFIX}" + path, {
1609
+ method,
1610
+ headers,
1611
+ body: body === undefined ? undefined : JSON.stringify(body)
1612
+ })
1613
+ const text = await response.text()
1614
+ let data = null
1615
+ if (text) {
1616
+ try { data = JSON.parse(text) } catch { data = { raw: text } }
1617
+ }
1618
+ if (response.status === 401) {
1619
+ keyInput.focus()
1620
+ throw new Error((data && data.error && data.error.message) || "Admin key required")
1621
+ }
1622
+ if (!response.ok) {
1623
+ throw new Error((data && data.error && data.error.message) || (response.status + " " + method + " " + path))
1624
+ }
1625
+ return data
1626
+ }
1627
+ const panelApi = {
1628
+ get: (path) => api("GET", path),
1629
+ send: (method, path, body) => api(method, path, body),
1630
+ namespace: () => state.namespace
1631
+ }
1632
+
1633
+ function setTheme(theme) {
1634
+ if (theme) document.documentElement.dataset.theme = theme
1635
+ else delete document.documentElement.dataset.theme
1636
+ }
1637
+ const storedTheme = localStorage.getItem("mockingbird-admin-theme")
1638
+ if (storedTheme) setTheme(storedTheme)
1639
+ $("theme").addEventListener("click", () => {
1640
+ const current = document.documentElement.dataset.theme
1641
+ const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches
1642
+ const next = dark ? "light" : "dark"
1643
+ localStorage.setItem("mockingbird-admin-theme", next)
1644
+ setTheme(next)
1645
+ })
1646
+
1647
+ function show(view) {
1648
+ state.view = view
1649
+ for (const button of document.querySelectorAll("#nav button")) {
1650
+ button.setAttribute("aria-current", button.dataset.view === view ? "true" : "false")
1651
+ }
1652
+ for (const section of document.querySelectorAll("main .view")) {
1653
+ section.hidden = section.id !== "view-" + view
1654
+ }
1655
+ }
1656
+ document.getElementById("nav").addEventListener("click", (event) => {
1657
+ const button = event.target.closest("button[data-view]")
1658
+ if (!button) return
1659
+ show(button.dataset.view)
1660
+ refresh().catch(showError)
1661
+ })
1662
+
1663
+ function cards(items) {
1664
+ return '<div class="grid">' + items.map((item) =>
1665
+ '<article class="card"><div class="k">' + item.k + '</div><div class="v">' + item.v + '</div></article>'
1666
+ ).join("") + "</div>"
1667
+ }
1668
+ function table(columns, rows) {
1669
+ if (rows.length === 0) return '<p class="empty">Nothing here yet.</p>'
1670
+ const head = "<tr>" + columns.map((column) => "<th>" + column.label + "</th>").join("") + "</tr>"
1671
+ const body = rows.map((row) => "<tr" + (row.attrs || "") + ">" + columns.map((column) =>
1672
+ '<td data-label="' + column.label + '">' + column.cell(row) + "</td>"
1673
+ ).join("") + "</tr>").join("")
1674
+ return "<table><thead>" + head + "</thead><tbody>" + body + "</tbody></table>"
1675
+ }
1676
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
1677
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
1678
+ }[char]))
1679
+ const preview = (value) => {
1680
+ const text = JSON.stringify(value)
1681
+ return esc(text.length > 180 ? text.slice(0, 180) + "\u2026" : text)
1682
+ }
1683
+ const when = (epoch) => {
1684
+ const date = new Date(epoch)
1685
+ return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString()
1686
+ }
1687
+ const clockCard = (epoch) => {
1688
+ const date = new Date(epoch)
1689
+ if (Number.isNaN(date.getTime())) return esc(String(epoch))
1690
+ const iso = date.toISOString()
1691
+ return esc(iso.slice(0, 10)) + '<span class="clock-time">' + esc(iso.slice(11, 19)) + " UTC</span>"
1692
+ }
1693
+
1694
+ async function loadNamespaces() {
1695
+ const data = await api("GET", "/namespaces")
1696
+ const select = $("namespace")
1697
+ const names = data.namespaces && data.namespaces.length ? data.namespaces : [data.default || "default"]
1698
+ if (!names.includes(state.namespace)) names.unshift(state.namespace)
1699
+ select.innerHTML = names.map((name) =>
1700
+ '<option value="' + esc(name) + '">' + esc(name) + "</option>"
1701
+ ).join("")
1702
+ select.value = state.namespace
1703
+ }
1704
+
1705
+ async function renderOverview() {
1706
+ const [health, shape, clock, journal] = await Promise.all([
1707
+ originGet("/health"),
1708
+ api("GET", "/state"),
1709
+ api("GET", "/clock"),
1710
+ api("GET", "/requests?limit=8")
1711
+ ])
1712
+ state.shape = shape
1713
+ $("overview-cards").innerHTML = cards([
1714
+ { k: "Service", v: esc(health.service || "${name}") },
1715
+ { k: "Status", v: esc(health.status || "ok") },
1716
+ { k: "Namespace", v: esc(state.namespace) },
1717
+ { k: "Collections", v: String(shape.collections.length) },
1718
+ { k: "Records", v: String(shape.collections.reduce((sum, item) => sum + item.count, 0)) },
1719
+ { k: "Clock", v: clockCard(clock.now) }
1720
+ ])
1721
+ const requests = (journal && journal.requests) || []
1722
+ $("overview-requests").innerHTML = table(
1723
+ [
1724
+ { label: "Method", cell: (row) => esc(row.method) },
1725
+ { label: "Path", cell: (row) => esc(row.path) },
1726
+ { label: "Status", cell: (row) => '<span class="status-' + String(row.status).charAt(0) + '">' + esc(row.status) + "</span>" },
1727
+ { label: "Operation", cell: (row) => esc(row.operationId || "") }
1728
+ ],
1729
+ requests
1730
+ )
1731
+ }
1732
+
1733
+ function schemaChips(collection) {
1734
+ if (!collection || collection.fields.length === 0) {
1735
+ return '<p class="empty">No fields yet. Add a record or declare the collection on the service.</p>'
1736
+ }
1737
+ return '<div class="chips">' + collection.fields.map((field) =>
1738
+ '<span class="chip">' + esc(field.name) + ' <i>' + esc(field.kind) + (field.optional ? "?" : "") + "</i></span>"
1739
+ ).join("") + "</div>"
1740
+ }
1741
+
1742
+ async function renderState() {
1743
+ const shape = await api("GET", "/state")
1744
+ state.shape = shape
1745
+ const host = $("collections")
1746
+ if (shape.collections.length === 0) {
1747
+ host.innerHTML = '<p class="empty">This namespace has no collections yet.</p>'
1748
+ $("schema").innerHTML = ""
1749
+ $("records").innerHTML = ""
1750
+ return
1751
+ }
1752
+ if (!shape.collections.some((item) => item.name === state.collection)) {
1753
+ state.collection = shape.collections[0].name
1754
+ }
1755
+ host.innerHTML = shape.collections.map((item) =>
1756
+ '<button type="button" class="collection" data-collection="' + esc(item.name) + '" aria-current="' +
1757
+ (item.name === state.collection ? "true" : "false") + '"><strong>' + esc(item.label) +
1758
+ '</strong><small>' + esc(item.count) + " \xB7 " + esc(item.source) + "</small></button>"
1759
+ ).join("")
1760
+ const selected = shape.collections.find((item) => item.name === state.collection)
1761
+ $("schema").innerHTML = "<h3>" + esc(selected.label) + "</h3>" +
1762
+ (selected.description ? "<p class=" + '"lede">' + esc(selected.description) + "</p>" : "") +
1763
+ schemaChips(selected)
1764
+ const page = await api("GET", "/state/" + encodeURIComponent(state.collection) + "?limit=50")
1765
+ const rows = page.records.map((record) => ({
1766
+ ...record,
1767
+ attrs: ' class="clickable" data-id="' + esc(record.id) + '"'
1768
+ }))
1769
+ $("records").innerHTML = table(
1770
+ [
1771
+ { label: "Id", cell: (row) => "<code>" + esc(row.id) + "</code>" },
1772
+ { label: "Seq", cell: (row) => esc(row.seq) },
1773
+ { label: "Value", cell: (row) => preview(row.value) }
1774
+ ],
1775
+ rows
1776
+ )
1777
+ }
1778
+
1779
+ async function renderClock() {
1780
+ const clock = await api("GET", "/clock")
1781
+ $("clock-readout").innerHTML = "<div class='k'>Now</div><div class='v'>" + esc(when(clock.now)) +
1782
+ "</div><p>" + esc(clock.now) + " ms \xB7 offset " + esc(clock.offsetMs) + " ms " +
1783
+ (clock.frozen ? "<span class='badge warn'>frozen</span>" : "<span class='badge ok'>live</span>") + "</p>"
1784
+ $("freeze").textContent = clock.frozen ? "Unfreeze" : "Freeze"
1785
+ $("freeze").dataset.frozen = clock.frozen ? "1" : "0"
1786
+ }
1787
+
1788
+ async function renderFaults() {
1789
+ const [faults, presets] = await Promise.all([
1790
+ api("GET", "/faults"),
1791
+ api("GET", "/faults/presets").catch(() => ({ presets: [] }))
1792
+ ])
1793
+ const select = $("fault-preset")
1794
+ const current = select.value
1795
+ const list = presets.presets || []
1796
+ select.innerHTML = '<option value="">Custom rule</option>' + list.map((preset) =>
1797
+ '<option value="' + esc(preset.name) + '">' + esc(preset.name) + "</option>"
1798
+ ).join("")
1799
+ select.value = current
1800
+ const rules = faults.faults || []
1801
+ $("fault-list").innerHTML = table(
1802
+ [
1803
+ { label: "Id", cell: (row) => esc(row.id) },
1804
+ { label: "Match", cell: (row) => esc([row.method, row.pathPrefix, row.operationId].filter(Boolean).join(" ") || "all") },
1805
+ { label: "Effect", cell: (row) => esc(row.status ?? row.effect ?? (row.drop ? "drop" : row.latencyMs || "")) }
1806
+ ],
1807
+ rules
1808
+ )
1809
+ }
1810
+
1811
+ async function renderJournal() {
1812
+ const journal = await api("GET", "/requests?limit=100")
1813
+ $("journal").innerHTML = table(
1814
+ [
1815
+ { label: "When", cell: (row) => esc(row.at || "") },
1816
+ { label: "Method", cell: (row) => esc(row.method) },
1817
+ { label: "Path", cell: (row) => esc(row.path) },
1818
+ { label: "Status", cell: (row) => esc(row.status) },
1819
+ { label: "ms", cell: (row) => esc(row.durationMs) }
1820
+ ],
1821
+ journal.requests || []
1822
+ )
1823
+ }
1824
+
1825
+ async function renderRoutes() {
1826
+ const data = await api("GET", "/")
1827
+ const routes = data.routes || []
1828
+ $("route-list").innerHTML = routes.map((route) => {
1829
+ const extra = STANDARD.has(route) ? "" : " <span class='badge'>extension</span>"
1830
+ return '<div class="route"><span class="method">' + esc(route.split(" ")[0]) +
1831
+ '</span><span>' + esc(route.split(" ").slice(1).join(" ")) + extra +
1832
+ '</span><button class="btn" type="button" data-route="' + esc(route) + '">Use</button></div>'
1833
+ }).join("")
1834
+ }
1835
+
1836
+ const renderers = {
1837
+ overview: renderOverview,
1838
+ state: renderState,
1839
+ clock: renderClock,
1840
+ faults: renderFaults,
1841
+ journal: renderJournal,
1842
+ routes: renderRoutes
1843
+ }
1844
+
1845
+ async function refresh() {
1846
+ clearError()
1847
+ if (state.view in renderers) await renderers[state.view]()
1848
+ }
1849
+
1850
+ $("namespace").addEventListener("change", () => {
1851
+ state.namespace = $("namespace").value
1852
+ refresh().catch(showError)
1853
+ })
1854
+ keyInput.addEventListener("change", () => {
1855
+ state.key = keyInput.value.trim()
1856
+ sessionStorage.setItem("mockingbird-admin-key", state.key)
1857
+ refresh().catch(showError)
1858
+ })
1859
+ $("refresh").addEventListener("click", () => refresh().catch(showError))
1860
+
1861
+ $("collections").addEventListener("click", (event) => {
1862
+ const button = event.target.closest("[data-collection]")
1863
+ if (!button) return
1864
+ state.collection = button.dataset.collection
1865
+ renderState().catch(showError)
1866
+ })
1867
+ $("records").addEventListener("click", (event) => {
1868
+ const row = event.target.closest("[data-id]")
1869
+ if (!row) return
1870
+ openEditor(row.dataset.id).catch(showError)
1871
+ })
1872
+
1873
+ const editor = $("editor")
1874
+ async function openEditor(id) {
1875
+ state.editing = id || null
1876
+ $("editor-title").textContent = id ? "Edit record" : "New record"
1877
+ $("editor-id").value = id || ""
1878
+ $("editor-id").readOnly = Boolean(id)
1879
+ $("editor-delete").hidden = !id
1880
+ if (id) {
1881
+ const record = await api("GET", "/state/" + encodeURIComponent(state.collection) + "/" + encodeURIComponent(id))
1882
+ $("editor-value").value = JSON.stringify(record.value, null, 2)
1883
+ } else {
1884
+ $("editor-value").value = "{\\n \\n}"
1885
+ }
1886
+ editor.showModal()
1887
+ }
1888
+ $("new-record").addEventListener("click", () => openEditor(null).catch(showError))
1889
+ $("editor-form").addEventListener("submit", (event) => {
1890
+ const submitter = event.submitter
1891
+ if (!submitter || submitter.value !== "save") return
1892
+ event.preventDefault()
1893
+ let value
1894
+ try { value = JSON.parse($("editor-value").value) }
1895
+ catch { showError(new Error("Value is not JSON")); return }
1896
+ const id = $("editor-id").value.trim()
1897
+ const path = "/state/" + encodeURIComponent(state.collection)
1898
+ const request = state.editing
1899
+ ? api("PUT", path + "/" + encodeURIComponent(id), { value })
1900
+ : api("POST", path, { id: id || undefined, value })
1901
+ request.then(() => { editor.close(); return renderState() }).catch(showError)
1902
+ })
1903
+ $("editor-delete").addEventListener("click", () => {
1904
+ const id = state.editing
1905
+ if (!id) return
1906
+ api("DELETE", "/state/" + encodeURIComponent(state.collection) + "/" + encodeURIComponent(id))
1907
+ .then(() => { editor.close(); return renderState() })
1908
+ .catch(showError)
1909
+ })
1910
+
1911
+ document.querySelectorAll("[data-advance]").forEach((button) => {
1912
+ button.addEventListener("click", () => {
1913
+ api("POST", "/clock", { advance: Number(button.dataset.advance) }).then(renderClock).catch(showError)
1914
+ })
1915
+ })
1916
+ $("freeze").addEventListener("click", () => {
1917
+ const frozen = $("freeze").dataset.frozen === "1"
1918
+ api("POST", "/clock", { freeze: !frozen }).then(renderClock).catch(showError)
1919
+ })
1920
+ $("clock-reset").addEventListener("click", () => {
1921
+ api("POST", "/clock", { reset: true }).then(renderClock).catch(showError)
1922
+ })
1923
+ $("clock-set").addEventListener("submit", (event) => {
1924
+ event.preventDefault()
1925
+ const raw = $("clock-instant").value.trim()
1926
+ const set = /^-?\\d+$/.test(raw) ? Number(raw) : raw
1927
+ api("POST", "/clock", { set }).then(renderClock).catch(showError)
1928
+ })
1929
+
1930
+ $("fault-form").addEventListener("submit", (event) => {
1931
+ event.preventDefault()
1932
+ const preset = $("fault-preset").value
1933
+ const body = preset
1934
+ ? { preset }
1935
+ : {
1936
+ status: Number($("fault-status").value),
1937
+ ...($("fault-path").value ? { pathPrefix: $("fault-path").value } : {}),
1938
+ ...($("fault-method").value ? { method: $("fault-method").value } : {})
1939
+ }
1940
+ if (!preset && !Number.isFinite(body.status)) {
1941
+ showError(new Error("Status must be a number, or choose a preset"))
1942
+ return
1943
+ }
1944
+ api("POST", "/faults", body).then(renderFaults).catch(showError)
1945
+ })
1946
+ $("fault-clear").addEventListener("click", () => api("DELETE", "/faults").then(renderFaults).catch(showError))
1947
+ $("journal-clear").addEventListener("click", () => api("DELETE", "/requests").then(renderJournal).catch(showError))
1948
+
1949
+ $("route-list").addEventListener("click", (event) => {
1950
+ const button = event.target.closest("[data-route]")
1951
+ if (!button) return
1952
+ state.route = button.dataset.route
1953
+ $("route-title").textContent = state.route
1954
+ const method = state.route.split(" ")[0]
1955
+ $("route-body").disabled = method === "GET" || method === "DELETE"
1956
+ })
1957
+ $("route-form").addEventListener("submit", async (event) => {
1958
+ event.preventDefault()
1959
+ if (!state.route) return showError(new Error("Choose a route first"))
1960
+ const [method, path] = [state.route.split(" ")[0], state.route.split(" ").slice(1).join(" ")]
1961
+ const concrete = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => prompt("Value for " + key) || "")
1962
+ let body
1963
+ if (method !== "GET" && method !== "DELETE" && $("route-body").value.trim()) {
1964
+ try { body = JSON.parse($("route-body").value) }
1965
+ catch { return showError(new Error("Body is not JSON")) }
1966
+ }
1967
+ try {
1968
+ const data = await api(method, concrete, body)
1969
+ $("route-result").textContent = JSON.stringify(data, null, 2)
1970
+ } catch (error) {
1971
+ showError(error)
1972
+ }
1973
+ })
1974
+
1975
+ function mountPanels(panels) {
1976
+ const nav = $("nav")
1977
+ const views = $("views")
1978
+ for (const panel of panels) {
1979
+ const button = document.createElement("button")
1980
+ button.type = "button"
1981
+ button.dataset.view = panel.id
1982
+ button.textContent = panel.title
1983
+ nav.append(button)
1984
+ const section = document.createElement("section")
1985
+ section.className = "view"
1986
+ section.id = "view-" + panel.id
1987
+ section.hidden = true
1988
+ const title = document.createElement("h2")
1989
+ title.textContent = panel.title
1990
+ const lede = document.createElement("p")
1991
+ lede.className = "lede"
1992
+ lede.textContent = panel.description || ""
1993
+ const body = document.createElement("div")
1994
+ body.className = "panel-body"
1995
+ body.id = "panel-" + panel.id
1996
+ body.innerHTML = panel.html || ""
1997
+ section.append(title, lede, body)
1998
+ views.append(section)
1999
+ if (panel.script) {
2000
+ try {
2001
+ const run = new Function("root", "api", panel.script)
2002
+ run(body, panelApi)
2003
+ } catch (error) {
2004
+ const note = document.createElement("p")
2005
+ note.className = "banner show"
2006
+ note.textContent = error instanceof Error ? error.message : String(error)
2007
+ body.append(note)
2008
+ }
2009
+ }
2010
+ }
2011
+ }
2012
+
2013
+ loadNamespaces()
2014
+ .then(() => api("GET", "/ui/manifest"))
2015
+ .then((manifest) => { mountPanels((manifest && manifest.panels) || []); return refresh() })
2016
+ .catch(showError)
2017
+ })()
2018
+ </script>
2019
+ </body>
2020
+ </html>`;
2021
+ };
2022
+ var htmlResponse = (body) => new Response(body, {
2023
+ status: 200,
2024
+ headers: {
2025
+ "content-type": "text/html; charset=utf-8",
2026
+ "cache-control": "no-store"
2027
+ }
2028
+ });
2029
+ var json2 = (body) => new Response(JSON.stringify(body), {
2030
+ status: 200,
2031
+ headers: { "content-type": "application/json; charset=utf-8" }
2032
+ });
2033
+ var adminUiRoutes = (service, ui) => {
2034
+ const shell = () => renderAdminDocument(service);
2035
+ const document2 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
2036
+ return {
2037
+ "GET /ui": () => htmlResponse(document2()),
2038
+ "GET /ui/": () => htmlResponse(document2()),
2039
+ "GET /ui/manifest": () => json2({
2040
+ service,
2041
+ panels: (ui?.panels ?? []).map((panel) => ({
2042
+ id: panel.id,
2043
+ title: panel.title,
2044
+ ...panel.description !== void 0 ? { description: panel.description } : {},
2045
+ html: panel.html,
2046
+ ...panel.script !== void 0 ? { script: panel.script } : {}
2047
+ }))
2048
+ })
2049
+ };
2050
+ };
2051
+
1120
2052
  // ../../openapi/metadata/dist/types.js
1121
2053
  var EXTENSION_KEYS = {
1122
2054
  operation: "x-mockingbird",
@@ -1280,8 +2212,442 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1280
2212
  });
1281
2213
  };
1282
2214
 
2215
+ // ../core/dist/state-view.js
2216
+ var COLLECTION_NAME = /^[A-Za-z0-9_.@:-]{1,200}$/;
2217
+ var RECORD_ID = /^[^\s/]{1,256}$/;
2218
+ var SAMPLE = 25;
2219
+ var WALK_LIMIT = 1e4;
2220
+ var SKIP_CTORS = /* @__PURE__ */ new Set(["Hono", "HonoRequest", "Router"]);
2221
+ var fail = (status, message) => ({ ok: false, status, message });
2222
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2223
+ var kindOf = (value) => {
2224
+ if (value === null)
2225
+ return "null";
2226
+ if (Array.isArray(value))
2227
+ return "array";
2228
+ switch (typeof value) {
2229
+ case "string":
2230
+ return "string";
2231
+ case "number":
2232
+ return Number.isFinite(value) ? "number" : "unknown";
2233
+ case "boolean":
2234
+ return "boolean";
2235
+ case "object":
2236
+ return "object";
2237
+ default:
2238
+ return "unknown";
2239
+ }
2240
+ };
2241
+ var humanize = (name) => {
2242
+ const words = name.replace(/[@:_-]+/g, " ").trim();
2243
+ if (words === "")
2244
+ return name;
2245
+ return words.charAt(0).toUpperCase() + words.slice(1);
2246
+ };
2247
+ var jsonValue = (value) => {
2248
+ if (value === void 0)
2249
+ return fail(400, "value is required");
2250
+ try {
2251
+ return { ok: true, value: JSON.parse(JSON.stringify(value)) };
2252
+ } catch {
2253
+ return fail(400, "value must be JSON");
2254
+ }
2255
+ };
2256
+ var ctorName = (value) => {
2257
+ const ctor = value.constructor;
2258
+ return typeof ctor?.name === "string" ? ctor.name : "";
2259
+ };
2260
+ var isSqlite = (value) => "prepare" in value && typeof value.prepare === "function" && "transaction" in value;
2261
+ var discoverCollections = (root) => {
2262
+ const found = [];
2263
+ const seen = /* @__PURE__ */ new Set();
2264
+ const visit = (value, depth) => {
2265
+ if (found.length + seen.size > WALK_LIMIT)
2266
+ return;
2267
+ if (value === null || typeof value !== "object")
2268
+ return;
2269
+ if (seen.has(value))
2270
+ return;
2271
+ seen.add(value);
2272
+ if (value instanceof Collection) {
2273
+ found.push(value);
2274
+ return;
2275
+ }
2276
+ if (value instanceof Map) {
2277
+ for (const child of value.values())
2278
+ visit(child, depth + 1);
2279
+ return;
2280
+ }
2281
+ if (value instanceof Set) {
2282
+ for (const child of value.values())
2283
+ visit(child, depth + 1);
2284
+ return;
2285
+ }
2286
+ if (depth > 6 || isSqlite(value) || SKIP_CTORS.has(ctorName(value)))
2287
+ return;
2288
+ if (Array.isArray(value)) {
2289
+ const cap = Math.min(value.length, 32);
2290
+ for (let i = 0; i < cap; i++)
2291
+ visit(value[i], depth + 1);
2292
+ return;
2293
+ }
2294
+ let children;
2295
+ try {
2296
+ children = Object.values(value);
2297
+ } catch {
2298
+ return;
2299
+ }
2300
+ for (const child of children)
2301
+ visit(child, depth + 1);
2302
+ };
2303
+ visit(root, 0);
2304
+ return found;
2305
+ };
2306
+ var storedNames = (sqlite, namespace) => {
2307
+ const records = sqlite.prepare("SELECT DISTINCT collection AS name FROM mockingbird_records WHERE namespace = ?").all(namespace);
2308
+ const sequences = sqlite.prepare("SELECT DISTINCT name FROM mockingbird_sequences WHERE namespace = ? AND kind = 'collection'").all(namespace);
2309
+ return [...records, ...sequences].map((row2) => row2.name);
2310
+ };
2311
+ var blankAccum = (name) => ({
2312
+ name,
2313
+ kinds: /* @__PURE__ */ new Set(),
2314
+ present: 0,
2315
+ nested: /* @__PURE__ */ new Map()
2316
+ });
2317
+ var absorb = (accum, value) => {
2318
+ const kind = kindOf(value);
2319
+ accum.kinds.add(kind);
2320
+ accum.present++;
2321
+ if (!isRecord3(value))
2322
+ return;
2323
+ for (const [key, child] of Object.entries(value)) {
2324
+ const nested = accum.nested.get(key) ?? blankAccum(key);
2325
+ absorb(nested, child);
2326
+ accum.nested.set(key, nested);
2327
+ }
2328
+ };
2329
+ var finishField = (accum, samples) => {
2330
+ const kinds = [...accum.kinds];
2331
+ const kind = kinds.length === 1 ? kinds[0] : "unknown";
2332
+ const field = {
2333
+ name: accum.name,
2334
+ kind,
2335
+ optional: accum.present < samples || kinds.includes("null")
2336
+ };
2337
+ if (accum.nested.size > 0) {
2338
+ field.fields = [...accum.nested.values()].sort((a, b) => a.name.localeCompare(b.name)).map((child) => finishField(child, accum.present));
2339
+ }
2340
+ return field;
2341
+ };
2342
+ var inferFields = (records) => {
2343
+ if (records.length === 0)
2344
+ return [];
2345
+ const sample = records.slice(0, SAMPLE);
2346
+ const objects = sample.filter((record) => isRecord3(record.value));
2347
+ if (objects.length === 0) {
2348
+ const kinds = new Set(sample.map((record) => kindOf(record.value)));
2349
+ const list = [...kinds];
2350
+ return [
2351
+ {
2352
+ name: "(value)",
2353
+ kind: list.length === 1 ? list[0] : "unknown",
2354
+ optional: false
2355
+ }
2356
+ ];
2357
+ }
2358
+ const root = /* @__PURE__ */ new Map();
2359
+ for (const record of objects) {
2360
+ for (const [key, value] of Object.entries(record.value)) {
2361
+ const accum = root.get(key) ?? blankAccum(key);
2362
+ absorb(accum, value);
2363
+ root.set(key, accum);
2364
+ }
2365
+ }
2366
+ return [...root.values()].sort((a, b) => a.name.localeCompare(b.name)).map((accum) => finishField(accum, objects.length));
2367
+ };
2368
+ var mergeFields = (declared, inferred) => {
2369
+ const byName = /* @__PURE__ */ new Map();
2370
+ for (const field of inferred)
2371
+ byName.set(field.name, field);
2372
+ const ordered = [];
2373
+ const seen = /* @__PURE__ */ new Set();
2374
+ for (const field of declared ?? []) {
2375
+ const found = byName.get(field.name);
2376
+ seen.add(field.name);
2377
+ if (!found) {
2378
+ ordered.push({ ...field, optional: field.optional });
2379
+ continue;
2380
+ }
2381
+ ordered.push({
2382
+ ...found,
2383
+ kind: field.kind === "unknown" ? found.kind : field.kind,
2384
+ optional: field.optional || found.optional,
2385
+ ...field.description !== void 0 ? { description: field.description } : {},
2386
+ ...field.meta !== void 0 ? { meta: field.meta } : {},
2387
+ ...field.fields !== void 0 ? { fields: mergeFields(field.fields, found.fields ?? []) } : {}
2388
+ });
2389
+ }
2390
+ for (const field of inferred) {
2391
+ if (!seen.has(field.name))
2392
+ ordered.push(field);
2393
+ }
2394
+ return ordered;
2395
+ };
2396
+ var collectionOf = (sqlite, namespace, name) => new Collection(sqlite, namespace, name);
2397
+ var readRecord = (collection, id) => {
2398
+ const rows = collection.list({ where: (_value, _seq) => true, order: "oldest" });
2399
+ const row2 = rows.find((entry) => entry.id === id);
2400
+ return row2 ? { id: row2.id, seq: row2.seq, value: row2.value } : void 0;
2401
+ };
2402
+ var inspectState = (scope) => {
2403
+ const declared = /* @__PURE__ */ new Map();
2404
+ for (const declaration of scope.declarations ?? []) {
2405
+ if (COLLECTION_NAME.test(declaration.name))
2406
+ declared.set(declaration.name, declaration);
2407
+ }
2408
+ const names = new Set(declared.keys());
2409
+ for (const name of storedNames(scope.sqlite, scope.storageNamespace))
2410
+ names.add(name);
2411
+ for (const collection of discoverCollections(scope.root)) {
2412
+ if (collection.namespace === scope.storageNamespace)
2413
+ names.add(collection.collectionName);
2414
+ }
2415
+ const collections = [];
2416
+ for (const name of names) {
2417
+ if (!COLLECTION_NAME.test(name))
2418
+ continue;
2419
+ const declaration = declared.get(name);
2420
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2421
+ const records = collection.list({ order: "newest" }).slice(0, SAMPLE).map((row2) => ({ id: row2.id, seq: row2.seq, value: row2.value }));
2422
+ const inferred = inferFields(records);
2423
+ const fields = mergeFields(declaration?.fields, inferred);
2424
+ const source = declaration && records.length > 0 ? "mixed" : declaration ? "declared" : "inferred";
2425
+ collections.push({
2426
+ name,
2427
+ label: declaration?.label ?? humanize(name),
2428
+ ...declaration?.description !== void 0 ? { description: declaration.description } : {},
2429
+ count: collection.count(),
2430
+ source,
2431
+ fields,
2432
+ ...declaration?.meta !== void 0 ? { meta: declaration.meta } : {}
2433
+ });
2434
+ }
2435
+ const order = /* @__PURE__ */ new Map();
2436
+ for (const [index, item] of (scope.declarations ?? []).entries())
2437
+ order.set(item.name, index);
2438
+ collections.sort((a, b) => {
2439
+ const left = order.get(a.name);
2440
+ const right = order.get(b.name);
2441
+ if (left !== void 0 && right !== void 0)
2442
+ return left - right;
2443
+ if (left !== void 0)
2444
+ return -1;
2445
+ if (right !== void 0)
2446
+ return 1;
2447
+ return a.name.localeCompare(b.name);
2448
+ });
2449
+ return {
2450
+ namespace: scope.namespace,
2451
+ storageNamespace: scope.storageNamespace,
2452
+ collections
2453
+ };
2454
+ };
2455
+ var pageOf = (sqlite, storageNamespace, name, query) => {
2456
+ if (!COLLECTION_NAME.test(name))
2457
+ return fail(400, "collection name is not allowed");
2458
+ const limit = query.limit ?? 50;
2459
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
2460
+ return fail(400, "limit must be an integer from 1 to 200");
2461
+ }
2462
+ const order = query.order ?? "newest";
2463
+ if (order !== "newest" && order !== "oldest")
2464
+ return fail(400, 'order must be "newest" or "oldest"');
2465
+ const collection = collectionOf(sqlite, storageNamespace, name);
2466
+ const rows = collection.list({ order }).filter((row2) => {
2467
+ if (query.after === void 0)
2468
+ return true;
2469
+ return order === "newest" ? row2.seq < query.after : row2.seq > query.after;
2470
+ });
2471
+ const slice = rows.slice(0, limit);
2472
+ const last = slice.at(-1);
2473
+ return {
2474
+ ok: true,
2475
+ value: {
2476
+ collection: name,
2477
+ count: collection.count(),
2478
+ records: slice.map((row2) => ({ id: row2.id, seq: row2.seq, value: row2.value })),
2479
+ next: rows.length > limit && last ? last.seq : null
2480
+ }
2481
+ };
2482
+ };
2483
+ var readStatePage = (scope, name, query = {}) => pageOf(scope.sqlite, scope.storageNamespace, name, query);
2484
+ var readStateRecord = (scope, name, id) => {
2485
+ if (!COLLECTION_NAME.test(name))
2486
+ return fail(400, "collection name is not allowed");
2487
+ if (!RECORD_ID.test(id))
2488
+ return fail(400, "record id is not allowed");
2489
+ const record = readRecord(collectionOf(scope.sqlite, scope.storageNamespace, name), id);
2490
+ return record ? { ok: true, value: record } : fail(404, `no record ${id} in ${name}`);
2491
+ };
2492
+ var requireId = (id) => RECORD_ID.test(id) ? { ok: true, value: id } : fail(400, "id must be a single path segment");
2493
+ var createStateRecord = (scope, name, input) => {
2494
+ if (!COLLECTION_NAME.test(name))
2495
+ return fail(400, "collection name is not allowed");
2496
+ const parsed = jsonValue(input.value);
2497
+ if (!parsed.ok)
2498
+ return parsed;
2499
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2500
+ const id = input.id ?? `${name}_${collection.nextSequence()}`;
2501
+ const checked = requireId(id);
2502
+ if (!checked.ok)
2503
+ return checked;
2504
+ if (collection.has(checked.value))
2505
+ return fail(409, `record ${checked.value} already exists`);
2506
+ const stored = collection.insert(checked.value, parsed.value);
2507
+ return { ok: true, value: { id: checked.value, seq: stored.seq, value: stored.value } };
2508
+ };
2509
+ var replaceStateRecord = (scope, name, id, value) => {
2510
+ if (!COLLECTION_NAME.test(name))
2511
+ return fail(400, "collection name is not allowed");
2512
+ const checked = requireId(id);
2513
+ if (!checked.ok)
2514
+ return checked;
2515
+ const parsed = jsonValue(value);
2516
+ if (!parsed.ok)
2517
+ return parsed;
2518
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2519
+ if (!collection.has(checked.value))
2520
+ return fail(404, `no record ${checked.value} in ${name}`);
2521
+ const stored = collection.update(checked.value, parsed.value);
2522
+ if (!stored)
2523
+ return fail(404, `no record ${checked.value} in ${name}`);
2524
+ return { ok: true, value: { id: checked.value, seq: stored.seq, value: stored.value } };
2525
+ };
2526
+ var patchStateRecord = (scope, name, id, value) => {
2527
+ const current = readStateRecord(scope, name, id);
2528
+ if (!current.ok)
2529
+ return current;
2530
+ const parsed = jsonValue(value);
2531
+ if (!parsed.ok)
2532
+ return parsed;
2533
+ const next = isRecord3(current.value.value) && isRecord3(parsed.value) ? { ...current.value.value, ...parsed.value } : parsed.value;
2534
+ return replaceStateRecord(scope, name, id, next);
2535
+ };
2536
+ var deleteStateRecord = (scope, name, id) => {
2537
+ if (!COLLECTION_NAME.test(name))
2538
+ return fail(400, "collection name is not allowed");
2539
+ const checked = requireId(id);
2540
+ if (!checked.ok)
2541
+ return checked;
2542
+ const collection = collectionOf(scope.sqlite, scope.storageNamespace, name);
2543
+ return collection.delete(checked.value) ? { ok: true, value: { id: checked.value } } : fail(404, `no record ${checked.value} in ${name}`);
2544
+ };
2545
+ var json3 = (status, body) => new Response(JSON.stringify(body), {
2546
+ status,
2547
+ headers: { "content-type": "application/json; charset=utf-8" }
2548
+ });
2549
+ var adminError2 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2550
+ var fromResult = (result, status) => result.ok ? json3(status, result.value) : adminError2(result.status, result.message);
2551
+ var integerParam = (value) => {
2552
+ if (value === null)
2553
+ return void 0;
2554
+ if (!/^-?\d+$/.test(value))
2555
+ return "bad";
2556
+ return Number(value);
2557
+ };
2558
+ var stateAdminRoutes = (routes) => {
2559
+ const open = (namespace) => {
2560
+ try {
2561
+ return { ok: true, value: routes.open(namespace) };
2562
+ } catch (error) {
2563
+ return fail(400, error instanceof Error ? error.message : String(error));
2564
+ }
2565
+ };
2566
+ const wrote = (namespace) => {
2567
+ routes.afterWrite?.(namespace);
2568
+ };
2569
+ return {
2570
+ "GET /state": ({ namespace }) => {
2571
+ const scope = open(namespace);
2572
+ return fromResult(scope.ok ? { ok: true, value: inspectState(scope.value) } : scope, 200);
2573
+ },
2574
+ "GET /state/:collection": ({ namespace, params, url }) => {
2575
+ const scope = open(namespace);
2576
+ if (!scope.ok)
2577
+ return fromResult(scope, 200);
2578
+ const limit = integerParam(url.searchParams.get("limit"));
2579
+ const after = integerParam(url.searchParams.get("after"));
2580
+ if (limit === "bad")
2581
+ return adminError2(400, "limit: expected an integer");
2582
+ if (after === "bad")
2583
+ return adminError2(400, "after: expected a sequence number");
2584
+ const order = url.searchParams.get("order");
2585
+ return fromResult(readStatePage(scope.value, params.collection ?? "", {
2586
+ ...limit !== void 0 ? { limit } : {},
2587
+ ...after !== void 0 ? { after } : {},
2588
+ ...order === "newest" || order === "oldest" ? { order } : {}
2589
+ }), 200);
2590
+ },
2591
+ "GET /state/:collection/:id": ({ namespace, params }) => {
2592
+ const scope = open(namespace);
2593
+ if (!scope.ok)
2594
+ return fromResult(scope, 200);
2595
+ return fromResult(readStateRecord(scope.value, params.collection ?? "", params.id ?? ""), 200);
2596
+ },
2597
+ "POST /state/:collection": ({ namespace, params, body }) => {
2598
+ const scope = open(namespace);
2599
+ if (!scope.ok)
2600
+ return fromResult(scope, 201);
2601
+ if (!isRecord3(body) || !("value" in body)) {
2602
+ return adminError2(400, 'expected {"id?": "<id>", "value": <json>}');
2603
+ }
2604
+ const id = body.id;
2605
+ if (id !== void 0 && typeof id !== "string")
2606
+ return adminError2(400, "id must be a string");
2607
+ const created = createStateRecord(scope.value, params.collection ?? "", {
2608
+ ...typeof id === "string" ? { id } : {},
2609
+ value: body.value
2610
+ });
2611
+ if (created.ok)
2612
+ wrote(namespace);
2613
+ return fromResult(created, 201);
2614
+ },
2615
+ "PUT /state/:collection/:id": ({ namespace, params, body }) => {
2616
+ const scope = open(namespace);
2617
+ if (!scope.ok)
2618
+ return fromResult(scope, 200);
2619
+ if (!isRecord3(body) || !("value" in body))
2620
+ return adminError2(400, 'expected {"value": <json>}');
2621
+ const replaced = replaceStateRecord(scope.value, params.collection ?? "", params.id ?? "", body.value);
2622
+ if (replaced.ok)
2623
+ wrote(namespace);
2624
+ return fromResult(replaced, 200);
2625
+ },
2626
+ "PATCH /state/:collection/:id": ({ namespace, params, body }) => {
2627
+ const scope = open(namespace);
2628
+ if (!scope.ok)
2629
+ return fromResult(scope, 200);
2630
+ if (!isRecord3(body) || !("value" in body))
2631
+ return adminError2(400, 'expected {"value": <json>}');
2632
+ const patched = patchStateRecord(scope.value, params.collection ?? "", params.id ?? "", body.value);
2633
+ if (patched.ok)
2634
+ wrote(namespace);
2635
+ return fromResult(patched, 200);
2636
+ },
2637
+ "DELETE /state/:collection/:id": ({ namespace, params }) => {
2638
+ const scope = open(namespace);
2639
+ if (!scope.ok)
2640
+ return fromResult(scope, 200);
2641
+ const removed = deleteStateRecord(scope.value, params.collection ?? "", params.id ?? "");
2642
+ if (removed.ok)
2643
+ wrote(namespace);
2644
+ return fromResult(removed, 200);
2645
+ }
2646
+ };
2647
+ };
2648
+
1283
2649
  // ../core/dist/version.js
1284
- var PACKAGE_VERSION = true ? "1.4.0" : UNRELEASED_VERSION;
2650
+ var PACKAGE_VERSION = true ? "1.5.0" : UNRELEASED_VERSION;
1285
2651
 
1286
2652
  // ../core/dist/signing.js
1287
2653
  var encoder = new TextEncoder();
@@ -1295,11 +2661,11 @@ var toBase64 = (bytes) => {
1295
2661
  var fromBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
1296
2662
 
1297
2663
  // ../core/dist/webhooks.js
1298
- var json2 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
1299
- var adminError2 = (status, message) => json2(status, { error: { type: "mockingbird_admin", message } });
1300
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2664
+ var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
2665
+ var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
2666
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1301
2667
  var parseEndpoint = (value) => {
1302
- if (!isRecord3(value) || typeof value.url !== "string")
2668
+ if (!isRecord4(value) || typeof value.url !== "string")
1303
2669
  return "each endpoint needs a url";
1304
2670
  try {
1305
2671
  new URL(value.url);
@@ -1316,18 +2682,18 @@ var parseEndpoint = (value) => {
1316
2682
  const events = value.events ?? value.enabledEvents;
1317
2683
  if (Array.isArray(events))
1318
2684
  endpoint.events = events.map(String);
1319
- if (isRecord3(value.tags)) {
2685
+ if (isRecord4(value.tags)) {
1320
2686
  endpoint.tags = Object.fromEntries(Object.entries(value.tags).map(([k, v]) => [k, String(v)]));
1321
2687
  }
1322
2688
  if (typeof value.account === "string")
1323
2689
  endpoint.tags = { ...endpoint.tags, account: value.account };
1324
- if (isRecord3(value.headers)) {
2690
+ if (isRecord4(value.headers)) {
1325
2691
  endpoint.headers = Object.fromEntries(Object.entries(value.headers).map(([k, v]) => [k, String(v)]));
1326
2692
  }
1327
2693
  return endpoint;
1328
2694
  };
1329
2695
  var webhookAdminRoutes = (hub) => ({
1330
- "GET /webhooks": ({ url, namespace }) => json2(200, {
2696
+ "GET /webhooks": ({ url, namespace }) => json4(200, {
1331
2697
  deliveries: hub.deliveries(url.searchParams.get("all") === "1" ? void 0 : namespace).filter((d) => {
1332
2698
  const type = url.searchParams.get("type");
1333
2699
  return type === null || d.type === type;
@@ -1335,51 +2701,51 @@ var webhookAdminRoutes = (hub) => ({
1335
2701
  }),
1336
2702
  "GET /webhooks/events": ({ url, namespace }) => {
1337
2703
  const type = url.searchParams.get("type");
1338
- return json2(200, {
2704
+ return json4(200, {
1339
2705
  events: hub.messages(url.searchParams.get("all") === "1" ? void 0 : namespace).filter((m) => type === null || m.type === type).map((m) => ({ ...m, payload: parsePayload(m) }))
1340
2706
  });
1341
2707
  },
1342
2708
  "POST /webhooks/:id/replay": async ({ params }) => {
1343
2709
  const replayed = await hub.replay(params.id);
1344
- return replayed ? json2(200, replayed) : adminError2(404, `no delivery ${params.id}`);
2710
+ return replayed ? json4(200, replayed) : adminError3(404, `no delivery ${params.id}`);
1345
2711
  },
1346
2712
  "POST /webhooks/flush": async () => {
1347
2713
  await hub.flush();
1348
- return json2(200, { status: "ok" });
2714
+ return json4(200, { status: "ok" });
1349
2715
  },
1350
2716
  "POST /webhooks/faults": ({ body, namespace }) => {
1351
- if (!isRecord3(body) || !["duplicate", "reorder", "drop"].includes(String(body.mode))) {
1352
- return adminError2(400, "mode must be duplicate, reorder or drop");
2717
+ if (!isRecord4(body) || !["duplicate", "reorder", "drop"].includes(String(body.mode))) {
2718
+ return adminError3(400, "mode must be duplicate, reorder or drop");
1353
2719
  }
1354
2720
  const fault = { mode: body.mode };
1355
2721
  if (typeof body.count === "number")
1356
2722
  fault.count = body.count;
1357
2723
  hub.fault(namespace, fault);
1358
- return json2(201, { namespace, ...fault });
2724
+ return json4(201, { namespace, ...fault });
1359
2725
  },
1360
- "GET /webhook-endpoints": ({ namespace }) => json2(200, {
2726
+ "GET /webhook-endpoints": ({ namespace }) => json4(200, {
1361
2727
  endpoints: hub.endpoints(namespace).map(({ secret, ...rest }) => ({
1362
2728
  ...rest,
1363
2729
  secret: secret ? "(set)" : null
1364
2730
  }))
1365
2731
  }),
1366
2732
  "PUT /webhook-endpoints": ({ body, namespace }) => {
1367
- const list = Array.isArray(body) ? body : isRecord3(body) ? body.endpoints : void 0;
2733
+ const list = Array.isArray(body) ? body : isRecord4(body) ? body.endpoints : void 0;
1368
2734
  if (!Array.isArray(list))
1369
- return adminError2(400, "expected [{url, secret?, events?}]");
2735
+ return adminError3(400, "expected [{url, secret?, events?}]");
1370
2736
  const parsed = [];
1371
2737
  for (const each of list) {
1372
2738
  const endpoint = parseEndpoint(each);
1373
2739
  if (typeof endpoint === "string")
1374
- return adminError2(400, endpoint);
2740
+ return adminError3(400, endpoint);
1375
2741
  parsed.push(endpoint);
1376
2742
  }
1377
2743
  const set = hub.setEndpoints(namespace, parsed);
1378
- return json2(200, { endpoints: set.map((e) => ({ ...e, secret: e.secret ? "(set)" : null })) });
2744
+ return json4(200, { endpoints: set.map((e) => ({ ...e, secret: e.secret ? "(set)" : null })) });
1379
2745
  },
1380
2746
  "DELETE /webhook-endpoints": ({ namespace }) => {
1381
2747
  hub.setEndpoints(namespace, []);
1382
- return json2(200, { status: "ok" });
2748
+ return json4(200, { status: "ok" });
1383
2749
  }
1384
2750
  });
1385
2751
  var parsePayload = (message) => {
@@ -1444,6 +2810,7 @@ var operationMatcher = (document2) => {
1444
2810
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
1445
2811
  };
1446
2812
  var createRuntime = (options) => {
2813
+ assertAdminUi(options.adminUi);
1447
2814
  const sqlite = bootSqlite(options.sqlite);
1448
2815
  const clock = options.clock ?? createClock();
1449
2816
  const rng = createRng(options.seed ?? 0);
@@ -1650,6 +3017,19 @@ var createRuntime = (options) => {
1650
3017
  else
1651
3018
  timeline(name);
1652
3019
  };
3020
+ const declarationsFor = (namespace) => {
3021
+ const declared = options.state;
3022
+ if (!declared)
3023
+ return [];
3024
+ return typeof declared === "function" ? declared(instance(namespace), namespace) : declared;
3025
+ };
3026
+ const stateScope = (namespace) => ({
3027
+ sqlite,
3028
+ namespace,
3029
+ storageNamespace: storageNamespace(namespace),
3030
+ root: instance(namespace),
3031
+ declarations: declarationsFor(namespace)
3032
+ });
1653
3033
  const runtime = {
1654
3034
  name: options.name,
1655
3035
  sqlite,
@@ -1688,6 +3068,7 @@ var createRuntime = (options) => {
1688
3068
  branch,
1689
3069
  checkout,
1690
3070
  timeline,
3071
+ state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
1691
3072
  fetch: async (incoming) => {
1692
3073
  let request = incoming;
1693
3074
  const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
@@ -1881,11 +3262,20 @@ var createRuntime = (options) => {
1881
3262
  ...credentialRoutes(credentials),
1882
3263
  ...options.presets ? presetRoutes(options.presets, runtime) : {},
1883
3264
  ...options.webhooks ? webhookAdminRoutes(options.webhooks) : {},
1884
- ...options.admin?.(runtime) ?? {}
3265
+ ...options.admin?.(runtime) ?? {},
3266
+ // Standard state and UI routes win a colliding key. A service adds its own keys
3267
+ // beside these; it does not replace `/state` or `/ui`.
3268
+ ...stateAdminRoutes({
3269
+ open: stateScope,
3270
+ afterWrite: (namespace) => {
3271
+ checkpoint(namespace, "main");
3272
+ }
3273
+ }),
3274
+ ...adminUiRoutes(options.name, options.adminUi)
1885
3275
  },
1886
3276
  adminKey: options.adminKey
1887
3277
  });
1888
- return runtime;
3278
+ return defineMock(runtime);
1889
3279
  };
1890
3280
  var mutableResponse = (response) => {
1891
3281
  try {
@@ -3072,7 +4462,7 @@ var POSTAL_ADDRESS = /* @__PURE__ */ new Set([
3072
4462
  var MAX_INPUT_LENGTH = 280;
3073
4463
  var MAX_ADDRESS_LINES = 3;
3074
4464
  var MAX_ADDRESS_LINE_LENGTH = 80;
3075
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4465
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3076
4466
  var optionalString = (value, field) => {
3077
4467
  if (value === void 0 || value === null) return void 0;
3078
4468
  if (typeof value !== "string") {
@@ -3084,7 +4474,7 @@ var optionalString = (value, field) => {
3084
4474
  return trimmed ? trimmed : void 0;
3085
4475
  };
3086
4476
  var parseValidateRequest = (body) => {
3087
- if (!isRecord4(body)) throw new ValidationRequestError("Invalid JSON payload received.");
4477
+ if (!isRecord5(body)) throw new ValidationRequestError("Invalid JSON payload received.");
3088
4478
  for (const key of Object.keys(body)) {
3089
4479
  if (!TOP_LEVEL.has(key)) {
3090
4480
  throw new ValidationRequestError(
@@ -3096,7 +4486,7 @@ var parseValidateRequest = (body) => {
3096
4486
  if (address === void 0 || address === null) {
3097
4487
  throw new ValidationRequestError("Address is missing from the request.");
3098
4488
  }
3099
- if (!isRecord4(address)) {
4489
+ if (!isRecord5(address)) {
3100
4490
  throw new ValidationRequestError(
3101
4491
  `Invalid value at 'address' (type.googleapis.com/google.type.PostalAddress), ${JSON.stringify(address)}`
3102
4492
  );
@@ -3665,14 +5055,14 @@ var GOOGLE_MAPS_PRESETS = {
3665
5055
  rules: [{ operationId: "MapsJavaScriptApi", status: 503, body: "Service Unavailable" }]
3666
5056
  }
3667
5057
  };
3668
- var json3 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
3669
- var adminError3 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
3670
- var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5058
+ var json5 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5059
+ var adminError4 = (status, message) => json5(status, { error: { type: "mockingbird_admin", message } });
5060
+ var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3671
5061
  var stringList = (value) => Array.isArray(value) && value.every((v) => typeof v === "string") ? value : void 0;
3672
5062
  var parseValidation = (value, index) => {
3673
5063
  if (value === void 0 || value === null) return void 0;
3674
5064
  const at = `addresses[${index}].validation`;
3675
- if (!isRecord5(value)) return `${at} must be an object`;
5065
+ if (!isRecord6(value)) return `${at} must be an object`;
3676
5066
  const out = {};
3677
5067
  const enums = [
3678
5068
  ["granularity", GRANULARITIES],
@@ -3706,7 +5096,7 @@ var parseValidation = (value, index) => {
3706
5096
  return out;
3707
5097
  };
3708
5098
  var parseAddress = (value, index) => {
3709
- if (!isRecord5(value)) return `addresses[${index}] must be an object`;
5099
+ if (!isRecord6(value)) return `addresses[${index}] must be an object`;
3710
5100
  for (const key of ["line1", "city", "state", "zip"]) {
3711
5101
  if (typeof value[key] !== "string" || !value[key].trim()) {
3712
5102
  return `addresses[${index}].${key} must be a non-empty string`;
@@ -3736,15 +5126,15 @@ var parseAddress = (value, index) => {
3736
5126
  var adminRoutes = (runtime) => ({
3737
5127
  "GET /corpus": ({ namespace }) => {
3738
5128
  const api = runtime.instance(namespace);
3739
- return json3(200, {
5129
+ return json5(200, {
3740
5130
  addresses: api.corpus(),
3741
5131
  custom: api.state.custom.count()
3742
5132
  });
3743
5133
  },
3744
5134
  "PUT /corpus": ({ body, namespace }) => {
3745
- const list = Array.isArray(body) ? body : isRecord5(body) ? body.addresses : void 0;
5135
+ const list = Array.isArray(body) ? body : isRecord6(body) ? body.addresses : void 0;
3746
5136
  if (!Array.isArray(list)) {
3747
- return adminError3(
5137
+ return adminError4(
3748
5138
  400,
3749
5139
  'expected {"addresses": [{line1, city, state, zip, lat?, lng?, validation?}]}'
3750
5140
  );
@@ -3752,32 +5142,32 @@ var adminRoutes = (runtime) => ({
3752
5142
  const rows = [];
3753
5143
  for (const [index, each] of list.entries()) {
3754
5144
  const parsed = parseAddress(each, index);
3755
- if (typeof parsed === "string") return adminError3(400, parsed);
5145
+ if (typeof parsed === "string") return adminError4(400, parsed);
3756
5146
  rows.push(parsed);
3757
5147
  }
3758
5148
  const api = runtime.instance(namespace);
3759
5149
  api.state.replaceCustom(rows);
3760
- return json3(200, { custom: rows });
5150
+ return json5(200, { custom: rows });
3761
5151
  },
3762
5152
  "DELETE /corpus": ({ namespace }) => {
3763
5153
  runtime.instance(namespace).state.replaceCustom([]);
3764
- return json3(200, { status: "ok" });
5154
+ return json5(200, { status: "ok" });
3765
5155
  },
3766
- "GET /settings": ({ namespace }) => json3(200, runtime.instance(namespace).state.current()),
5156
+ "GET /settings": ({ namespace }) => json5(200, runtime.instance(namespace).state.current()),
3767
5157
  "PUT /settings": ({ body, namespace }) => {
3768
- if (!isRecord5(body)) return adminError3(400, "expected a JSON object");
5158
+ if (!isRecord6(body)) return adminError4(400, "expected a JSON object");
3769
5159
  const patch = {};
3770
5160
  if (body.keys !== void 0) {
3771
- if (!Array.isArray(body.keys)) return adminError3(400, "keys: string[]");
5161
+ if (!Array.isArray(body.keys)) return adminError4(400, "keys: string[]");
3772
5162
  patch.keys = body.keys.map(String);
3773
5163
  }
3774
5164
  if (body.publicUrl !== void 0) {
3775
5165
  if (body.publicUrl !== null && typeof body.publicUrl !== "string") {
3776
- return adminError3(400, "publicUrl: string | null");
5166
+ return adminError4(400, "publicUrl: string | null");
3777
5167
  }
3778
5168
  patch.publicUrl = body.publicUrl;
3779
5169
  }
3780
- return json3(200, runtime.instance(namespace).state.update(patch));
5170
+ return json5(200, runtime.instance(namespace).state.update(patch));
3781
5171
  }
3782
5172
  });
3783
5173
  var createRuntime2 = (options = {}) => createRuntime({
@@ -3812,7 +5202,7 @@ var MISSING_API_KEY_MESSAGE = "The request is missing a valid API key.";
3812
5202
  var INVALID_API_KEY_MESSAGE = "API key not valid. Please pass a valid API key.";
3813
5203
  var keyCredential = (request) => new URL(request.url).searchParams.get("key") ?? request.headers.get("x-goog-api-key") ?? void 0;
3814
5204
  var CORS = { "access-control-allow-origin": "*" };
3815
- var json4 = (body, status = 200) => new Response(JSON.stringify(body, null, 3), {
5205
+ var json6 = (body, status = 200) => new Response(JSON.stringify(body, null, 3), {
3816
5206
  status,
3817
5207
  headers: { "content-type": "application/json; charset=UTF-8", ...CORS }
3818
5208
  });
@@ -3827,7 +5217,7 @@ var DEFAULT_MESSAGES = {
3827
5217
  UNKNOWN_ERROR: "An unknown error occurred. Please try again.",
3828
5218
  REQUEST_DENIED: INVALID_KEY_MESSAGE
3829
5219
  };
3830
- var apiError = (error) => json4({ error }, error.code);
5220
+ var apiError = (error) => json6({ error }, error.code);
3831
5221
  var requestJson = (body) => {
3832
5222
  if (body.kind === "json") return body.value;
3833
5223
  if (body.kind === "empty") return {};
@@ -3892,7 +5282,7 @@ var GoogleMapsAPI = class {
3892
5282
  return this.state.corpus();
3893
5283
  }
3894
5284
  status(operationId, status, message) {
3895
- return json4({
5285
+ return json6({
3896
5286
  ...EMPTY[operationId],
3897
5287
  ...message ? { error_message: message } : {},
3898
5288
  status
@@ -3954,7 +5344,7 @@ var GoogleMapsAPI = class {
3954
5344
  const outcome = validateAddress(request, this.state.corpus(), {
3955
5345
  ...dpvOverride ? { dpvOverride } : {}
3956
5346
  });
3957
- return annotateResponse(json4(outcome.body), {
5347
+ return annotateResponse(json6(outcome.body), {
3958
5348
  ids: {
3959
5349
  ...outcome.rowId ? { addressRowId: outcome.rowId } : {},
3960
5350
  verdict: outcome.verdictClass
@@ -3975,17 +5365,17 @@ var GoogleMapsAPI = class {
3975
5365
  if (components !== void 0) {
3976
5366
  const countries = components.split("|").map((c) => c.trim().toLowerCase()).filter((c) => c.startsWith("country:")).map((c) => c.slice("country:".length));
3977
5367
  if (countries.length > 0 && !countries.includes("us")) {
3978
- return this.noted(json4({ predictions: [], status: "ZERO_RESULTS" }), context);
5368
+ return this.noted(json6({ predictions: [], status: "ZERO_RESULTS" }), context);
3979
5369
  }
3980
5370
  }
3981
5371
  const types = text(context.query.types);
3982
5372
  let places = autocomplete(input, this.state.corpus());
3983
5373
  if (types === "address") places = places.filter((p) => p.kind === "street_address");
3984
5374
  if (places.length === 0) {
3985
- return this.noted(json4({ predictions: [], status: "ZERO_RESULTS" }), context);
5375
+ return this.noted(json6({ predictions: [], status: "ZERO_RESULTS" }), context);
3986
5376
  }
3987
5377
  return this.noted(
3988
- json4({ predictions: places.map((p) => prediction(p, input)), status: "OK" }),
5378
+ json6({ predictions: places.map((p) => prediction(p, input)), status: "OK" }),
3989
5379
  context,
3990
5380
  places[0]
3991
5381
  );
@@ -4004,7 +5394,7 @@ var GoogleMapsAPI = class {
4004
5394
  const place = placeById(placeId, this.state.corpus());
4005
5395
  if (!place) return this.noted(this.status("PlaceDetails", "NOT_FOUND"), context);
4006
5396
  return this.noted(
4007
- json4({
5397
+ json6({
4008
5398
  html_attributions: [],
4009
5399
  result: pickFields(placeResult(place), fields, "all"),
4010
5400
  status: "OK"
@@ -4021,10 +5411,10 @@ var GoogleMapsAPI = class {
4021
5411
  if (!address && !placeId && !zip) return this.status("Geocode", "INVALID_REQUEST");
4022
5412
  const rows = this.state.corpus();
4023
5413
  const place = placeId ? placeById(placeId, rows) : address ? geocode(address, rows) : geocode(zip, rows);
4024
- if (!place) return this.noted(json4({ results: [], status: "ZERO_RESULTS" }), context);
5414
+ if (!place) return this.noted(json6({ results: [], status: "ZERO_RESULTS" }), context);
4025
5415
  const full = placeResult(place);
4026
5416
  return this.noted(
4027
- json4({
5417
+ json6({
4028
5418
  results: [
4029
5419
  {
4030
5420
  address_components: full.address_components,
@@ -4055,9 +5445,9 @@ var GoogleMapsAPI = class {
4055
5445
  );
4056
5446
  }
4057
5447
  const place = inputtype === "textquery" ? findPlace(input, this.state.corpus()) : void 0;
4058
- if (!place) return this.noted(json4({ candidates: [], status: "ZERO_RESULTS" }), context);
5448
+ if (!place) return this.noted(json6({ candidates: [], status: "ZERO_RESULTS" }), context);
4059
5449
  return this.noted(
4060
- json4({ candidates: [pickFields(placeResult(place), fields, ["place_id"])], status: "OK" }),
5450
+ json6({ candidates: [pickFields(placeResult(place), fields, ["place_id"])], status: "OK" }),
4061
5451
  context,
4062
5452
  place
4063
5453
  );
@@ -4100,4 +5490,4 @@ export {
4100
5490
  keyCredential,
4101
5491
  GoogleMapsAPI
4102
5492
  };
4103
- //# sourceMappingURL=chunk-M5RVKYST.js.map
5493
+ //# sourceMappingURL=chunk-33MOWM5L.js.map