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.
- package/README.md +13 -8
- package/assets/logo.svg +19 -0
- package/dist/auth/adapter.d.ts +1 -0
- package/dist/auth/adapter.d.ts.map +1 -1
- package/dist/auth/adapter.js +37 -1
- package/dist/auth/adapter.js.map +1 -1
- package/dist/auth/routes.d.ts.map +1 -1
- package/dist/auth/routes.js +15 -2
- package/dist/auth/routes.js.map +1 -1
- package/dist/auth/types.d.ts +4 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/types.js +10 -0
- package/dist/auth/types.js.map +1 -1
- package/dist/client/aggregate-subscription.d.ts +32 -0
- package/dist/client/aggregate-subscription.d.ts.map +1 -0
- package/dist/client/aggregate-subscription.js +131 -0
- package/dist/client/aggregate-subscription.js.map +1 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/react.d.ts +25 -1
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +53 -0
- package/dist/client/react.js.map +1 -1
- package/dist/client/repository.d.ts +2 -0
- package/dist/client/repository.d.ts.map +1 -1
- package/dist/client/repository.js +9 -0
- package/dist/client/repository.js.map +1 -1
- package/dist/client/types.d.ts +17 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/resource/changelog.d.ts +3 -3
- package/dist/resource/changelog.d.ts.map +1 -1
- package/dist/resource/changelog.js +6 -3
- package/dist/resource/changelog.js.map +1 -1
- package/dist/resource/hook.d.ts.map +1 -1
- package/dist/resource/hook.js +141 -18
- package/dist/resource/hook.js.map +1 -1
- package/dist/resource/mutate.d.ts +3 -3
- package/dist/resource/mutate.d.ts.map +1 -1
- package/dist/resource/mutate.js +8 -8
- package/dist/resource/mutate.js.map +1 -1
- package/dist/resource/query.d.ts +1 -0
- package/dist/resource/query.d.ts.map +1 -1
- package/dist/resource/query.js +12 -0
- package/dist/resource/query.js.map +1 -1
- package/dist/resource/subscription.d.ts +18 -1
- package/dist/resource/subscription.d.ts.map +1 -1
- package/dist/resource/subscription.js +310 -53
- package/dist/resource/subscription.js.map +1 -1
- package/dist/resource/track-mutations.d.ts.map +1 -1
- package/dist/resource/track-mutations.js +1 -1
- package/dist/resource/track-mutations.js.map +1 -1
- package/dist/resource/types.d.ts +2 -0
- package/dist/resource/types.d.ts.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +28 -3
- package/dist/server/app.js.map +1 -1
- package/dist/ui/html/layout.d.ts.map +1 -1
- package/dist/ui/html/layout.js +5 -1
- package/dist/ui/html/layout.js.map +1 -1
- package/dist/ui/html/logo.d.ts +2 -0
- package/dist/ui/html/logo.d.ts.map +1 -0
- package/dist/ui/html/logo.js +23 -0
- package/dist/ui/html/logo.js.map +1 -0
- package/dist/ui/html/pages/data-explorer.d.ts.map +1 -1
- package/dist/ui/html/pages/data-explorer.js +0 -5
- package/dist/ui/html/pages/data-explorer.js.map +1 -1
- package/dist/ui/html/pages/kv-inspector.d.ts.map +1 -1
- package/dist/ui/html/pages/kv-inspector.js +0 -1
- package/dist/ui/html/pages/kv-inspector.js.map +1 -1
- package/dist/ui/html/pages/sessions.d.ts.map +1 -1
- package/dist/ui/html/pages/sessions.js +4 -3
- package/dist/ui/html/pages/sessions.js.map +1 -1
- package/dist/ui/html/pages/subscriptions.d.ts.map +1 -1
- package/dist/ui/html/pages/subscriptions.js +2 -3
- package/dist/ui/html/pages/subscriptions.js.map +1 -1
- package/dist/ui/html/pages/tasks.d.ts.map +1 -1
- package/dist/ui/html/pages/tasks.js +0 -1
- package/dist/ui/html/pages/tasks.js.map +1 -1
- package/dist/ui/html/pages/users.js +1 -1
- package/dist/ui/html/pages/users.js.map +1 -1
- package/dist/ui/html/styles.d.ts +1 -1
- package/dist/ui/html/styles.d.ts.map +1 -1
- package/dist/ui/html/styles.js +14 -4
- package/dist/ui/html/styles.js.map +1 -1
- package/dist/ui/html/utils.d.ts +1 -1
- package/dist/ui/html/utils.d.ts.map +1 -1
- package/dist/ui/html/utils.js +4 -0
- package/dist/ui/html/utils.js.map +1 -1
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/middleware.d.ts +7 -2
- package/dist/ui/middleware.d.ts.map +1 -1
- package/dist/ui/middleware.js +327 -117
- package/dist/ui/middleware.js.map +1 -1
- package/dist/ui/schema-registry.d.ts +1 -0
- package/dist/ui/schema-registry.d.ts.map +1 -1
- package/dist/ui/schema-registry.js +1 -0
- package/dist/ui/schema-registry.js.map +1 -1
- package/package.json +2 -1
package/dist/ui/middleware.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
if (
|
|
1176
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
};
|