covara 0.6.1 → 0.7.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.
Files changed (107) hide show
  1. package/README.md +13 -8
  2. package/assets/logo.svg +19 -0
  3. package/dist/auth/adapter.d.ts +1 -0
  4. package/dist/auth/adapter.d.ts.map +1 -1
  5. package/dist/auth/adapter.js +37 -1
  6. package/dist/auth/adapter.js.map +1 -1
  7. package/dist/auth/routes.d.ts.map +1 -1
  8. package/dist/auth/routes.js +15 -2
  9. package/dist/auth/routes.js.map +1 -1
  10. package/dist/auth/types.d.ts +4 -0
  11. package/dist/auth/types.d.ts.map +1 -1
  12. package/dist/auth/types.js +10 -0
  13. package/dist/auth/types.js.map +1 -1
  14. package/dist/client/aggregate-subscription.d.ts +32 -0
  15. package/dist/client/aggregate-subscription.d.ts.map +1 -0
  16. package/dist/client/aggregate-subscription.js +131 -0
  17. package/dist/client/aggregate-subscription.js.map +1 -0
  18. package/dist/client/index.d.ts +2 -0
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +1 -0
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/client/react.d.ts +25 -1
  23. package/dist/client/react.d.ts.map +1 -1
  24. package/dist/client/react.js +53 -0
  25. package/dist/client/react.js.map +1 -1
  26. package/dist/client/repository.d.ts +2 -0
  27. package/dist/client/repository.d.ts.map +1 -1
  28. package/dist/client/repository.js +9 -0
  29. package/dist/client/repository.js.map +1 -1
  30. package/dist/client/types.d.ts +17 -0
  31. package/dist/client/types.d.ts.map +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/resource/changelog.d.ts +3 -3
  37. package/dist/resource/changelog.d.ts.map +1 -1
  38. package/dist/resource/changelog.js +6 -3
  39. package/dist/resource/changelog.js.map +1 -1
  40. package/dist/resource/hook.d.ts.map +1 -1
  41. package/dist/resource/hook.js +141 -18
  42. package/dist/resource/hook.js.map +1 -1
  43. package/dist/resource/mutate.d.ts +3 -3
  44. package/dist/resource/mutate.d.ts.map +1 -1
  45. package/dist/resource/mutate.js +8 -8
  46. package/dist/resource/mutate.js.map +1 -1
  47. package/dist/resource/query.d.ts +1 -0
  48. package/dist/resource/query.d.ts.map +1 -1
  49. package/dist/resource/query.js +12 -0
  50. package/dist/resource/query.js.map +1 -1
  51. package/dist/resource/subscription.d.ts +18 -1
  52. package/dist/resource/subscription.d.ts.map +1 -1
  53. package/dist/resource/subscription.js +310 -53
  54. package/dist/resource/subscription.js.map +1 -1
  55. package/dist/resource/track-mutations.d.ts.map +1 -1
  56. package/dist/resource/track-mutations.js +1 -1
  57. package/dist/resource/track-mutations.js.map +1 -1
  58. package/dist/resource/types.d.ts +2 -0
  59. package/dist/resource/types.d.ts.map +1 -1
  60. package/dist/server/app.d.ts.map +1 -1
  61. package/dist/server/app.js +28 -3
  62. package/dist/server/app.js.map +1 -1
  63. package/dist/ui/html/layout.d.ts.map +1 -1
  64. package/dist/ui/html/layout.js +5 -1
  65. package/dist/ui/html/layout.js.map +1 -1
  66. package/dist/ui/html/logo.d.ts +2 -0
  67. package/dist/ui/html/logo.d.ts.map +1 -0
  68. package/dist/ui/html/logo.js +23 -0
  69. package/dist/ui/html/logo.js.map +1 -0
  70. package/dist/ui/html/pages/data-explorer.d.ts.map +1 -1
  71. package/dist/ui/html/pages/data-explorer.js +0 -5
  72. package/dist/ui/html/pages/data-explorer.js.map +1 -1
  73. package/dist/ui/html/pages/kv-inspector.d.ts.map +1 -1
  74. package/dist/ui/html/pages/kv-inspector.js +0 -1
  75. package/dist/ui/html/pages/kv-inspector.js.map +1 -1
  76. package/dist/ui/html/pages/sessions.d.ts.map +1 -1
  77. package/dist/ui/html/pages/sessions.js +4 -3
  78. package/dist/ui/html/pages/sessions.js.map +1 -1
  79. package/dist/ui/html/pages/subscriptions.d.ts.map +1 -1
  80. package/dist/ui/html/pages/subscriptions.js +2 -3
  81. package/dist/ui/html/pages/subscriptions.js.map +1 -1
  82. package/dist/ui/html/pages/tasks.d.ts.map +1 -1
  83. package/dist/ui/html/pages/tasks.js +0 -1
  84. package/dist/ui/html/pages/tasks.js.map +1 -1
  85. package/dist/ui/html/pages/users.js +1 -1
  86. package/dist/ui/html/pages/users.js.map +1 -1
  87. package/dist/ui/html/styles.d.ts +1 -1
  88. package/dist/ui/html/styles.d.ts.map +1 -1
  89. package/dist/ui/html/styles.js +14 -4
  90. package/dist/ui/html/styles.js.map +1 -1
  91. package/dist/ui/html/utils.d.ts +1 -1
  92. package/dist/ui/html/utils.d.ts.map +1 -1
  93. package/dist/ui/html/utils.js +4 -0
  94. package/dist/ui/html/utils.js.map +1 -1
  95. package/dist/ui/index.d.ts +2 -2
  96. package/dist/ui/index.d.ts.map +1 -1
  97. package/dist/ui/index.js +1 -1
  98. package/dist/ui/index.js.map +1 -1
  99. package/dist/ui/middleware.d.ts +7 -2
  100. package/dist/ui/middleware.d.ts.map +1 -1
  101. package/dist/ui/middleware.js +327 -117
  102. package/dist/ui/middleware.js.map +1 -1
  103. package/dist/ui/schema-registry.d.ts +1 -0
  104. package/dist/ui/schema-registry.d.ts.map +1 -1
  105. package/dist/ui/schema-registry.js +1 -0
  106. package/dist/ui/schema-registry.js.map +1 -1
  107. package/package.json +2 -1
