@dudousxd/nestjs-agent-dashboard 0.9.1 → 0.10.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.
@@ -261,6 +261,56 @@ function clearSessionCookie(context) {
261
261
  appendSetCookie(context.response, cookie);
262
262
  }
263
263
  __name(clearSessionCookie, "clearSessionCookie");
264
+ var inFlightRevalidations = /* @__PURE__ */ new Map();
265
+ function revalidationKey(session) {
266
+ return `${session.sub}:${session.iat}`;
267
+ }
268
+ __name(revalidationKey, "revalidationKey");
269
+ function getOrStartRevalidation(auth, session, user) {
270
+ const hook = auth.revalidate;
271
+ if (!hook) return Promise.resolve(true);
272
+ const key = revalidationKey(session);
273
+ const inFlight = inFlightRevalidations.get(key);
274
+ if (inFlight) return inFlight;
275
+ const outcome = (async () => {
276
+ try {
277
+ return await hook(user);
278
+ } catch {
279
+ return false;
280
+ }
281
+ })().finally(() => {
282
+ inFlightRevalidations.delete(key);
283
+ });
284
+ inFlightRevalidations.set(key, outcome);
285
+ return outcome;
286
+ }
287
+ __name(getOrStartRevalidation, "getOrStartRevalidation");
288
+ async function maybeRenewSession(auth, session, request, response, now = Date.now()) {
289
+ if (now - session.iat <= auth.ttlMs / 2) return true;
290
+ const user = {
291
+ id: session.sub,
292
+ ...session.name !== void 0 ? {
293
+ name: session.name
294
+ } : {},
295
+ roles: session.roles
296
+ };
297
+ const allowed = await getOrStartRevalidation(auth, session, user);
298
+ if (!allowed) {
299
+ clearSessionCookie({
300
+ request,
301
+ response
302
+ });
303
+ return false;
304
+ }
305
+ issueSessionCookie(user, {
306
+ auth,
307
+ request,
308
+ response,
309
+ now
310
+ });
311
+ return true;
312
+ }
313
+ __name(maybeRenewSession, "maybeRenewSession");
264
314
 
265
315
  // src/server/auth/dashboard-auth.guard.ts
