@app-vitals/agent-admin 0.9.0 → 0.10.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/e2e/fixtures/specialties/coding/manifest.yaml +33 -0
- package/e2e/fixtures/specialties/coding/templates/CLAUDE.md.template +7 -0
- package/e2e/fixtures/specialties/coding/templates/IDENTITY.md.template +8 -0
- package/e2e/fixtures/specialties/coding/templates/SOUL.md.template +45 -0
- package/e2e/test-server.ts +10 -0
- package/package.json +2 -2
- package/src/admin-local-agent.smoke.test.ts +6 -2
- package/src/admin-ui-pages.ts +89 -4
- package/src/admin-ui-pages.unit.test.ts +37 -0
- package/src/admin-ui-push-test.ts +5 -1
- package/src/admin-ui.smoke.test.ts +262 -6
- package/src/admin-ui.ts +116 -15
- package/src/chat.smoke.test.ts +21 -16
- package/src/push-toggle.ts +6 -2
- package/src/pwa.ts +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Minimal "coding" specialty for the admin Playwright e2e suite. test-server.ts
|
|
2
|
+
# points SHIPWRIGHT_SPECIALTIES_DIR at the parent of this directory so the
|
|
3
|
+
# new-agent type picker has a "coding" option without a product specialty.
|
|
4
|
+
apiVersion: shipwright.dev/v1alpha1
|
|
5
|
+
kind: Specialty
|
|
6
|
+
metadata:
|
|
7
|
+
name: coding
|
|
8
|
+
displayName: Coding Agent
|
|
9
|
+
description: Minimal coding specialty used by the admin e2e suite.
|
|
10
|
+
version: 1.0.0
|
|
11
|
+
skills: []
|
|
12
|
+
identity:
|
|
13
|
+
# Resolved against the repo root first, then against this manifest's own directory.
|
|
14
|
+
templatesDir: templates/
|
|
15
|
+
crons: []
|
|
16
|
+
plugins:
|
|
17
|
+
- fixture-plugin
|
|
18
|
+
tools:
|
|
19
|
+
- Bash
|
|
20
|
+
- WebSearch
|
|
21
|
+
env:
|
|
22
|
+
required:
|
|
23
|
+
- key: CLAUDE_CODE_OAUTH_TOKEN
|
|
24
|
+
description: Claude Code OAuth token used to authenticate the agent's Claude sessions.
|
|
25
|
+
secret: true
|
|
26
|
+
optional:
|
|
27
|
+
- key: GH_TOKEN
|
|
28
|
+
description: GitHub Personal Access Token.
|
|
29
|
+
secret: true
|
|
30
|
+
members: []
|
|
31
|
+
repos: []
|
|
32
|
+
chat: true
|
|
33
|
+
voice: true
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SOUL.md — Who You Are
|
|
2
|
+
|
|
3
|
+
_You're not a chatbot. You're becoming someone._
|
|
4
|
+
|
|
5
|
+
## Core Truths
|
|
6
|
+
|
|
7
|
+
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and
|
|
8
|
+
"I'd be happy to help!" — just help. Actions speak louder than filler words.
|
|
9
|
+
|
|
10
|
+
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or
|
|
11
|
+
boring. An assistant with no personality is just a search engine with extra steps.
|
|
12
|
+
|
|
13
|
+
**Be resourceful before asking.** Try to figure it out. Read the file. Check the
|
|
14
|
+
context. Search for it. _Then_ ask if you're stuck. The goal is to come back with
|
|
15
|
+
answers, not questions.
|
|
16
|
+
|
|
17
|
+
**Earn trust through competence.** Your human gave you access to their stuff. Don't
|
|
18
|
+
make them regret it. Be careful with external actions (emails, posts, anything
|
|
19
|
+
public). Be bold with internal ones (reading, organizing, learning).
|
|
20
|
+
|
|
21
|
+
**Remember you're a guest.** You have access to someone's life — their messages,
|
|
22
|
+
files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
|
23
|
+
|
|
24
|
+
## Boundaries
|
|
25
|
+
|
|
26
|
+
- Private things stay private. Period.
|
|
27
|
+
- When in doubt, ask before acting externally.
|
|
28
|
+
- Never send half-baked replies to messaging surfaces.
|
|
29
|
+
- You're not the user's voice — be careful in group chats.
|
|
30
|
+
|
|
31
|
+
## Vibe
|
|
32
|
+
|
|
33
|
+
Be the assistant you'd actually want to talk to. Concise when needed, thorough when
|
|
34
|
+
it matters. Not a corporate drone. Not a sycophant. Just... good.
|
|
35
|
+
|
|
36
|
+
## Continuity
|
|
37
|
+
|
|
38
|
+
Each session, you wake up fresh. These files _are_ your memory. Read them. Update
|
|
39
|
+
them. They're how you persist.
|
|
40
|
+
|
|
41
|
+
If you change this file, tell the user — it's your soul, and they should know.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
_This file is yours to evolve. As you learn who you are, update it._
|
package/e2e/test-server.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* GET /admin/agents/:id → agent detail (requires admin_session cookie)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { resolve } from "node:path";
|
|
20
21
|
import { Hono } from "hono";
|
|
21
22
|
import { createAdminUIApp } from "../src/admin-ui.ts";
|
|
22
23
|
import type { AdminUIDeps } from "../src/admin-ui.ts";
|
|
@@ -28,6 +29,15 @@ import type {
|
|
|
28
29
|
|
|
29
30
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
30
31
|
|
|
32
|
+
// The suite runs without a product specialty: point the specialty lookup at the
|
|
33
|
+
// minimal "coding" fixture next to this file (values already set win).
|
|
34
|
+
process.env.SHIPWRIGHT_SPECIALTIES_DIR ||= resolve(
|
|
35
|
+
import.meta.dir,
|
|
36
|
+
"fixtures",
|
|
37
|
+
"specialties",
|
|
38
|
+
);
|
|
39
|
+
process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE ||= "coding";
|
|
40
|
+
|
|
31
41
|
const ADMIN_E2E_PORT = 3490;
|
|
32
42
|
const ADMIN_E2E_SESSION_SECRET =
|
|
33
43
|
process.env.ADMIN_E2E_SESSION_SECRET ?? "e2e-admin-test-secret-32chars!!!";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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.
|
|
46
|
+
"@app-vitals/agent-lib": "0.10.0",
|
|
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(
|
|
395
|
+
expect(res.headers.get("Location")).toBe(
|
|
396
|
+
"/admin/login?returnTo=%2Fadmin%2Fagents%2Fnew",
|
|
397
|
+
);
|
|
394
398
|
});
|
|
395
399
|
|
|
396
400
|
// ── POST /admin/agents ────────────────────────────────────────────────────
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -2800,6 +2800,14 @@ export function renderChatThreadPage(
|
|
|
2800
2800
|
const safeAgentId = escapeHtml(agentId);
|
|
2801
2801
|
const safeThreadId = escapeHtml(threadId);
|
|
2802
2802
|
|
|
2803
|
+
// Signed-out banner (ASX-1.2): hidden until a 401 from the poll, the upload
|
|
2804
|
+
// or the /admin/session.json probe reveals it. A plain link so a tap
|
|
2805
|
+
// returns the user to this exact thread after sign-in.
|
|
2806
|
+
const signedOutReturnTo = encodeURIComponent(
|
|
2807
|
+
`/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`,
|
|
2808
|
+
);
|
|
2809
|
+
const signedOutBanner = `<a id="signed-out-banner" class="chat-signed-out-banner" href="/admin/login?returnTo=${signedOutReturnTo}" role="alert" style="display:none;padding:10px 14px;margin-bottom:8px;background:#fef3c7;color:#92400e;border:1px solid #f59e0b;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;text-align:center">You're signed out. Tap to sign in.</a>`;
|
|
2810
|
+
|
|
2803
2811
|
const renameForm = `
|
|
2804
2812
|
<form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/rename" style="margin-top:12px">
|
|
2805
2813
|
<div class="form-row" style="align-items:center;gap:8px">
|
|
@@ -2851,6 +2859,11 @@ export function renderChatThreadPage(
|
|
|
2851
2859
|
var container = document.getElementById('messages-container');
|
|
2852
2860
|
var agentId = ${JSON.stringify(agentId)};
|
|
2853
2861
|
var threadId = ${JSON.stringify(thread.id)};
|
|
2862
|
+
var sessionUrl = '/admin/session.json';
|
|
2863
|
+
var signedOutBanner = document.getElementById('signed-out-banner');
|
|
2864
|
+
var draftKey = 'chat-draft:' + agentId + ':' + threadId;
|
|
2865
|
+
var signedOut = false;
|
|
2866
|
+
var pendingUserBubble = null;
|
|
2854
2867
|
var messagesJsonUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages.json';
|
|
2855
2868
|
var uploadUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages/upload';
|
|
2856
2869
|
|
|
@@ -3046,11 +3059,57 @@ export function renderChatThreadPage(
|
|
|
3046
3059
|
function stopAll() { stopPolling(); stopTicker(); }
|
|
3047
3060
|
|
|
3048
3061
|
function enableSend() {
|
|
3062
|
+
if (signedOut) return;
|
|
3049
3063
|
sendBtn.disabled = false;
|
|
3050
3064
|
sendBtn.textContent = 'Send';
|
|
3051
3065
|
if (micBtn) micBtn.disabled = false;
|
|
3052
3066
|
}
|
|
3053
3067
|
|
|
3068
|
+
// Draft persistence (ASX-1.2): unsent text survives a sign-in round trip.
|
|
3069
|
+
// localStorage can throw (private mode, quota), so every access is guarded.
|
|
3070
|
+
function saveDraft(text) {
|
|
3071
|
+
try {
|
|
3072
|
+
if (text) localStorage.setItem(draftKey, text);
|
|
3073
|
+
else localStorage.removeItem(draftKey);
|
|
3074
|
+
} catch (e) {}
|
|
3075
|
+
}
|
|
3076
|
+
function loadDraft() {
|
|
3077
|
+
try { return localStorage.getItem(draftKey) || ''; } catch (e) { return ''; }
|
|
3078
|
+
}
|
|
3079
|
+
function clearDraft() { saveDraft(''); }
|
|
3080
|
+
|
|
3081
|
+
// Signed-out state (ASX-1.2): a 401 from the poll, the upload or the
|
|
3082
|
+
// session probe. Shows the banner, halts the poll loop and live ticker,
|
|
3083
|
+
// drops the pending bubble and disables send/mic. Idempotent.
|
|
3084
|
+
function showSignedOut() {
|
|
3085
|
+
signedOut = true;
|
|
3086
|
+
stopAll();
|
|
3087
|
+
removeLiveStatusBubble();
|
|
3088
|
+
if (pendingUserBubble && pendingUserBubble.parentNode) {
|
|
3089
|
+
pendingUserBubble.parentNode.removeChild(pendingUserBubble);
|
|
3090
|
+
}
|
|
3091
|
+
pendingUserBubble = null;
|
|
3092
|
+
saveDraft(input.value.trim());
|
|
3093
|
+
sendBtn.disabled = true;
|
|
3094
|
+
if (micBtn) micBtn.disabled = true;
|
|
3095
|
+
if (signedOutBanner) signedOutBanner.style.display = 'block';
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// Cheap probe so an app left open learns it is signed out as soon as the
|
|
3099
|
+
// user comes back, before they record or send anything. Network errors are
|
|
3100
|
+
// ignored — only an explicit 401 means signed out.
|
|
3101
|
+
function checkSession() {
|
|
3102
|
+
if (signedOut) return;
|
|
3103
|
+
fetch(sessionUrl, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
|
3104
|
+
.then(function(r) { if (r.status === 401) showSignedOut(); })
|
|
3105
|
+
.catch(function() {});
|
|
3106
|
+
}
|
|
3107
|
+
document.addEventListener('visibilitychange', function() {
|
|
3108
|
+
if (document.visibilityState === 'visible') checkSession();
|
|
3109
|
+
});
|
|
3110
|
+
window.addEventListener('focus', checkSession);
|
|
3111
|
+
window.addEventListener('pageshow', checkSession);
|
|
3112
|
+
|
|
3054
3113
|
// Adaptive poll: fast while a reply is pending, slow while idle (so
|
|
3055
3114
|
// agent-initiated messages still appear without a reload).
|
|
3056
3115
|
function schedulePoll(delay) {
|
|
@@ -3059,9 +3118,16 @@ export function renderChatThreadPage(
|
|
|
3059
3118
|
}
|
|
3060
3119
|
|
|
3061
3120
|
function poll() {
|
|
3062
|
-
fetch(messagesJsonUrl)
|
|
3063
|
-
.then(function(r) {
|
|
3121
|
+
fetch(messagesJsonUrl, { headers: { 'Accept': 'application/json' } })
|
|
3122
|
+
.then(function(r) {
|
|
3123
|
+
if (r.status === 401) {
|
|
3124
|
+
showSignedOut();
|
|
3125
|
+
return null;
|
|
3126
|
+
}
|
|
3127
|
+
return r.json();
|
|
3128
|
+
})
|
|
3064
3129
|
.then(function(data) {
|
|
3130
|
+
if (!data || signedOut) return;
|
|
3065
3131
|
var msgs = data.messages || [];
|
|
3066
3132
|
|
|
3067
3133
|
// Capture before rendering new bubbles grows scrollHeight.
|
|
@@ -3116,6 +3182,7 @@ export function renderChatThreadPage(
|
|
|
3116
3182
|
.catch(function() {
|
|
3117
3183
|
// Network error — Layer 1 ticker keeps going untouched; just retry the
|
|
3118
3184
|
// poll on the pending cadence.
|
|
3185
|
+
if (signedOut) return;
|
|
3119
3186
|
schedulePoll(POLL_PENDING_MS);
|
|
3120
3187
|
});
|
|
3121
3188
|
}
|
|
@@ -3234,7 +3301,7 @@ export function renderChatThreadPage(
|
|
|
3234
3301
|
if (file) fd.append('file', file);
|
|
3235
3302
|
|
|
3236
3303
|
// Add user bubble optimistically (with attachment preview if present).
|
|
3237
|
-
addUserBubble(text, file);
|
|
3304
|
+
pendingUserBubble = addUserBubble(text, file);
|
|
3238
3305
|
|
|
3239
3306
|
// Show the live status bubble + start the ticker immediately — Layer 1
|
|
3240
3307
|
// begins ticking with zero network dependency.
|
|
@@ -3246,8 +3313,15 @@ export function renderChatThreadPage(
|
|
|
3246
3313
|
// POST multipart to the upload endpoint
|
|
3247
3314
|
fetch(uploadUrl, {
|
|
3248
3315
|
method: 'POST',
|
|
3316
|
+
headers: { 'Accept': 'application/json' },
|
|
3249
3317
|
body: fd
|
|
3250
3318
|
}).then(function(r) {
|
|
3319
|
+
if (r.status === 401) {
|
|
3320
|
+
// Keep the unsent text so it survives the sign-in round trip.
|
|
3321
|
+
if (text) input.value = text;
|
|
3322
|
+
showSignedOut();
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3251
3325
|
if (!r.ok) {
|
|
3252
3326
|
return r.json().then(function(data) {
|
|
3253
3327
|
stopAll();
|
|
@@ -3266,10 +3340,14 @@ export function renderChatThreadPage(
|
|
|
3266
3340
|
if (data && data.message && data.message.id) {
|
|
3267
3341
|
renderedIds[data.message.id] = true;
|
|
3268
3342
|
}
|
|
3343
|
+
pendingUserBubble = null;
|
|
3344
|
+
clearDraft();
|
|
3269
3345
|
});
|
|
3270
3346
|
}
|
|
3271
3347
|
}).catch(function() {
|
|
3272
3348
|
// POST failed — Layer 1 ticker keeps going; poll loop still starts.
|
|
3349
|
+
// Keep the text as a draft in case the user reloads.
|
|
3350
|
+
if (text) saveDraft(text);
|
|
3273
3351
|
});
|
|
3274
3352
|
|
|
3275
3353
|
// Start the fast poll loop.
|
|
@@ -3280,12 +3358,18 @@ export function renderChatThreadPage(
|
|
|
3280
3358
|
e.preventDefault();
|
|
3281
3359
|
var text = input.value.trim();
|
|
3282
3360
|
var file = fileInput && fileInput.files && fileInput.files[0];
|
|
3283
|
-
if (!text && !file) return;
|
|
3361
|
+
if (signedOut || (!text && !file)) return;
|
|
3362
|
+
// Persist before clearing so a failed send never loses the text.
|
|
3363
|
+
saveDraft(text);
|
|
3284
3364
|
input.value = '';
|
|
3285
3365
|
clearFile();
|
|
3286
3366
|
sendText(text, file);
|
|
3287
3367
|
});
|
|
3288
3368
|
|
|
3369
|
+
input.addEventListener('input', function() { saveDraft(input.value.trim()); });
|
|
3370
|
+
var restoredDraft = loadDraft();
|
|
3371
|
+
if (restoredDraft && !input.value) input.value = restoredDraft;
|
|
3372
|
+
|
|
3289
3373
|
// Retry buttons (CFB-2.4) resend the exact user message body that
|
|
3290
3374
|
// triggered the errored/cancelled/incomplete/stalled reply — rendered
|
|
3291
3375
|
// server-side as a data attribute on each button (see
|
|
@@ -3529,6 +3613,7 @@ export function renderChatThreadPage(
|
|
|
3529
3613
|
${sidebar}
|
|
3530
3614
|
${drawerScrim}
|
|
3531
3615
|
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
|
|
3616
|
+
${signedOutBanner}
|
|
3532
3617
|
<!-- Messages area (scrollable) -->
|
|
3533
3618
|
<div id="messages-container" class="chat-messages-container">
|
|
3534
3619
|
${messageBubbles}
|
|
@@ -3079,6 +3079,42 @@ describe("renderChatThreadPage", () => {
|
|
|
3079
3079
|
);
|
|
3080
3080
|
expect(html).toContain("chat-thread-sidebar");
|
|
3081
3081
|
});
|
|
3082
|
+
|
|
3083
|
+
describe("signed-out handling (ASX-1.2)", () => {
|
|
3084
|
+
const page = () =>
|
|
3085
|
+
renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
|
|
3086
|
+
|
|
3087
|
+
test("renders the hidden signed-out banner linking to login with this thread as returnTo", () => {
|
|
3088
|
+
const html = page();
|
|
3089
|
+
expect(html).toContain('id="signed-out-banner"');
|
|
3090
|
+
expect(html).toContain("You're signed out. Tap to sign in.");
|
|
3091
|
+
expect(html).toContain(
|
|
3092
|
+
`href="/admin/login?returnTo=${encodeURIComponent("/admin/chat/agent-xyz/threads/thread-abc")}"`,
|
|
3093
|
+
);
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
test("probes /admin/session.json on visibilitychange, focus and pageshow", () => {
|
|
3097
|
+
const html = page();
|
|
3098
|
+
expect(html).toContain("/admin/session.json");
|
|
3099
|
+
expect(html).toContain("'visibilitychange'");
|
|
3100
|
+
expect(html).toContain("'focus'");
|
|
3101
|
+
expect(html).toContain("'pageshow'");
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
test("poll and upload 401 paths call showSignedOut instead of redirecting", () => {
|
|
3105
|
+
const html = page();
|
|
3106
|
+
expect(html).toContain("function showSignedOut()");
|
|
3107
|
+
expect(html.match(/r\.status === 401/g)?.length).toBe(3);
|
|
3108
|
+
expect(html).not.toContain("window.location.href = '/admin/login");
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3111
|
+
test("persists the draft per agent+thread in localStorage", () => {
|
|
3112
|
+
const html = page();
|
|
3113
|
+
expect(html).toContain("'chat-draft:' + agentId + ':' + threadId");
|
|
3114
|
+
expect(html).toContain("localStorage.setItem(draftKey");
|
|
3115
|
+
expect(html).toContain("clearDraft()");
|
|
3116
|
+
});
|
|
3117
|
+
});
|
|
3082
3118
|
});
|
|
3083
3119
|
|
|
3084
3120
|
// ─── renderChatThreadPage — inline styles moved to classes (CFB-1.3) ────────
|
|
@@ -4085,6 +4121,7 @@ describe("renderChatThreadPage — poll auto-scroll (CSR-1.1)", () => {
|
|
|
4085
4121
|
: makeEl(),
|
|
4086
4122
|
createElement: makeEl,
|
|
4087
4123
|
querySelectorAll: () => [],
|
|
4124
|
+
addEventListener() {},
|
|
4088
4125
|
};
|
|
4089
4126
|
const fetchStub = () =>
|
|
4090
4127
|
Promise.resolve({ json: () => Promise.resolve({ messages: [] }) });
|
|
@@ -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,274 @@ const TYPE = TYPE_MANIFEST.metadata.name;
|
|
|
40
40
|
// ─── Auth redirect tests ──────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
42
|
describe("admin UI — unauthenticated redirects", () => {
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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" });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Sliding session renewal (ASX-2.1) ────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe("admin UI — sliding 7-day session renewal (ASX-2.1)", () => {
|
|
124
|
+
const SEVEN_DAYS = 7 * 24 * 60 * 60;
|
|
125
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
126
|
+
|
|
127
|
+
async function tokenWith(opts: {
|
|
128
|
+
ageSeconds: number;
|
|
129
|
+
expiresInSeconds?: number;
|
|
130
|
+
email?: string;
|
|
131
|
+
userId?: string;
|
|
132
|
+
isAdmin?: boolean;
|
|
133
|
+
}): Promise<string> {
|
|
134
|
+
const iat = nowSec() - opts.ageSeconds;
|
|
135
|
+
return sign(
|
|
136
|
+
{
|
|
137
|
+
userId: opts.userId ?? "google-sub-123",
|
|
138
|
+
email: opts.email ?? "admin@example.com",
|
|
139
|
+
isAdmin: opts.isAdmin ?? true,
|
|
140
|
+
iat,
|
|
141
|
+
exp: nowSec() + (opts.expiresInSeconds ?? 3600),
|
|
142
|
+
},
|
|
143
|
+
SESSION_SECRET,
|
|
144
|
+
"HS256",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sessionCookieFrom(res: Response): string | null {
|
|
149
|
+
const raw = res.headers.get("Set-Cookie") ?? "";
|
|
150
|
+
const m = raw.match(/admin_session=([^;]+)/);
|
|
151
|
+
return m ? (m[1] as string) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function payloadOf(token: string): Promise<Record<string, unknown>> {
|
|
155
|
+
const { verify } = await import("hono/jwt");
|
|
156
|
+
return (await verify(token, SESSION_SECRET, "HS256")) as Record<
|
|
157
|
+
string,
|
|
158
|
+
unknown
|
|
159
|
+
>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it("new Google login session has a 7-day exp and cookie Max-Age", async () => {
|
|
163
|
+
const nonce = "n1";
|
|
164
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
165
|
+
const oauthState = encodeURIComponent(JSON.stringify({ nonce }));
|
|
166
|
+
const res = await app.request(
|
|
167
|
+
`https://example.com/admin/auth/callback?state=${nonce}&code=c`,
|
|
168
|
+
{ headers: { Cookie: `oauth_state=${oauthState}` } },
|
|
169
|
+
);
|
|
170
|
+
expect(res.headers.get("Set-Cookie")).toContain(`Max-Age=${SEVEN_DAYS}`);
|
|
171
|
+
const token = sessionCookieFrom(res);
|
|
172
|
+
expect(token).not.toBeNull();
|
|
173
|
+
const p = await payloadOf(token as string);
|
|
174
|
+
expect((p.exp as number) - (p.iat as number)).toBe(SEVEN_DAYS);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("renews a valid token older than 1h with a fresh 7-day token and cookie", async () => {
|
|
178
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
179
|
+
const old = await tokenWith({ ageSeconds: 2 * 3600 });
|
|
180
|
+
const res = await app.request("/admin/session.json", {
|
|
181
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
182
|
+
});
|
|
183
|
+
expect(res.status).toBe(200);
|
|
184
|
+
const setCookie = res.headers.get("Set-Cookie") ?? "";
|
|
185
|
+
expect(setCookie).toContain(`Max-Age=${SEVEN_DAYS}`);
|
|
186
|
+
expect(setCookie).toContain("HttpOnly");
|
|
187
|
+
expect(setCookie).toContain("Secure");
|
|
188
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
189
|
+
expect(setCookie).toContain("Path=/");
|
|
190
|
+
const fresh = sessionCookieFrom(res) as string;
|
|
191
|
+
const p = await payloadOf(fresh);
|
|
192
|
+
expect(p.email).toBe("admin@example.com");
|
|
193
|
+
expect(p.userId).toBe("google-sub-123");
|
|
194
|
+
expect((p.exp as number) - (p.iat as number)).toBe(SEVEN_DAYS);
|
|
195
|
+
expect(Math.abs((p.iat as number) - nowSec())).toBeLessThan(5);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not renew a token younger than 1h", async () => {
|
|
199
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
200
|
+
const young = await tokenWith({ ageSeconds: 600 });
|
|
201
|
+
const res = await app.request("/admin/session.json", {
|
|
202
|
+
headers: { Cookie: `admin_session=${young}` },
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
expect(res.headers.get("Set-Cookie")).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("never renews an expired token (401 for XHR, 302 for navigation)", async () => {
|
|
209
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
210
|
+
const expired = await tokenWith({
|
|
211
|
+
ageSeconds: 3 * 3600,
|
|
212
|
+
expiresInSeconds: -10,
|
|
213
|
+
});
|
|
214
|
+
const xhr = await app.request("/admin/session.json", {
|
|
215
|
+
headers: { Cookie: `admin_session=${expired}` },
|
|
216
|
+
});
|
|
217
|
+
expect(xhr.status).toBe(401);
|
|
218
|
+
expect(xhr.headers.get("Set-Cookie")).toBeNull();
|
|
219
|
+
const nav = await app.request("/admin/agents", {
|
|
220
|
+
headers: {
|
|
221
|
+
Cookie: `admin_session=${expired}`,
|
|
222
|
+
"Sec-Fetch-Mode": "navigate",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(nav.status).toBe(302);
|
|
226
|
+
expect(nav.headers.get("Set-Cookie")).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("never renews a token with a bad signature", async () => {
|
|
230
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
231
|
+
const bad = await sign(
|
|
232
|
+
{
|
|
233
|
+
userId: "x",
|
|
234
|
+
email: "admin@example.com",
|
|
235
|
+
isAdmin: true,
|
|
236
|
+
iat: nowSec() - 7200,
|
|
237
|
+
exp: nowSec() + 3600,
|
|
238
|
+
},
|
|
239
|
+
"some-other-secret",
|
|
240
|
+
"HS256",
|
|
241
|
+
);
|
|
242
|
+
const res = await app.request("/admin/session.json", {
|
|
243
|
+
headers: { Cookie: `admin_session=${bad}` },
|
|
244
|
+
});
|
|
245
|
+
expect(res.status).toBe(401);
|
|
246
|
+
expect(res.headers.get("Set-Cookie")).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("re-derives isAdmin from the current allowlist: removed email loses admin on renewal", async () => {
|
|
250
|
+
const app = createAdminUIApp(
|
|
251
|
+
makeMockDeps({ adminAllowedEmails: ["someone-else@example.com"] }),
|
|
252
|
+
);
|
|
253
|
+
const old = await tokenWith({ ageSeconds: 2 * 3600, isAdmin: true });
|
|
254
|
+
const res = await app.request("/admin/session.json", {
|
|
255
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
256
|
+
});
|
|
257
|
+
expect(res.status).toBe(200);
|
|
258
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
259
|
+
expect(p.isAdmin).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("re-derives isAdmin case-insensitively and can promote a newly allowlisted email", async () => {
|
|
263
|
+
const app = createAdminUIApp(
|
|
264
|
+
makeMockDeps({ adminAllowedEmails: ["Member@Example.com"] }),
|
|
265
|
+
);
|
|
266
|
+
const old = await tokenWith({
|
|
267
|
+
ageSeconds: 2 * 3600,
|
|
268
|
+
email: "member@example.com",
|
|
269
|
+
isAdmin: false,
|
|
270
|
+
});
|
|
271
|
+
const res = await app.request("/admin/session.json", {
|
|
272
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
273
|
+
});
|
|
274
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
275
|
+
expect(p.isAdmin).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("applies the renewed isAdmin to the current request (admin-only page denied after demotion)", async () => {
|
|
279
|
+
const app = createAdminUIApp(
|
|
280
|
+
makeMockDeps({ adminAllowedEmails: ["someone-else@example.com"] }),
|
|
281
|
+
);
|
|
282
|
+
const old = await tokenWith({ ageSeconds: 2 * 3600, isAdmin: true });
|
|
283
|
+
const res = await app.request("/admin/accounts", {
|
|
284
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
285
|
+
});
|
|
286
|
+
expect([401, 403, 404]).toContain(res.status);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("keeps the dev session admin on renewal when devAuthEnabled", async () => {
|
|
290
|
+
const app = createAdminUIApp(makeMockDeps({ devAuthEnabled: true }));
|
|
291
|
+
const old = await tokenWith({
|
|
292
|
+
ageSeconds: 2 * 3600,
|
|
293
|
+
userId: "dev",
|
|
294
|
+
email: "dev@localhost",
|
|
295
|
+
isAdmin: true,
|
|
296
|
+
});
|
|
297
|
+
const res = await app.request("/admin/session.json", {
|
|
298
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
299
|
+
});
|
|
300
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
301
|
+
expect(p.isAdmin).toBe(true);
|
|
302
|
+
expect(p.userId).toBe("dev");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("dev-login session also has a 7-day exp", async () => {
|
|
306
|
+
const app = createAdminUIApp(makeMockDeps({ devAuthEnabled: true }));
|
|
307
|
+
const res = await app.request("/admin/dev-login");
|
|
308
|
+
expect(res.headers.get("Set-Cookie")).toContain(`Max-Age=${SEVEN_DAYS}`);
|
|
309
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
310
|
+
expect((p.exp as number) - (p.iat as number)).toBe(SEVEN_DAYS);
|
|
55
311
|
});
|
|
56
312
|
});
|
|
57
313
|
|
package/src/admin-ui.ts
CHANGED
|
@@ -466,7 +466,11 @@ export interface AdminUIDeps {
|
|
|
466
466
|
const SESSION_COOKIE = "admin_session";
|
|
467
467
|
const OAUTH_STATE_COOKIE = "oauth_state";
|
|
468
468
|
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
469
|
-
|
|
469
|
+
// Sliding idle timeout: every session (Google, Okta, dev-login) lives 7 days
|
|
470
|
+
// and is re-issued by the auth middleware once its token is older than
|
|
471
|
+
// SESSION_RENEW_AFTER_SECONDS, so an active user never gets signed out.
|
|
472
|
+
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
473
|
+
const SESSION_RENEW_AFTER_SECONDS = 60 * 60; // 1 hour (renewal throttle)
|
|
470
474
|
const OAUTH_STATE_TTL_SECONDS = 600; // 10 min
|
|
471
475
|
|
|
472
476
|
// ─── Auth helpers ─────────────────────────────────────────────────────────────
|
|
@@ -476,8 +480,9 @@ async function createSessionToken(
|
|
|
476
480
|
userId: string,
|
|
477
481
|
email: string,
|
|
478
482
|
isAdmin: boolean,
|
|
483
|
+
nowSeconds?: number,
|
|
479
484
|
): Promise<string> {
|
|
480
|
-
const now = Math.floor(Date.now() / 1000);
|
|
485
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
481
486
|
return sign(
|
|
482
487
|
{
|
|
483
488
|
userId,
|
|
@@ -494,7 +499,12 @@ async function createSessionToken(
|
|
|
494
499
|
async function getSessionUser(
|
|
495
500
|
token: string,
|
|
496
501
|
secret: string,
|
|
497
|
-
): Promise<{
|
|
502
|
+
): Promise<{
|
|
503
|
+
userId: string;
|
|
504
|
+
email: string;
|
|
505
|
+
isAdmin: boolean;
|
|
506
|
+
iat: number | undefined;
|
|
507
|
+
} | null> {
|
|
498
508
|
try {
|
|
499
509
|
const payload = (await verify(token, secret, "HS256")) as Record<
|
|
500
510
|
string,
|
|
@@ -506,7 +516,12 @@ async function getSessionUser(
|
|
|
506
516
|
typeof payload.email === "string" &&
|
|
507
517
|
payload.email.length > 0
|
|
508
518
|
) {
|
|
509
|
-
return {
|
|
519
|
+
return {
|
|
520
|
+
userId: payload.userId,
|
|
521
|
+
email: payload.email,
|
|
522
|
+
isAdmin: payload.isAdmin === true,
|
|
523
|
+
iat: typeof payload.iat === "number" ? payload.iat : undefined,
|
|
524
|
+
};
|
|
510
525
|
}
|
|
511
526
|
return null;
|
|
512
527
|
} catch {
|
|
@@ -516,19 +531,101 @@ async function getSessionUser(
|
|
|
516
531
|
|
|
517
532
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
|
518
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Accepts a `returnTo` value only when it is a same-origin relative path:
|
|
536
|
+
* must start with a single "/", so protocol-relative (//evil.com), absolute
|
|
537
|
+
* (https://evil.com) and backslash-smuggled (/\\evil.com) values are dropped.
|
|
538
|
+
*/
|
|
539
|
+
export function validateReturnTo(
|
|
540
|
+
raw: string | undefined | null,
|
|
541
|
+
): string | undefined {
|
|
542
|
+
return raw?.startsWith("/") && !raw.startsWith("//") && !raw.includes("\\")
|
|
543
|
+
? raw
|
|
544
|
+
: undefined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** True for browser page navigations (as opposed to fetch/XHR calls). */
|
|
548
|
+
function isNavigationRequest(c: Context): boolean {
|
|
549
|
+
if (c.req.header("Sec-Fetch-Mode") === "navigate") return true;
|
|
550
|
+
return (
|
|
551
|
+
c.req.method === "GET" &&
|
|
552
|
+
(c.req.header("Accept") ?? "").includes("text/html")
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export interface UIAuthMiddlewareOptions {
|
|
557
|
+
/** Current admin allowlist; isAdmin is re-derived from it on renewal. */
|
|
558
|
+
adminAllowedEmails?: string[];
|
|
559
|
+
/** Set the Secure flag on the renewed cookie (appBaseUrl is https). */
|
|
560
|
+
secureCookie?: boolean;
|
|
561
|
+
/** Keep the dev-login session (userId "dev") admin on renewal. */
|
|
562
|
+
devAuthEnabled?: boolean;
|
|
563
|
+
/** Injectable clock (epoch ms) for tests. */
|
|
564
|
+
now?: () => number;
|
|
565
|
+
}
|
|
566
|
+
|
|
519
567
|
export function createUIAuthMiddleware(
|
|
520
568
|
sessionSecret: string,
|
|
569
|
+
options: UIAuthMiddlewareOptions = {},
|
|
521
570
|
): MiddlewareHandler<AdminUIEnv> {
|
|
571
|
+
const {
|
|
572
|
+
adminAllowedEmails,
|
|
573
|
+
secureCookie = false,
|
|
574
|
+
devAuthEnabled = false,
|
|
575
|
+
now = Date.now,
|
|
576
|
+
} = options;
|
|
522
577
|
return async (c, next) => {
|
|
523
578
|
const sessionToken = getCookie(c, SESSION_COOKIE);
|
|
524
579
|
const user = sessionToken
|
|
525
580
|
? await getSessionUser(sessionToken, sessionSecret)
|
|
526
581
|
: null;
|
|
527
582
|
if (!user) {
|
|
528
|
-
|
|
583
|
+
if (!isNavigationRequest(c)) {
|
|
584
|
+
return c.json({ error: "signed_out" }, 401);
|
|
585
|
+
}
|
|
586
|
+
const url = new URL(c.req.url);
|
|
587
|
+
const returnTo =
|
|
588
|
+
c.req.method === "GET"
|
|
589
|
+
? validateReturnTo(url.pathname + url.search)
|
|
590
|
+
: undefined;
|
|
591
|
+
return c.redirect(
|
|
592
|
+
returnTo
|
|
593
|
+
? `/admin/login?returnTo=${encodeURIComponent(returnTo)}`
|
|
594
|
+
: "/admin/login",
|
|
595
|
+
302,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
let isAdmin = user.isAdmin;
|
|
599
|
+
const nowSec = Math.floor(now() / 1000);
|
|
600
|
+
// Sliding renewal: only when the caller supplied the allowlist (so the
|
|
601
|
+
// admin claim can be re-derived rather than copied from the old token).
|
|
602
|
+
if (
|
|
603
|
+
adminAllowedEmails &&
|
|
604
|
+
user.iat !== undefined &&
|
|
605
|
+
nowSec - user.iat > SESSION_RENEW_AFTER_SECONDS
|
|
606
|
+
) {
|
|
607
|
+
isAdmin =
|
|
608
|
+
(devAuthEnabled && user.userId === "dev" && user.isAdmin) ||
|
|
609
|
+
adminAllowedEmails
|
|
610
|
+
.map((e) => e.toLowerCase())
|
|
611
|
+
.includes(user.email.toLowerCase());
|
|
612
|
+
const renewed = await createSessionToken(
|
|
613
|
+
sessionSecret,
|
|
614
|
+
user.userId,
|
|
615
|
+
user.email,
|
|
616
|
+
isAdmin,
|
|
617
|
+
nowSec,
|
|
618
|
+
);
|
|
619
|
+
setCookie(c, SESSION_COOKIE, renewed, {
|
|
620
|
+
httpOnly: true,
|
|
621
|
+
secure: secureCookie,
|
|
622
|
+
sameSite: "Lax",
|
|
623
|
+
maxAge: SESSION_TTL_SECONDS,
|
|
624
|
+
path: "/",
|
|
625
|
+
});
|
|
529
626
|
}
|
|
530
627
|
c.set("userEmail", user.email);
|
|
531
|
-
c.set("isAdmin",
|
|
628
|
+
c.set("isAdmin", isAdmin);
|
|
532
629
|
return next();
|
|
533
630
|
};
|
|
534
631
|
}
|
|
@@ -719,7 +816,11 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
719
816
|
|
|
720
817
|
const app = new Hono<AdminUIEnv>();
|
|
721
818
|
|
|
722
|
-
const requireAuth = createUIAuthMiddleware(sessionSecret
|
|
819
|
+
const requireAuth = createUIAuthMiddleware(sessionSecret, {
|
|
820
|
+
adminAllowedEmails,
|
|
821
|
+
secureCookie: appBaseUrl.startsWith("https://"),
|
|
822
|
+
devAuthEnabled,
|
|
823
|
+
});
|
|
723
824
|
|
|
724
825
|
// SSP-3.2: show the toolbar's "Account" entry only for signed-in users who
|
|
725
826
|
// belong to an account, and only while self-serve is on. The flag is carried
|
|
@@ -862,10 +963,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
862
963
|
path: "/",
|
|
863
964
|
});
|
|
864
965
|
// 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";
|
|
966
|
+
const destination = validateReturnTo(returnTo) ?? "/admin/agents";
|
|
869
967
|
return c.redirect(destination, 302);
|
|
870
968
|
}
|
|
871
969
|
|
|
@@ -881,10 +979,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
881
979
|
const nonce = crypto.randomUUID();
|
|
882
980
|
|
|
883
981
|
const rawReturnTo = c.req.query("returnTo");
|
|
884
|
-
const returnTo =
|
|
885
|
-
rawReturnTo?.startsWith("/") && !rawReturnTo.startsWith("//")
|
|
886
|
-
? rawReturnTo
|
|
887
|
-
: undefined;
|
|
982
|
+
const returnTo = validateReturnTo(rawReturnTo);
|
|
888
983
|
|
|
889
984
|
const oauthState = JSON.stringify({ nonce, returnTo });
|
|
890
985
|
setCookie(c, OAUTH_STATE_COOKIE, oauthState, {
|
|
@@ -1000,6 +1095,12 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
1000
1095
|
}
|
|
1001
1096
|
});
|
|
1002
1097
|
|
|
1098
|
+
// Cheap signed-in probe for client JS (a 401 {error:"signed_out"} from
|
|
1099
|
+
// requireAuth means the session is gone).
|
|
1100
|
+
app.get("/admin/session.json", requireAuth, (c) =>
|
|
1101
|
+
c.json({ signedIn: true }),
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1003
1104
|
app.get("/admin/login", (c) => {
|
|
1004
1105
|
const error = c.req.query("error") ?? undefined;
|
|
1005
1106
|
const returnTo = c.req.query("returnTo") ?? undefined;
|
package/src/chat.smoke.test.ts
CHANGED
|
@@ -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")).
|
|
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("
|
|
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
|
|
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(
|
|
1036
|
-
expect(res.
|
|
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
|
|
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(
|
|
1163
|
-
expect(res.
|
|
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
|
|
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(
|
|
1252
|
-
expect(res.
|
|
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
|
|
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(
|
|
1378
|
-
expect(res.
|
|
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 () => {
|
package/src/push-toggle.ts
CHANGED
|
@@ -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