@app-vitals/agent-admin 0.9.1 → 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.
@@ -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,7 @@
1
+ # Agent Identity
2
+
3
+ @RUNTIME.md
4
+ @SOUL.md
5
+ @IDENTITY.md
6
+ @BOOTSTRAP.md
7
+ @SPECIALTY.md
@@ -0,0 +1,8 @@
1
+ # Identity
2
+
3
+ - **Name:** _(fill in during bootstrap)_
4
+ - **Role:** _(AI assistant? team member?)_
5
+ - **Vibe:** _(direct? warm? grounded?)_
6
+ - **Emoji:** _(pick one)_
7
+
8
+ <!-- Bootstrap: add @USER.md or @TEAM.md here once you've created the right file -->
@@ -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._
@@ -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.9.1",
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.9.1",
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",
@@ -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) {
@@ -3062,13 +3121,13 @@ export function renderChatThreadPage(
3062
3121
  fetch(messagesJsonUrl, { headers: { 'Accept': 'application/json' } })
3063
3122
  .then(function(r) {
3064
3123
  if (r.status === 401) {
3065
- stopPolling();
3066
- window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
3067
- return { messages: [] };
3124
+ showSignedOut();
3125
+ return null;
3068
3126
  }
3069
3127
  return r.json();
3070
3128
  })
3071
3129
  .then(function(data) {
3130
+ if (!data || signedOut) return;
3072
3131
  var msgs = data.messages || [];
3073
3132
 
3074
3133
  // Capture before rendering new bubbles grows scrollHeight.
@@ -3123,6 +3182,7 @@ export function renderChatThreadPage(
3123
3182
  .catch(function() {
3124
3183
  // Network error — Layer 1 ticker keeps going untouched; just retry the
3125
3184
  // poll on the pending cadence.
3185
+ if (signedOut) return;
3126
3186
  schedulePoll(POLL_PENDING_MS);
3127
3187
  });
3128
3188
  }
@@ -3241,7 +3301,7 @@ export function renderChatThreadPage(
3241
3301
  if (file) fd.append('file', file);
3242
3302
 
3243
3303
  // Add user bubble optimistically (with attachment preview if present).
3244
- addUserBubble(text, file);
3304
+ pendingUserBubble = addUserBubble(text, file);
3245
3305
 
3246
3306
  // Show the live status bubble + start the ticker immediately — Layer 1
3247
3307
  // begins ticking with zero network dependency.
@@ -3257,8 +3317,9 @@ export function renderChatThreadPage(
3257
3317
  body: fd
3258
3318
  }).then(function(r) {
3259
3319
  if (r.status === 401) {
3260
- stopAll();
3261
- window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
3320
+ // Keep the unsent text so it survives the sign-in round trip.
3321
+ if (text) input.value = text;
3322
+ showSignedOut();
3262
3323
  return;
3263
3324
  }
3264
3325
  if (!r.ok) {
@@ -3279,10 +3340,14 @@ export function renderChatThreadPage(
3279
3340
  if (data && data.message && data.message.id) {
3280
3341
  renderedIds[data.message.id] = true;
3281
3342
  }
3343
+ pendingUserBubble = null;
3344
+ clearDraft();
3282
3345
  });
3283
3346
  }
3284
3347
  }).catch(function() {
3285
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);
3286
3351
  });
3287
3352
 
3288
3353
  // Start the fast poll loop.
@@ -3293,12 +3358,18 @@ export function renderChatThreadPage(
3293
3358
  e.preventDefault();
3294
3359
  var text = input.value.trim();
3295
3360
  var file = fileInput && fileInput.files && fileInput.files[0];
3296
- 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);
3297
3364
  input.value = '';
3298
3365
  clearFile();
3299
3366
  sendText(text, file);
3300
3367
  });
3301
3368
 
3369
+ input.addEventListener('input', function() { saveDraft(input.value.trim()); });
3370
+ var restoredDraft = loadDraft();
3371
+ if (restoredDraft && !input.value) input.value = restoredDraft;
3372
+
3302
3373
  // Retry buttons (CFB-2.4) resend the exact user message body that
3303
3374
  // triggered the errored/cancelled/incomplete/stalled reply — rendered
3304
3375
  // server-side as a data attribute on each button (see
@@ -3542,6 +3613,7 @@ export function renderChatThreadPage(
3542
3613
  ${sidebar}
3543
3614
  ${drawerScrim}
3544
3615
  <div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
3616
+ ${signedOutBanner}
3545
3617
  <!-- Messages area (scrollable) -->
3546
3618
  <div id="messages-container" class="chat-messages-container">
3547
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: [] }) });
@@ -118,6 +118,199 @@ describe("admin UI — GET /admin/session.json", () => {
118
118
  });
119
119
  });
120
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);
311
+ });
312
+ });
313
+
121
314
  // ─── Login page ───────────────────────────────────────────────────────────────
122
315
 
123
316
  describe("admin UI — login page", () => {
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
- const SESSION_TTL_SECONDS = 8 * 60 * 60; // 8 hours
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<{ email: string; isAdmin: boolean } | null> {
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 { email: payload.email, isAdmin: payload.isAdmin === true };
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 {
@@ -538,9 +553,27 @@ function isNavigationRequest(c: Context): boolean {
538
553
  );
539
554
  }
540
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
+
541
567
  export function createUIAuthMiddleware(
542
568
  sessionSecret: string,
569
+ options: UIAuthMiddlewareOptions = {},
543
570
  ): MiddlewareHandler<AdminUIEnv> {
571
+ const {
572
+ adminAllowedEmails,
573
+ secureCookie = false,
574
+ devAuthEnabled = false,
575
+ now = Date.now,
576
+ } = options;
544
577
  return async (c, next) => {
545
578
  const sessionToken = getCookie(c, SESSION_COOKIE);
546
579
  const user = sessionToken
@@ -562,8 +595,37 @@ export function createUIAuthMiddleware(
562
595
  302,
563
596
  );
564
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
+ });
626
+ }
565
627
  c.set("userEmail", user.email);
566
- c.set("isAdmin", user.isAdmin);
628
+ c.set("isAdmin", isAdmin);
567
629
  return next();
568
630
  };
569
631
  }
@@ -754,7 +816,11 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
754
816
 
755
817
  const app = new Hono<AdminUIEnv>();
756
818
 
757
- const requireAuth = createUIAuthMiddleware(sessionSecret);
819
+ const requireAuth = createUIAuthMiddleware(sessionSecret, {
820
+ adminAllowedEmails,
821
+ secureCookie: appBaseUrl.startsWith("https://"),
822
+ devAuthEnabled,
823
+ });
758
824
 
759
825
  // SSP-3.2: show the toolbar's "Account" entry only for signed-in users who
760
826
  // belong to an account, and only while self-serve is on. The flag is carried