266
316
  function _ts_decorate(decorators, target, key, desc) {
@@ -288,14 +338,16 @@ var DashboardAuthGuard = class {
288
338
  constructor(auth) {
289
339
  this.auth = auth;
290
340
  }
291
- canActivate(context) {
341
+ async canActivate(context) {
292
342
  if (!this.auth) return true;
293
343
  const http = context.switchToHttp();
294
344
  const request = http.getRequest();
295
345
  const session = this.verifyRequestSession(request);
296
346
  if (!session) throw new UnauthorizedException();
297
347
  attachSession(request, session);
298
- this.maybeRenew(http.getResponse(), request, session);
348
+ if (!await maybeRenewSession(this.auth, session, request, http.getResponse())) {
349
+ throw new UnauthorizedException();
350
+ }
299
351
  return true;
300
352
  }
301
353
  verifyRequestSession(request) {
@@ -307,28 +359,6 @@ var DashboardAuthGuard = class {
307
359
  secret: auth.secret
308
360
  });
309
361
  }
310
- /**
311
- * Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
312
- * response, so an active user never gets logged out mid-session.
313
- */
314
- maybeRenew(response, request, session) {
315
- const auth = this.auth;
316
- if (!auth) return;
317
- const now = Date.now();
318
- if (now - session.iat <= auth.ttlMs / 2) return;
319
- issueSessionCookie({
320
- id: session.sub,
321
- ...session.name !== void 0 ? {
322
- name: session.name
323
- } : {},
324
- roles: session.roles
325
- }, {
326
- auth,
327
- request,
328
- response,
329
- now
330
- });
331
- }
332
362
  };
333
363
  DashboardAuthGuard = _ts_decorate([
334
364
  Injectable(),
@@ -1117,24 +1147,21 @@ AgentApiController = _ts_decorate3([
1117
1147
  ], AgentApiController);
1118
1148
 
1119
1149
  // src/server/agent-dashboard-auth.controller.ts
1120
- import { Body as Body2, Controller as Controller2, Get as Get2, Header, Inject as Inject4, Logger, NotFoundException, Optional as Optional4, Post as Post2, Query as Query2, Req as Req2, Res } from "@nestjs/common";
1150
+ import { Body as Body2, Controller as Controller2, Get as Get2, Header, HttpCode as HttpCode2, Inject as Inject4, Logger, NotFoundException, Optional as Optional4, Post as Post2, Query as Query2, Req as Req2, Res, UnauthorizedException as UnauthorizedException2 } from "@nestjs/common";
1121
1151
 
1122
1152
  // src/server/auth/login-page.ts
1123
1153
  function escapeHtml(value) {
1124
1154
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1125
1155
  }
1126
1156
  __name(escapeHtml, "escapeHtml");
1127
- function renderLoginPage(options) {
1128
- const action = escapeHtml(options.actionUrl);
1129
- const returnTo = escapeHtml(options.returnTo);
1130
- const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
1157
+ function pageShell(content, title) {
1131
1158
  return `<!doctype html>
1132
1159
  <html lang="en">
1133
1160
  <head>
1134
1161
  <meta charset="utf-8" />
1135
1162
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1136
1163
  <meta name="robots" content="noindex, nofollow" />
1137
- <title>Sign in \u2014 AI Gateway</title>
1164
+ <title>${title}</title>
1138
1165
  <style>
1139
1166
  :root { color-scheme: dark; }
1140
1167
  * { box-sizing: border-box; }
@@ -1165,6 +1192,13 @@ function renderLoginPage(options) {
1165
1192
  font-weight: 600;
1166
1193
  color: #34d399;
1167
1194
  }
1195
+ h1 {
1196
+ margin: 0 0 12px;
1197
+ font-size: 16px;
1198
+ font-weight: 600;
1199
+ color: #f4f4f5;
1200
+ }
1201
+ p { margin: 0 0 16px; color: #a1a1aa; }
1168
1202
  form { display: flex; flex-direction: column; gap: 16px; }
1169
1203
  label { display: flex; flex-direction: column; gap: 6px; }
1170
1204
  .field-label {
@@ -1203,7 +1237,17 @@ function renderLoginPage(options) {
1203
1237
  </head>
1204
1238
  <body>
1205
1239
  <div class="card">
1206
- <p class="brand">AI Gateway</p>
1240
+ ${content}
1241
+ </div>
1242
+ </body>
1243
+ </html>`;
1244
+ }
1245
+ __name(pageShell, "pageShell");
1246
+ function renderLoginPage(options) {
1247
+ const action = escapeHtml(options.actionUrl);
1248
+ const returnTo = escapeHtml(options.returnTo);
1249
+ const errorBanner = options.error === true ? '<p role="alert" class="error">Invalid username or password.</p>' : "";
1250
+ return pageShell(`<p class="brand">AI Gateway</p>
1207
1251
  <form method="post" action="${action}">
1208
1252
  <input type="hidden" name="returnTo" value="${returnTo}" />
1209
1253
  <label>
@@ -1216,12 +1260,15 @@ function renderLoginPage(options) {
1216
1260
  </label>
1217
1261
  ${errorBanner}
1218
1262
  <button type="submit">Sign in</button>
1219
- </form>
1220
- </div>
1221
- </body>
1222
- </html>`;
1263
+ </form>`, "Sign in \u2014 AI Gateway");
1223
1264
  }
1224
1265
  __name(renderLoginPage, "renderLoginPage");
1266
+ function renderSessionRequiredPage() {
1267
+ return pageShell(`<p class="brand">AI Gateway</p>
1268
+ <h1>Open this console from your application</h1>
1269
+ <p>Your session is minted by the host app. Use its console launcher to sign in, then reload.</p>`, "Sign in \u2014 AI Gateway");
1270
+ }
1271
+ __name(renderSessionRequiredPage, "renderSessionRequiredPage");
1225
1272
 
1226
1273
  // src/server/auth/sanitize-return-to.ts
1227
1274
  function sanitizeReturnTo(basePath, returnTo) {
@@ -1267,14 +1314,15 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1267
1314
  auth;
1268
1315
  basePath;
1269
1316
  logger = new Logger(_AgentDashboardAuthController.name);
1270
- /** Warn once (not per request) so a broken hook can't be used to flood the logs. */
1271
- warnedLoginHookThrow = false;
1317
+ /** Warn once PER HOOK KIND (not per request) so a broken hook can't be used to flood the logs. */
1318
+ warnedHooks = /* @__PURE__ */ new Set();
1272
1319
  constructor(auth, basePath) {
1273
1320
  this.auth = auth;
1274
1321
  this.basePath = basePath;
1275
1322
  }
1276
1323
  loginPage(req, res, returnToQuery, error) {
1277
- this.requireAuth();
1324
+ const auth = this.requireAuth();
1325
+ if (!auth.modes.includes("login")) throw new NotFoundException();
1278
1326
  const returnTo = sanitizeReturnTo(this.basePath, returnToQuery);
1279
1327
  if (this.currentSession(req)) {
1280
1328
  res.status(302);
@@ -1289,6 +1337,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1289
1337
  }
1290
1338
  async login(body, req, res) {
1291
1339
  const auth = this.requireAuth();
1340
+ if (!auth.modes.includes("login")) throw new NotFoundException();
1292
1341
  const returnTo = sanitizeReturnTo(this.basePath, body?.returnTo);
1293
1342
  const username = body?.username;
1294
1343
  const password = body?.password;
@@ -1296,7 +1345,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1296
1345
  this.redirectToLoginError(res, returnTo);
1297
1346
  return "";
1298
1347
  }
1299
- const user = await this.runLoginHook(auth, username.trim(), isString(password) ? password : "");
1348
+ const user = await this.runHook("login", () => auth.login?.(username.trim(), isString(password) ? password : "") ?? null);
1300
1349
  if (!user) {
1301
1350
  this.redirectToLoginError(res, returnTo);
1302
1351
  return "";
@@ -1316,9 +1365,30 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1316
1365
  response: res
1317
1366
  });
1318
1367
  res.status(302);
1319
- res.setHeader("Location", `${this.basePath}/auth/login`);
1368
+ res.setHeader("Location", this.auth && !this.auth.modes.includes("login") ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
1320
1369
  return "";
1321
1370
  }
1371
+ // Mode A: the host frontend (carrying its own auth) POSTs here; the host hook validates the raw
1372
+ // request and returns the session user, or `null` to deny. No credential reaches this library.
1373
+ async session(request, response) {
1374
+ const auth = this.requireAuth();
1375
+ if (!auth.modes.includes("session")) throw new NotFoundException();
1376
+ const user = await this.runHook("session", () => auth.session?.(request) ?? null);
1377
+ if (!user) throw new UnauthorizedException2();
1378
+ issueSessionCookie(user, {
1379
+ auth,
1380
+ request,
1381
+ response
1382
+ });
1383
+ }
1384
+ // Mode-A-only landing: the host mints the session, so there is nothing to submit here. Serves
1385
+ // as the redirect target `DashboardAuthPageGuard` bounces to instead of `/auth/login`, which
1386
+ // 404s under Mode A (see `loginPage` above).
1387
+ sessionRequiredPage() {
1388
+ const auth = this.requireAuth();
1389
+ if (auth.modes.includes("login")) throw new NotFoundException();
1390
+ return renderSessionRequiredPage();
1391
+ }
1322
1392
  /** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
1323
1393
  redirectToLoginError(res, returnTo) {
1324
1394
  res.status(303);
@@ -1334,17 +1404,17 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
1334
1404
  }) !== null;
1335
1405
  }
1336
1406
  /**
1337
- * Run the host's `login` hook defensively: a throw (sync or async) is treated as a denial
1338
- * (`null`) and warn-logged ONCE, so a buggy/unreachable hook never 500s the endpoint into a
1339
- * stack-trace leak nor floods the logs.
1407
+ * Run a host hook (`login` or `session`) defensively: a throw (sync or async) is treated as a
1408
+ * denial (`null`) and warn-logged ONCE PER KIND, so a buggy/unreachable hook never 500s the
1409
+ * endpoint into a stack-trace leak nor floods the logs on repeated bad attempts.
1340
1410
  */
1341
- async runLoginHook(auth, username, password) {
1411
+ async runHook(kind, run) {
1342
1412
  try {
1343
- return await auth.login(username, password) ?? null;
1413
+ return await run() ?? null;
1344
1414
  } catch (error) {
1345
- if (!this.warnedLoginHookThrow) {
1346
- this.warnedLoginHookThrow = true;
1347
- this.logger.warn(`AgentDashboardModule dashboardAuth login hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1415
+ if (!this.warnedHooks.has(kind)) {
1416
+ this.warnedHooks.add(kind);
1417
+ this.logger.warn(`AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
1348
1418
  }
1349
1419
  return null;
1350
1420
  }
@@ -1401,6 +1471,28 @@ _ts_decorate4([
1401
1471
  ]),
1402
1472
  _ts_metadata4("design:returntype", String)
1403
1473
  ], AgentDashboardAuthController.prototype, "logout", null);
1474
+ _ts_decorate4([
1475
+ Post2("session"),
1476
+ HttpCode2(204),
1477
+ _ts_param4(0, Req2()),
1478
+ _ts_param4(1, Res({
1479
+ passthrough: true
1480
+ })),
1481
+ _ts_metadata4("design:type", Function),
1482
+ _ts_metadata4("design:paramtypes", [
1483
+ Object,
1484
+ Object
1485
+ ]),
1486
+ _ts_metadata4("design:returntype", Promise)
1487
+ ], AgentDashboardAuthController.prototype, "session", null);
1488
+ _ts_decorate4([
1489
+ Get2("session-required"),
1490
+ Header("Content-Type", "text/html; charset=utf-8"),
1491
+ Header("Cache-Control", "no-store, must-revalidate"),
1492
+ _ts_metadata4("design:type", Function),
1493
+ _ts_metadata4("design:paramtypes", []),
1494
+ _ts_metadata4("design:returntype", String)
1495
+ ], AgentDashboardAuthController.prototype, "sessionRequiredPage", null);
1404
1496
  AgentDashboardAuthController = _ts_decorate4([
1405
1497
  Controller2("auth"),
1406
1498
  _ts_param4(0, Optional4()),
@@ -1513,19 +1605,39 @@ var DashboardAuthPageGuard = class {
1513
1605
  this.auth = auth;
1514
1606
  this.basePath = basePath;
1515
1607
  }
1516
- canActivate(context) {
1608
+ async canActivate(context) {
1517
1609
  if (!this.auth) return true;
1610
+ const auth = this.auth;
1518
1611
  const http = context.switchToHttp();
1519
1612
  const request = http.getRequest();
1520
1613
  const session = this.verifyRequestSession(request);
1521
- if (!session) {
1522
- const returnTo = readOriginalUrl(request);
1523
- throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
1524
- }
1614
+ if (!session) this.deny(auth, request);
1525
1615
  attachSession(request, session);
1526
- this.maybeRenew(http.getResponse(), request, session);
1616
+ if (!await maybeRenewSession(auth, session, request, http.getResponse())) {
1617
+ this.deny(auth, request);
1618
+ }
1527
1619
  return true;
1528
1620
  }
1621
+ /**
1622
+ * No valid session: bounce the browser navigation onward. Under Mode B (`login` configured)
1623
+ * that's the built-in login screen, carrying `returnTo` back to the page that was requested.
1624
+ * Under Mode-A-only there is no login screen to redirect to — the host mints the session
1625
+ * itself — so this instead serves the instruction page rather than redirecting into a 404.
1626
+ * Pulled out of `canActivate` so both the "no session at all" and the "revalidate revoked a
1627
+ * renewable session" paths reuse the exact same mode-aware deny logic.
1628
+ *
1629
+ * Takes `auth` explicitly rather than reading `this.auth`: `canActivate`'s `!this.auth` early
1630
+ * return narrows the field within that method only — TS doesn't carry it across a method call —
1631
+ * so the caller passes the already-narrowed value instead of this method re-deriving (or
1632
+ * asserting away) the nullability.
1633
+ */
1634
+ deny(auth, request) {
1635
+ if (!auth.modes.includes("login")) {
1636
+ throw new DashboardAuthRedirect(`${this.basePath}/auth/session-required`);
1637
+ }
1638
+ const returnTo = readOriginalUrl(request);
1639
+ throw new DashboardAuthRedirect(`${this.basePath}/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
1640
+ }
1529
1641
  verifyRequestSession(request) {
1530
1642
  const auth = this.auth;
1531
1643
  if (!auth) return null;
@@ -1535,28 +1647,6 @@ var DashboardAuthPageGuard = class {
1535
1647
  secret: auth.secret
1536
1648
  });
1537
1649
  }
1538
- /**
1539
- * Sliding renewal: a valid cookie past 50% of its TTL is transparently re-issued on the
1540
- * response, so an active user never gets logged out mid-session.
1541
- */
1542
- maybeRenew(response, request, session) {
1543
- const auth = this.auth;
1544
- if (!auth) return;
1545
- const now = Date.now();
1546
- if (now - session.iat <= auth.ttlMs / 2) return;
1547
- issueSessionCookie({
1548
- id: session.sub,
1549
- ...session.name !== void 0 ? {
1550
- name: session.name
1551
- } : {},
1552
- roles: session.roles
1553
- }, {
1554
- auth,
1555
- request,
1556
- response,
1557
- now
1558
- });
1559
- }
1560
1650
  };
1561
1651
  DashboardAuthPageGuard = _ts_decorate6([
1562
1652
  Injectable4(),
@@ -1715,13 +1805,38 @@ function resolveDashboardAuth(options) {
1715
1805
  if (typeof options.secret !== "string" || options.secret === "") {
1716
1806
  throw new Error("AgentDashboardModule dashboardAuth: `secret` is required and must be a non-empty string (HMAC-SHA256 signing key, 32+ bytes recommended). Failing closed.");
1717
1807
  }
1718
- if (typeof options.login !== "function") {
1719
- throw new Error("AgentDashboardModule dashboardAuth: `login` is required (validates submitted credentials for the built-in login screen). Failing closed.");
1808
+ const modes = [];
1809
+ if (options.session !== void 0) {
1810
+ if (typeof options.session !== "function") {
1811
+ throw new Error("AgentDashboardModule dashboardAuth: `session` must be a function.");
1812
+ }
1813
+ modes.push("session");
1814
+ }
1815
+ if (options.login !== void 0) {
1816
+ if (typeof options.login !== "function") {
1817
+ throw new Error("AgentDashboardModule dashboardAuth: `login` must be a function.");
1818
+ }
1819
+ modes.push("login");
1820
+ }
1821
+ if (options.revalidate !== void 0 && typeof options.revalidate !== "function") {
1822
+ throw new Error("AgentDashboardModule dashboardAuth: `revalidate` must be a function.");
1823
+ }
1824
+ if (modes.length === 0) {
1825
+ throw new Error("AgentDashboardModule dashboardAuth: at least one of `session` (the host mints the session from its own auth) or `login` (the built-in login screen) is required \u2014 otherwise the cookie can never be minted. Failing closed.");
1720
1826
  }
1721
1827
  return {
1722
1828
  secret: options.secret,
1723
1829
  ttlMs: durationToMs(options.ttl ?? DEFAULT_TTL),
1724
- login: options.login
1830
+ modes,
1831
+ ...options.session !== void 0 ? {
1832
+ session: options.session
1833
+ } : {},
1834
+ ...options.login !== void 0 ? {
1835
+ login: options.login
1836
+ } : {},
1837
+ ...options.revalidate !== void 0 ? {
1838
+ revalidate: options.revalidate
1839
+ } : {}
1725
1840
  };
1726
1841
  }
1727
1842
  __name(resolveDashboardAuth, "resolveDashboardAuth");