@@ -13,6 +13,7 @@ import { layout } from "./html/layout.js";
13
13
  import { runtimeScript } from "./html/client/runtime.js";
14
14
  import { dataExplorerScript } from "./html/client/data-explorer-app.js";
15
15
  import { htmxScript } from "./html/client/htmx-vendor.js";
16
+ import { logoSvg } from "./html/logo.js";
16
17
  import * as pages from "./html/pages/index.js";
17
18
  import { html, escapeHtml } from "./html/utils.js";
18
19
  import { emptyState } from "./html/components/index.js";
@@ -30,6 +31,28 @@ export const logError = (log) => {
30
31
  if (errorLogs.length > MAX_LOGS)
31
32
  errorLogs.pop();
32
33
  };
34
+ // Middleware that records each request into the admin dashboard's request/error
35
+ // log. `createCovara` mounts this automatically when the admin UI is enabled;
36
+ // mount it yourself (early, before your routes) when wiring the admin UI by hand.
37
+ export const createAdminRequestLogger = (options = {}) => {
38
+ const skip = options.skipPaths ?? ["/__covara"];
39
+ const reqId = () => globalThis.crypto?.randomUUID?.() ??
40
+ `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
41
+ return async (c, next) => {
42
+ const start = Date.now();
43
+ await next();
44
+ const path = c.req.path;
45
+ if (skip.some((p) => path.startsWith(p)))
46
+ return;
47
+ const status = c.res?.status ?? 0;
48
+ const id = reqId();
49
+ const timestamp = Date.now();
50
+ logRequest({ id, method: c.req.method, path, status, duration: timestamp - start, timestamp });
51
+ if (status >= 500) {
52
+ logError({ id, timestamp, path, method: c.req.method, error: `HTTP ${status}`, statusCode: status });
53
+ }
54
+ };
55
+ };
33
56
  export const createAdminUI = (config = {}) => {
34
57
  const router = new Hono();
35
58
  const basePath = config.basePath || "/__covara";
@@ -175,11 +198,32 @@ export const createAdminUI = (config = {}) => {
175
198
  return c.json({ entries: [], currentSeq: 0, enabled: false });
176
199
  }
177
200
  });
178
- router.get("/api/subscriptions", (c) => {
201
+ router.get("/api/subscriptions", async (c) => {
179
202
  if (!config.getActiveSubscriptions) {
180
203
  return c.json({ subscriptions: [], enabled: false });
181
204
  }
182
- return c.json({ subscriptions: config.getActiveSubscriptions(), enabled: true });
205
+ return c.json({ subscriptions: await config.getActiveSubscriptions(), enabled: true });
206
+ });
207
+ router.delete("/api/subscriptions/:id", async (c) => {
208
+ if (!config.disconnectSubscription) {
209
+ return c.json({ error: "Subscription management not configured" }, 501);
210
+ }
211
+ try {
212
+ const disconnected = await config.disconnectSubscription(c.req.param("id"));
213
+ if (isHtmxRequest(c)) {
214
+ // The Disconnect button swaps out its table row on success.
215
+ return disconnected
216
+ ? c.html("")
217
+ : c.html(html `<tr><td colspan="7" class="alert alert-error">Subscription not found</td></tr>`);
218
+ }
219
+ if (!disconnected) {
220
+ return c.json({ error: "Subscription not found" }, 404);
221
+ }
222
+ return c.body(null, 204);
223
+ }
224
+ catch (e) {
225
+ return c.json({ error: e.message }, 400);
226
+ }
183
227
  });
184
228
  router.post("/api/query", async (c) => {
185
229
  const { resource, filter, limit = 10 } = (await readJsonBody(c));
@@ -227,7 +271,7 @@ export const createAdminUI = (config = {}) => {
227
271
  return c.json({ error: "User management not configured" }, 501);
228
272
  }
229
273
  try {
230
- const body = (await readJsonBody(c));
274
+ const body = (await readFlexibleBody(c));
231
275
  const user = await config.userManager.createUser(body);
232
276
  return c.json({ user }, 201);
233
277
  }
@@ -241,7 +285,7 @@ export const createAdminUI = (config = {}) => {
241
285
  }
242
286
  try {
243
287
  const userId = c.req.param("id");
244
- const body = (await readJsonBody(c));
288
+ const body = (await readFlexibleBody(c));
245
289
  const user = await config.userManager.updateUser(userId, body);
246
290
  return c.json({ user });
247
291
  }
@@ -269,7 +313,7 @@ export const createAdminUI = (config = {}) => {
269
313
  }
270
314
  try {
271
315
  const limit = parseInt(String(c.req.query("limit"))) || 50;
272
- const sessions = await config.sessionManager.listSessions(limit);
316
+ const sessions = (await config.sessionManager.listSessions(limit)).map(normalizeSession);
273
317
  return c.json({ sessions, enabled: true });
274
318
  }
275
319
  catch (e) {
@@ -282,7 +326,7 @@ export const createAdminUI = (config = {}) => {
282
326
  }
283
327
  try {
284
328
  const userId = c.req.param("userId");
285
- const sessions = await config.sessionManager.getSessionsByUser(userId);
329
+ const sessions = (await config.sessionManager.getSessionsByUser(userId)).map(normalizeSession);
286
330
  return c.json({ sessions });
287
331
  }
288
332
  catch (e) {
@@ -294,14 +338,34 @@ export const createAdminUI = (config = {}) => {
294
338
  return c.json({ error: "Session management not configured" }, 501);
295
339
  }
296
340
  try {
297
- const { userId, expiresIn } = (await readJsonBody(c));
341
+ const body = (await readFlexibleBody(c));
342
+ const htmx = isHtmxRequest(c);
343
+ const userId = typeof body.userId === "string" ? body.userId : undefined;
344
+ const expiresInRaw = body.expiresIn != null ? Number(body.expiresIn) : undefined;
345
+ // The mint form sends the TTL in seconds; convert to ms for that path.
346
+ // JSON API callers pass the value through unchanged.
347
+ const expiresIn = expiresInRaw != null && !Number.isNaN(expiresInRaw)
348
+ ? htmx
349
+ ? expiresInRaw * 1000
350
+ : expiresInRaw
351
+ : undefined;
298
352
  if (!userId) {
353
+ if (htmx) {
354
+ return c.html(html `<div class="alert alert-error">userId is required</div>`);
355
+ }
299
356
  return c.json({ error: "userId is required" }, 400);
300
357
  }
301
358
  const session = await config.sessionManager.createSession(userId, expiresIn);
359
+ if (htmx) {
360
+ const sessions = (await config.sessionManager.listSessions(100)).map(normalizeSession);
361
+ return c.html(pages.sessionsList(sessions));
362
+ }
302
363
  return c.json({ session }, 201);
303
364
  }
304
365
  catch (e) {
366
+ if (isHtmxRequest(c)) {
367
+ return c.html(html `<div class="alert alert-error">${escapeHtml(e.message)}</div>`);
368
+ }
305
369
  return c.json({ error: e.message }, 400);
306
370
  }
307
371
  });
@@ -491,6 +555,112 @@ export const createAdminUI = (config = {}) => {
491
555
  });
492
556
  // Helper to check if this is an HTMX request
493
557
  const isHtmxRequest = (c) => c.req.header('hx-request') === 'true';
558
+ // Read a request body whether it arrives as JSON or as a form submission
559
+ // (HTMX forms post application/x-www-form-urlencoded by default).
560
+ const readFlexibleBody = async (c) => {
561
+ const contentType = c.req.header("content-type") ?? "";
562
+ if (contentType.includes("application/json")) {
563
+ return (await readJsonBody(c));
564
+ }
565
+ if (contentType.includes("form-urlencoded") ||
566
+ contentType.includes("multipart/form-data")) {
567
+ return (await c.req.parseBody());
568
+ }
569
+ try {
570
+ return (await readJsonBody(c));
571
+ }
572
+ catch {
573
+ return (await c.req.parseBody());
574
+ }
575
+ };
576
+ // Normalize whatever shape a sessionManager returns into the canonical
577
+ // SessionInfo the admin pages render. Accepts common field aliases
578
+ // (id/sessionToken/token, expiresAt/expires) so any session store works.
579
+ const toIso = (v) => {
580
+ if (v == null)
581
+ return undefined;
582
+ if (v instanceof Date)
583
+ return v.toISOString();
584
+ if (typeof v === "number")
585
+ return new Date(v).toISOString();
586
+ return String(v);
587
+ };
588
+ const normalizeSession = (s) => ({
589
+ ...s,
590
+ id: s?.id ?? s?.sessionToken ?? s?.token ?? "",
591
+ userId: s?.userId ?? "",
592
+ createdAt: toIso(s?.createdAt) ?? "",
593
+ expiresAt: toIso(s?.expiresAt ?? s?.expires) ?? "",
594
+ // Activity metadata is captured into session.data by the auth layer
595
+ // (login stamps ip/user-agent; getSession stamps lastActiveAt).
596
+ lastActiveAt: toIso(s?.lastActiveAt ?? s?.data?.lastActiveAt),
597
+ ipAddress: s?.ipAddress ?? s?.data?.ipAddress,
598
+ userAgent: s?.userAgent ?? s?.data?.userAgent,
599
+ });
600
+ // The changelog system stores `{type, objectId, object, previousObject}`;
601
+ // the admin pages render `{operation, recordId, after, before}`. Accept both
602
+ // shapes so custom `config.changelog` providers keep working.
603
+ const normalizeChangelogEntry = (e) => ({
604
+ ...e,
605
+ operation: e?.operation ?? e?.type ?? "update",
606
+ recordId: e?.recordId ?? e?.objectId ?? "",
607
+ before: e?.before ?? e?.previousObject,
608
+ after: e?.after ?? e?.object,
609
+ });
610
+ // Build the API explorer's endpoint catalog. The path uses each resource's
611
+ // captured mount path (e.g. "/api/todos") so the explorer's test runner hits
612
+ // the real endpoint; it falls back to "/<name>" before the resource is first
613
+ // requested (mount paths are captured lazily on the first hit).
614
+ const buildApiEndpoints = () => {
615
+ const endpoints = [];
616
+ for (const resource of getAllResourcesForDisplay()) {
617
+ const caps = resource.capabilities || {};
618
+ const base = resource.mountPath ?? `/${resource.name}`;
619
+ endpoints.push({
620
+ method: "GET",
621
+ path: base,
622
+ description: `List ${resource.name} with filtering and pagination`,
623
+ parameters: [
624
+ { name: "filter", in: "query", type: "string", description: "RSQL filter expression" },
625
+ { name: "limit", in: "query", type: "number", description: "Max results (default: 50)" },
626
+ { name: "cursor", in: "query", type: "string", description: "Pagination cursor" },
627
+ { name: "orderBy", in: "query", type: "string", description: "Sort field:direction" },
628
+ ],
629
+ });
630
+ endpoints.push({
631
+ method: "GET",
632
+ path: `${base}/:id`,
633
+ description: `Get a single ${resource.name} by ID`,
634
+ parameters: [{ name: "id", in: "path", type: "string", required: true }],
635
+ });
636
+ if (caps.enableCreate) {
637
+ endpoints.push({
638
+ method: "POST",
639
+ path: base,
640
+ description: `Create a new ${resource.name}`,
641
+ requestBody: { contentType: "application/json" },
642
+ });
643
+ }
644
+ if (caps.enableUpdate) {
645
+ endpoints.push({
646
+ method: "PATCH",
647
+ path: `${base}/:id`,
648
+ description: `Update a ${resource.name}`,
649
+ parameters: [{ name: "id", in: "path", type: "string", required: true }],
650
+ requestBody: { contentType: "application/json" },
651
+ });
652
+ }
653
+ if (caps.enableDelete) {
654
+ endpoints.push({
655
+ method: "DELETE",
656
+ path: `${base}/:id`,
657
+ description: `Delete a ${resource.name}`,
658
+ parameters: [{ name: "id", in: "path", type: "string", required: true }],
659
+ });
660
+ }
661
+ }
662
+ return endpoints;
663
+ };
494
664
  // Helper to send HTML response (full page or fragment for HTMX)
495
665
  const sendHtml = (c, activePage, content) => {
496
666
  if (isHtmxRequest(c)) {
@@ -509,6 +679,13 @@ export const createAdminUI = (config = {}) => {
509
679
  router.get("/ui/htmx.js", (c) => sendJs(c, htmxScript));
510
680
  router.get("/ui/covara-runtime.js", (c) => sendJs(c, runtimeScript));
511
681
  router.get("/ui/data-explorer-app.js", (c) => sendJs(c, dataExplorerScript));
682
+ // Logo / favicon — served outside the auth gate so the favicon also shows on
683
+ // the login challenge page.
684
+ router.get("/logo.svg", (c) => {
685
+ c.header("Content-Type", "image/svg+xml; charset=utf-8");
686
+ c.header("Cache-Control", "public, max-age=3600");
687
+ return c.body(logoSvg);
688
+ });
512
689
  // ============================================
513
690
  // HTMX UI Routes - Full Page Renders
514
691
  // ============================================
@@ -532,7 +709,7 @@ export const createAdminUI = (config = {}) => {
532
709
  }
533
710
  catch { }
534
711
  }
535
- const subscriptions = config.getActiveSubscriptions?.() || [];
712
+ const subscriptions = (await config.getActiveSubscriptions?.()) || [];
536
713
  const content = pages.dashboardPage({
537
714
  stats: {
538
715
  resources: resources.length,
@@ -614,7 +791,7 @@ export const createAdminUI = (config = {}) => {
614
791
  let sessions = [];
615
792
  if (config.sessionManager) {
616
793
  try {
617
- sessions = await config.sessionManager.listSessions(100);
794
+ sessions = (await config.sessionManager.listSessions(100)).map(normalizeSession);
618
795
  }
619
796
  catch { }
620
797
  }
@@ -625,8 +802,8 @@ export const createAdminUI = (config = {}) => {
625
802
  return sendHtml(c, 'sessions', content);
626
803
  });
627
804
  // Subscriptions
628
- router.get("/ui/subscriptions", (c) => {
629
- const subscriptions = config.getActiveSubscriptions?.() || [];
805
+ router.get("/ui/subscriptions", async (c) => {
806
+ const subscriptions = (await config.getActiveSubscriptions?.()) || [];
630
807
  const byResource = {};
631
808
  for (const sub of subscriptions) {
632
809
  byResource[sub.resource] = (byResource[sub.resource] || 0) + 1;
@@ -648,7 +825,7 @@ export const createAdminUI = (config = {}) => {
648
825
  if (config.changelog) {
649
826
  try {
650
827
  stats.currentSeq = await config.changelog.getCurrentSequence();
651
- entries = await config.changelog.getEntries(Math.max(0, stats.currentSeq - 100), 100);
828
+ entries = (await config.changelog.getEntries(Math.max(0, stats.currentSeq - 100), 100)).map(normalizeChangelogEntry);
652
829
  stats.total = entries.length;
653
830
  for (const e of entries) {
654
831
  if (e.operation === 'create')
@@ -695,58 +872,7 @@ export const createAdminUI = (config = {}) => {
695
872
  });
696
873
  // API Explorer
697
874
  router.get("/ui/api-explorer", (c) => {
698
- const resources = getAllResourcesForDisplay();
699
- const endpoints = [];
700
- for (const resource of resources) {
701
- const caps = resource.capabilities || {};
702
- // GET list
703
- endpoints.push({
704
- method: 'GET',
705
- path: resource.name,
706
- description: `List ${resource.name} with filtering and pagination`,
707
- parameters: [
708
- { name: 'filter', in: 'query', type: 'string', description: 'RSQL filter expression' },
709
- { name: 'limit', in: 'query', type: 'number', description: 'Max results (default: 50)' },
710
- { name: 'cursor', in: 'query', type: 'string', description: 'Pagination cursor' },
711
- { name: 'orderBy', in: 'query', type: 'string', description: 'Sort field:direction' },
712
- ],
713
- });
714
- // GET single
715
- endpoints.push({
716
- method: 'GET',
717
- path: `${resource.name}/:id`,
718
- description: `Get a single ${resource.name} by ID`,
719
- parameters: [
720
- { name: 'id', in: 'path', type: 'string', required: true },
721
- ],
722
- });
723
- if (caps.enableCreate) {
724
- endpoints.push({
725
- method: 'POST',
726
- path: resource.name,
727
- description: `Create a new ${resource.name}`,
728
- requestBody: { contentType: 'application/json' },
729
- });
730
- }
731
- if (caps.enableUpdate) {
732
- endpoints.push({
733
- method: 'PATCH',
734
- path: `${resource.name}/:id`,
735
- description: `Update a ${resource.name}`,
736
- parameters: [{ name: 'id', in: 'path', type: 'string', required: true }],
737
- requestBody: { contentType: 'application/json' },
738
- });
739
- }
740
- if (caps.enableDelete) {
741
- endpoints.push({
742
- method: 'DELETE',
743
- path: `${resource.name}/:id`,
744
- description: `Delete a ${resource.name}`,
745
- parameters: [{ name: 'id', in: 'path', type: 'string', required: true }],
746
- });
747
- }
748
- }
749
- const content = pages.apiExplorerPage({ endpoints, baseUrl: '' });
875
+ const content = pages.apiExplorerPage({ endpoints: buildApiEndpoints(), baseUrl: '' });
750
876
  return sendHtml(c, 'api-explorer', content);
751
877
  });
752
878
  // ============================================
@@ -845,7 +971,7 @@ export const createAdminUI = (config = {}) => {
845
971
  let sessions = [];
846
972
  if (config.sessionManager) {
847
973
  try {
848
- sessions = await config.sessionManager.getSessionsByUser(id);
974
+ sessions = (await config.sessionManager.getSessionsByUser(id)).map(normalizeSession);
849
975
  }
850
976
  catch { }
851
977
  }
@@ -873,7 +999,7 @@ export const createAdminUI = (config = {}) => {
873
999
  return c.html(pages.sessionsList([]));
874
1000
  }
875
1001
  try {
876
- const sessions = await config.sessionManager.listSessions(100);
1002
+ const sessions = (await config.sessionManager.listSessions(100)).map(normalizeSession);
877
1003
  return c.html(pages.sessionsList(sessions));
878
1004
  }
879
1005
  catch (e) {
@@ -881,8 +1007,8 @@ export const createAdminUI = (config = {}) => {
881
1007
  }
882
1008
  });
883
1009
  // Subscriptions list partial
884
- router.get("/ui/subscriptions/list", (c) => {
885
- const subscriptions = config.getActiveSubscriptions?.() || [];
1010
+ router.get("/ui/subscriptions/list", async (c) => {
1011
+ const subscriptions = (await config.getActiveSubscriptions?.()) || [];
886
1012
  return c.html(pages.subscriptionsList(subscriptions));
887
1013
  });
888
1014
  // Changelog list partial
@@ -898,7 +1024,7 @@ export const createAdminUI = (config = {}) => {
898
1024
  if (resource) {
899
1025
  entries = entries.filter((e) => e.resource?.includes(resource));
900
1026
  }
901
- return c.html(pages.changelogList(entries));
1027
+ return c.html(pages.changelogList(entries.map(normalizeChangelogEntry)));
902
1028
  }
903
1029
  catch (e) {
904
1030
  return c.html(html `<div class="alert alert-error">${escapeHtml(e.message)}</div>`);
@@ -916,7 +1042,7 @@ export const createAdminUI = (config = {}) => {
916
1042
  if (!entry) {
917
1043
  return c.html(emptyState('✕', 'Entry not found', ''));
918
1044
  }
919
- return c.html(pages.changelogDetail({ entry }));
1045
+ return c.html(pages.changelogDetail({ entry: normalizeChangelogEntry(entry) }));
920
1046
  }
921
1047
  catch (e) {
922
1048
  return c.html(html `<div class="alert alert-error">${escapeHtml(e.message)}</div>`);
@@ -1151,58 +1277,142 @@ export const createAdminUI = (config = {}) => {
1151
1277
  // API explorer endpoint detail partial
1152
1278
  router.get("/ui/api-explorer/endpoint/:index", (c) => {
1153
1279
  const index = parseInt(c.req.param("index"));
1154
- const resources = getAllResourcesForDisplay();
1155
- const endpoints = [];
1156
- for (const resource of resources) {
1157
- const caps = resource.capabilities || {};
1158
- endpoints.push({
1159
- method: 'GET',
1160
- path: resource.name,
1161
- description: `List ${resource.name} with filtering and pagination`,
1162
- parameters: [
1163
- { name: 'filter', in: 'query', type: 'string', description: 'RSQL filter expression' },
1164
- { name: 'limit', in: 'query', type: 'number', description: 'Max results (default: 50)' },
1165
- { name: 'cursor', in: 'query', type: 'string', description: 'Pagination cursor' },
1166
- { name: 'orderBy', in: 'query', type: 'string', description: 'Sort field:direction' },
1167
- ],
1168
- });
1169
- endpoints.push({
1170
- method: 'GET',
1171
- path: `${resource.name}/:id`,
1172
- description: `Get a single ${resource.name} by ID`,
1173
- parameters: [{ name: 'id', in: 'path', type: 'string', required: true }],
1174
- });
1175
- if (caps.enableCreate) {
1176
- endpoints.push({
1177
- method: 'POST',
1178
- path: resource.name,
1179
- description: `Create a new ${resource.name}`,
1180
- requestBody: { contentType: 'application/json' },
1181
- });
1280
+ const endpoint = buildApiEndpoints()[index];
1281
+ if (!endpoint) {
1282
+ return c.html(emptyState('✕', 'Endpoint not found', ''));
1283
+ }
1284
+ return c.html(pages.endpointDetail({ endpoint, baseUrl: '' }));
1285
+ });
1286
+ // API explorer request runner — performs the configured request against this
1287
+ // same server (forwarding the caller's cookies for auth) and renders the
1288
+ // response. The form posts method/path plus param_<name> fields and a body.
1289
+ router.post("/ui/api-explorer/execute", async (c) => {
1290
+ const body = (await c.req.parseBody());
1291
+ const method = (body.method ?? "GET").toUpperCase();
1292
+ let path = body.path ?? "";
1293
+ const requestBody = typeof body.body === "string" ? body.body.trim() : "";
1294
+ const query = new URLSearchParams();
1295
+ for (const [key, value] of Object.entries(body)) {
1296
+ if (!key.startsWith("param_"))
1297
+ continue;
1298
+ if (value == null || value === "")
1299
+ continue;
1300
+ const paramName = key.slice("param_".length);
1301
+ if (path.includes(`:${paramName}`)) {
1302
+ path = path.replace(`:${paramName}`, encodeURIComponent(value));
1182
1303
  }
1183
- if (caps.enableUpdate) {
1184
- endpoints.push({
1185
- method: 'PATCH',
1186
- path: `${resource.name}/:id`,
1187
- description: `Update a ${resource.name}`,
1188
- parameters: [{ name: 'id', in: 'path', type: 'string', required: true }],
1189
- requestBody: { contentType: 'application/json' },
1190
- });
1304
+ else {
1305
+ query.set(paramName, value);
1191
1306
  }
1192
- if (caps.enableDelete) {
1193
- endpoints.push({
1194
- method: 'DELETE',
1195
- path: `${resource.name}/:id`,
1196
- description: `Delete a ${resource.name}`,
1197
- parameters: [{ name: 'id', in: 'path', type: 'string', required: true }],
1198
- });
1307
+ }
1308
+ const origin = new URL(c.req.url).origin;
1309
+ const qs = query.toString();
1310
+ const target = `${origin}${path.startsWith("/") ? "" : "/"}${path}${qs ? `?${qs}` : ""}`;
1311
+ const headers = {};
1312
+ const cookie = c.req.header("cookie");
1313
+ if (cookie)
1314
+ headers["cookie"] = cookie;
1315
+ const auth = c.req.header("authorization");
1316
+ if (auth)
1317
+ headers["authorization"] = auth;
1318
+ const init = { method, headers };
1319
+ if (method !== "GET" && method !== "HEAD" && requestBody) {
1320
+ headers["content-type"] = "application/json";
1321
+ init.body = requestBody;
1322
+ }
1323
+ const started = Date.now();
1324
+ try {
1325
+ const res = await fetch(target, init);
1326
+ const duration = Date.now() - started;
1327
+ const text = await res.text();
1328
+ let parsed = text;
1329
+ try {
1330
+ parsed = JSON.parse(text);
1199
1331
  }
1332
+ catch { }
1333
+ const resHeaders = {};
1334
+ res.headers.forEach((value, key) => {
1335
+ resHeaders[key] = value;
1336
+ });
1337
+ return c.html(pages.apiResponse({
1338
+ status: res.status,
1339
+ statusText: res.statusText,
1340
+ headers: resHeaders,
1341
+ body: parsed,
1342
+ duration,
1343
+ }));
1200
1344
  }
1201
- const endpoint = endpoints[index];
1202
- if (!endpoint) {
1203
- return c.html(emptyState('✕', 'Endpoint not found', ''));
1345
+ catch (e) {
1346
+ return c.html(pages.apiResponse({
1347
+ status: 0,
1348
+ statusText: "Request Failed",
1349
+ headers: {},
1350
+ body: "",
1351
+ duration: Date.now() - started,
1352
+ error: e?.message ?? String(e),
1353
+ }));
1354
+ }
1355
+ });
1356
+ // Filter tester — run an RSQL expression against a resource's live data
1357
+ // (in-memory matching, so it exercises the same predicate logic used for
1358
+ // subscription matching) and report which records match.
1359
+ router.post("/ui/filter/test", async (c) => {
1360
+ const body = (await c.req.parseBody());
1361
+ const filter = typeof body.filter === "string" ? body.filter : "";
1362
+ const resource = typeof body.resource === "string" ? body.resource : "";
1363
+ if (!resource) {
1364
+ return c.html(pages.filterTestResult({
1365
+ filter,
1366
+ resource,
1367
+ matchCount: 0,
1368
+ totalCount: 0,
1369
+ matches: [],
1370
+ executionTime: 0,
1371
+ error: "Select a resource to test against",
1372
+ }));
1373
+ }
1374
+ const entry = getResourceSchema(resource);
1375
+ if (!entry) {
1376
+ return c.html(pages.filterTestResult({
1377
+ filter,
1378
+ resource,
1379
+ matchCount: 0,
1380
+ totalCount: 0,
1381
+ matches: [],
1382
+ executionTime: 0,
1383
+ error: `Unknown resource: ${resource}`,
1384
+ }));
1385
+ }
1386
+ const started = Date.now();
1387
+ try {
1388
+ const rows = (await entry.db
1389
+ .select()
1390
+ .from(entry.schema)
1391
+ .limit(500));
1392
+ const filterer = createResourceFilter(entry.schema, {});
1393
+ const matches = filter
1394
+ ? rows.filter((row) => filterer.execute(filter, row))
1395
+ : rows;
1396
+ return c.html(pages.filterTestResult({
1397
+ filter,
1398
+ resource,
1399
+ matchCount: matches.length,
1400
+ totalCount: rows.length,
1401
+ matches: matches.slice(0, 50),
1402
+ executionTime: Date.now() - started,
1403
+ }));
1404
+ }
1405
+ catch (e) {
1406
+ return c.html(pages.filterTestResult({
1407
+ filter,
1408
+ resource,
1409
+ matchCount: 0,
1410
+ totalCount: 0,
1411
+ matches: [],
1412
+ executionTime: Date.now() - started,
1413
+ error: e?.message ?? String(e),
1414
+ }));
1204
1415
  }
1205
- return c.html(pages.endpointDetail({ endpoint, baseUrl: '' }));
1206
1416
  });
1207
1417
  return router;
1208
1418
  };