@app-vitals/agent-admin 0.9.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.9.0",
46
+ "@app-vitals/agent-lib": "0.9.1",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -388,9 +388,13 @@ describe("admin UI — new local agent create flow", () => {
388
388
 
389
389
  it("GET /admin/agents/new — unauthenticated redirects to /admin/login", async () => {
390
390
  const app = createAdminUIApp(makeMockDeps());
391
- const res = await app.request("/admin/agents/new");
391
+ const res = await app.request("/admin/agents/new", {
392
+ headers: { Accept: "text/html" },
393
+ });
392
394
  expect(res.status).toBe(302);
393
- expect(res.headers.get("Location")).toBe("/admin/login");
395
+ expect(res.headers.get("Location")).toBe(
396
+ "/admin/login?returnTo=%2Fadmin%2Fagents%2Fnew",
397
+ );
394
398
  });
395
399
 
396
400
  // ── POST /admin/agents ────────────────────────────────────────────────────
@@ -3059,8 +3059,15 @@ export function renderChatThreadPage(
3059
3059
  }
3060
3060
 
3061
3061
  function poll() {
3062
- fetch(messagesJsonUrl)
3063
- .then(function(r) { return r.json(); })
3062
+ fetch(messagesJsonUrl, { headers: { 'Accept': 'application/json' } })
3063
+ .then(function(r) {
3064
+ if (r.status === 401) {
3065
+ stopPolling();
3066
+ window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
3067
+ return { messages: [] };
3068
+ }
3069
+ return r.json();
3070
+ })
3064
3071
  .then(function(data) {
3065
3072
  var msgs = data.messages || [];
3066
3073
 
@@ -3246,8 +3253,14 @@ export function renderChatThreadPage(
3246
3253
  // POST multipart to the upload endpoint
3247
3254
  fetch(uploadUrl, {
3248
3255
  method: 'POST',
3256
+ headers: { 'Accept': 'application/json' },
3249
3257
  body: fd
3250
3258
  }).then(function(r) {
3259
+ if (r.status === 401) {
3260
+ stopAll();
3261
+ window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
3262
+ return;
3263
+ }
3251
3264
  if (!r.ok) {
3252
3265
  return r.json().then(function(data) {
3253
3266
  stopAll();
@@ -83,9 +83,13 @@ export function renderPushTestControl(): string {
83
83
  btn.addEventListener('click', function() {
84
84
  btn.disabled = true;
85
85
  status.textContent = 'Sending…';
86
- fetch(${JSON.stringify(PUSH_TEST_PATH)}, { method: 'POST', credentials: 'same-origin' })
86
+ fetch(${JSON.stringify(PUSH_TEST_PATH)}, { method: 'POST', credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
87
87
  .then(function(res) { return res.json().then(function(body) { return { status: res.status, body: body }; }); })
88
88
  .then(function(r) {
89
+ if (r.status === 401) {
90
+ window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
91
+ return;
92
+ }
89
93
  if (r.status !== 200 || !r.body || !r.body.ok) {
90
94
  status.textContent = 'Push is not enabled on this server.';
91
95
  } else if (r.body.delivered === 0) {
@@ -40,18 +40,81 @@ const TYPE = TYPE_MANIFEST.metadata.name;
40
40
  // ─── Auth redirect tests ──────────────────────────────────────────────────────
41
41
 
42
42
  describe("admin UI — unauthenticated redirects", () => {
43
- it("unauthenticated GET /admin/agents redirects to /admin/login", async () => {
43
+ const NAV = { "Sec-Fetch-Mode": "navigate" };
44
+
45
+ it("unauthenticated GET /admin/agents redirects to /admin/login with returnTo", async () => {
44
46
  const app = createAdminUIApp(makeMockDeps());
45
- const res = await app.request("/admin/agents");
47
+ const res = await app.request("/admin/agents", { headers: NAV });
46
48
  expect(res.status).toBe(302);
47
- expect(res.headers.get("Location")).toBe("/admin/login");
49
+ expect(res.headers.get("Location")).toBe(
50
+ "/admin/login?returnTo=%2Fadmin%2Fagents",
51
+ );
48
52
  });
49
53
 
50
- it("unauthenticated GET /admin/agents/:id redirects to /admin/login", async () => {
54
+ it("unauthenticated GET /admin/agents/:id redirects to /admin/login with returnTo", async () => {
51
55
  const app = createAdminUIApp(makeMockDeps());
52
- const res = await app.request(`/admin/agents/${AGENT_ID}`);
56
+ const res = await app.request(`/admin/agents/${AGENT_ID}`, {
57
+ headers: { Accept: "text/html,application/xhtml+xml" },
58
+ });
53
59
  expect(res.status).toBe(302);
54
- expect(res.headers.get("Location")).toBe("/admin/login");
60
+ expect(res.headers.get("Location")).toBe(
61
+ `/admin/login?returnTo=${encodeURIComponent(`/admin/agents/${AGENT_ID}`)}`,
62
+ );
63
+ });
64
+
65
+ it("navigation redirect preserves the query string in returnTo", async () => {
66
+ const app = createAdminUIApp(makeMockDeps());
67
+ const res = await app.request("/admin/agents?x=1&y=2", { headers: NAV });
68
+ expect(res.headers.get("Location")).toBe(
69
+ `/admin/login?returnTo=${encodeURIComponent("/admin/agents?x=1&y=2")}`,
70
+ );
71
+ });
72
+
73
+ it("unauthenticated fetch-style request gets 401 signed_out JSON", async () => {
74
+ const app = createAdminUIApp(makeMockDeps());
75
+ const res = await app.request("/admin/agents", {
76
+ headers: { Accept: "application/json", "Sec-Fetch-Mode": "cors" },
77
+ });
78
+ expect(res.status).toBe(401);
79
+ expect(await res.json()).toEqual({ error: "signed_out" });
80
+ });
81
+
82
+ it("request with no navigation hints gets 401 signed_out JSON", async () => {
83
+ const app = createAdminUIApp(makeMockDeps());
84
+ const res = await app.request("/admin/agents");
85
+ expect(res.status).toBe(401);
86
+ expect(await res.json()).toEqual({ error: "signed_out" });
87
+ });
88
+
89
+ it("unauthenticated POST fetch gets 401 signed_out JSON", async () => {
90
+ const app = createAdminUIApp(makeMockDeps());
91
+ const res = await app.request("/admin/agents", { method: "POST" });
92
+ expect(res.status).toBe(401);
93
+ expect(await res.json()).toEqual({ error: "signed_out" });
94
+ });
95
+
96
+ it("returnTo is never an open redirect (protocol-relative path dropped)", async () => {
97
+ const app = createAdminUIApp(makeMockDeps());
98
+ const res = await app.request("//evil.com/x", { headers: NAV });
99
+ expect(res.headers.get("Location") ?? "").not.toContain("evil.com");
100
+ });
101
+ });
102
+
103
+ describe("admin UI — GET /admin/session.json", () => {
104
+ it("returns 200 {signedIn:true} with a valid session", async () => {
105
+ const app = createAdminUIApp(makeMockDeps());
106
+ const res = await app.request("/admin/session.json", {
107
+ headers: { Cookie: `admin_session=${await makeSessionCookie()}` },
108
+ });
109
+ expect(res.status).toBe(200);
110
+ expect(await res.json()).toEqual({ signedIn: true });
111
+ });
112
+
113
+ it("returns 401 without a session", async () => {
114
+ const app = createAdminUIApp(makeMockDeps());
115
+ const res = await app.request("/admin/session.json");
116
+ expect(res.status).toBe(401);
117
+ expect(await res.json()).toEqual({ error: "signed_out" });
55
118
  });
56
119
  });
57
120
 
package/src/admin-ui.ts CHANGED
@@ -516,6 +516,28 @@ async function getSessionUser(
516
516
 
517
517
  // ─── Auth middleware ──────────────────────────────────────────────────────────
518
518
 
519
+ /**
520
+ * Accepts a `returnTo` value only when it is a same-origin relative path:
521
+ * must start with a single "/", so protocol-relative (//evil.com), absolute
522
+ * (https://evil.com) and backslash-smuggled (/\\evil.com) values are dropped.
523
+ */
524
+ export function validateReturnTo(
525
+ raw: string | undefined | null,
526
+ ): string | undefined {
527
+ return raw?.startsWith("/") && !raw.startsWith("//") && !raw.includes("\\")
528
+ ? raw
529
+ : undefined;
530
+ }
531
+
532
+ /** True for browser page navigations (as opposed to fetch/XHR calls). */
533
+ function isNavigationRequest(c: Context): boolean {
534
+ if (c.req.header("Sec-Fetch-Mode") === "navigate") return true;
535
+ return (
536
+ c.req.method === "GET" &&
537
+ (c.req.header("Accept") ?? "").includes("text/html")
538
+ );
539
+ }
540
+
519
541
  export function createUIAuthMiddleware(
520
542
  sessionSecret: string,
521
543
  ): MiddlewareHandler<AdminUIEnv> {
@@ -525,7 +547,20 @@ export function createUIAuthMiddleware(
525
547
  ? await getSessionUser(sessionToken, sessionSecret)
526
548
  : null;
527
549
  if (!user) {
528
- return c.redirect("/admin/login", 302);
550
+ if (!isNavigationRequest(c)) {
551
+ return c.json({ error: "signed_out" }, 401);
552
+ }
553
+ const url = new URL(c.req.url);
554
+ const returnTo =
555
+ c.req.method === "GET"
556
+ ? validateReturnTo(url.pathname + url.search)
557
+ : undefined;
558
+ return c.redirect(
559
+ returnTo
560
+ ? `/admin/login?returnTo=${encodeURIComponent(returnTo)}`
561
+ : "/admin/login",
562
+ 302,
563
+ );
529
564
  }
530
565
  c.set("userEmail", user.email);
531
566
  c.set("isAdmin", user.isAdmin);
@@ -862,10 +897,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
862
897
  path: "/",
863
898
  });
864
899
  // Redirect to returnTo if it's a valid same-origin relative path, otherwise default.
865
- const destination =
866
- returnTo?.startsWith("/") && !returnTo.startsWith("//")
867
- ? returnTo
868
- : "/admin/agents";
900
+ const destination = validateReturnTo(returnTo) ?? "/admin/agents";
869
901
  return c.redirect(destination, 302);
870
902
  }
871
903
 
@@ -881,10 +913,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
881
913
  const nonce = crypto.randomUUID();
882
914
 
883
915
  const rawReturnTo = c.req.query("returnTo");
884
- const returnTo =
885
- rawReturnTo?.startsWith("/") && !rawReturnTo.startsWith("//")
886
- ? rawReturnTo
887
- : undefined;
916
+ const returnTo = validateReturnTo(rawReturnTo);
888
917
 
889
918
  const oauthState = JSON.stringify({ nonce, returnTo });
890
919
  setCookie(c, OAUTH_STATE_COOKIE, oauthState, {
@@ -1000,6 +1029,12 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1000
1029
  }
1001
1030
  });
1002
1031
 
1032
+ // Cheap signed-in probe for client JS (a 401 {error:"signed_out"} from
1033
+ // requireAuth means the session is gone).
1034
+ app.get("/admin/session.json", requireAuth, (c) =>
1035
+ c.json({ signedIn: true }),
1036
+ );
1037
+
1003
1038
  app.get("/admin/login", (c) => {
1004
1039
  const error = c.req.query("error") ?? undefined;
1005
1040
  const returnTo = c.req.query("returnTo") ?? undefined;
@@ -413,9 +413,13 @@ function makeBaseDeps(overrides?: Partial<AdminUIDeps>): AdminUIDeps {
413
413
  describe("GET /admin/chat — auth gate", () => {
414
414
  it("returns 302 redirect to login without session cookie", async () => {
415
415
  const app = createAdminUIApp(makeBaseDeps());
416
- const res = await app.request("/admin/chat");
416
+ const res = await app.request("/admin/chat", {
417
+ headers: { Accept: "text/html" },
418
+ });
417
419
  expect(res.status).toBe(302);
418
- expect(res.headers.get("location")).toContain("/admin/login");
420
+ expect(res.headers.get("location")).toBe(
421
+ "/admin/login?returnTo=%2Fadmin%2Fchat",
422
+ );
419
423
  });
420
424
  });
421
425
 
@@ -616,14 +620,15 @@ describe("GET /admin/chat/:agentId/threads/:threadId — thread detail", () => {
616
620
  sessionCookie = await makeSessionCookie();
617
621
  });
618
622
 
619
- it("returns 401 without session cookie", async () => {
623
+ it("redirects navigations to login with returnTo without session cookie", async () => {
620
624
  const chatClient = makeMockChatClient();
621
625
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
622
626
  const res = await app.request(
623
627
  `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
628
+ { headers: { "Sec-Fetch-Mode": "navigate" } },
624
629
  );
625
630
  expect(res.status).toBe(302);
626
- expect(res.headers.get("location")).toContain("/admin/login");
631
+ expect(res.headers.get("location")).toContain("/admin/login?returnTo=");
627
632
  });
628
633
 
629
634
  it("returns 200 with messages when chatClient present", async () => {
@@ -1026,14 +1031,14 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1026
1031
  sessionCookie = await makeSessionCookie();
1027
1032
  });
1028
1033
 
1029
- it("returns 302 to login without session cookie", async () => {
1034
+ it("returns 401 signed_out without session cookie", async () => {
1030
1035
  const chatClient = makeMockChatClient();
1031
1036
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1032
1037
  const res = await app.request(
1033
1038
  `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1034
1039
  );
1035
- expect(res.status).toBe(302);
1036
- expect(res.headers.get("location")).toContain("/admin/login");
1040
+ expect(res.status).toBe(401);
1041
+ expect(await res.json()).toEqual({ error: "signed_out" });
1037
1042
  });
1038
1043
 
1039
1044
  it("returns 200 JSON with messages array when chatClient present", async () => {
@@ -1148,7 +1153,7 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1148
1153
  sessionCookie = await makeSessionCookie();
1149
1154
  });
1150
1155
 
1151
- it("returns 302 to login without session cookie", async () => {
1156
+ it("returns 401 signed_out without session cookie", async () => {
1152
1157
  const chatClient = makeMockChatClient();
1153
1158
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1154
1159
  const res = await app.request(
@@ -1159,8 +1164,8 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1159
1164
  body: JSON.stringify({ body: "test message" }),
1160
1165
  },
1161
1166
  );
1162
- expect(res.status).toBe(302);
1163
- expect(res.headers.get("location")).toContain("/admin/login");
1167
+ expect(res.status).toBe(401);
1168
+ expect(await res.json()).toEqual({ error: "signed_out" });
1164
1169
  });
1165
1170
 
1166
1171
  it("returns 200 JSON with message when chatClient present", async () => {
@@ -1237,7 +1242,7 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
1237
1242
  sessionCookie = await makeSessionCookie();
1238
1243
  });
1239
1244
 
1240
- it("returns 302 to login without session cookie", async () => {
1245
+ it("returns 401 signed_out without session cookie", async () => {
1241
1246
  const chatClient = makeMockChatClient();
1242
1247
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1243
1248
  const res = await app.request(
@@ -1248,8 +1253,8 @@ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
1248
1253
  body: "body=test+message",
1249
1254
  },
1250
1255
  );
1251
- expect(res.status).toBe(302);
1252
- expect(res.headers.get("location")).toContain("/admin/login");
1256
+ expect(res.status).toBe(401);
1257
+ expect(await res.json()).toEqual({ error: "signed_out" });
1253
1258
  });
1254
1259
 
1255
1260
  it("returns 201 JSON with message when chatClient present", async () => {
@@ -1368,14 +1373,14 @@ describe("GET /admin/chat/:agentId/threads/:threadId/messages/:messageId/attachm
1368
1373
  sessionCookie = await makeSessionCookie();
1369
1374
  });
1370
1375
 
1371
- it("returns 302 to login without session cookie", async () => {
1376
+ it("returns 401 signed_out without session cookie", async () => {
1372
1377
  const chatClient = makeMockChatClient();
1373
1378
  const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1374
1379
  const res = await app.request(
1375
1380
  `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1376
1381
  );
1377
- expect(res.status).toBe(302);
1378
- expect(res.headers.get("location")).toContain("/admin/login");
1382
+ expect(res.status).toBe(401);
1383
+ expect(await res.json()).toEqual({ error: "signed_out" });
1379
1384
  });
1380
1385
 
1381
1386
  it("streams bytes with an audio/* Content-Type for a .webm attachment", async () => {
@@ -114,14 +114,18 @@ export function renderPushToggle(args: PushToggleArgs): string {
114
114
  var json = subscription.toJSON();
115
115
  return fetch(SUBSCRIBE_URL, {
116
116
  method: 'POST',
117
- headers: { 'Content-Type': 'application/json' },
117
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
118
118
  body: JSON.stringify({
119
119
  endpoint: subscription.endpoint,
120
120
  p256dh: json.keys && json.keys.p256dh,
121
121
  auth: json.keys && json.keys.auth,
122
122
  }),
123
123
  });
124
- }).then(function() {
124
+ }).then(function(res) {
125
+ if (res && res.status === 401) {
126
+ window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
127
+ return;
128
+ }
125
129
  setStatus('Notifications enabled.');
126
130
  btn.textContent = 'Notifications on';
127
131
  });
package/src/pwa.ts CHANGED
@@ -115,6 +115,7 @@ export function buildManifest(
115
115
  const NON_NAVIGABLE_EXACT_PATHS: readonly string[] = [
116
116
  "/admin/manifest.webmanifest",
117
117
  "/admin/sw.js",
118
+ "/admin/session.json",
118
119
  "/admin/offline.html",
119
120
  "/admin/login",
120
121
  "/admin/logout",