@dudousxd/nestjs-agent-dashboard 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/server/index.cjs +97 -30
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +62 -13
- package/dist/server/index.d.ts +62 -13
- package/dist/server/index.js +97 -30
- package/dist/server/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -112,6 +112,30 @@ AgentDashboardModule.forRoot({
|
|
|
112
112
|
|
|
113
113
|
Have your host frontend call `POST <basePath>/auth/session` once it knows the visitor is signed in — no body this library reads. Success is `204` with the `Set-Cookie`; a denial is `401` and no cookie is set. An unauthenticated PAGE navigation under Mode-A-only has nowhere to redirect to (there's no login form) — it's served a small, static instruction page at `<basePath>/auth/session-required` instead, telling the visitor to sign in through the host app and reload.
|
|
114
114
|
|
|
115
|
+
##### `unauthenticatedPage` — render that instruction page yourself
|
|
116
|
+
|
|
117
|
+
The built-in page cannot know who hosts the console, so it can only say "open this console from your application" in the abstract — it can't name your launcher, link to it, or look like the rest of your product. Pass `unauthenticatedPage` and the whole response is yours:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
AgentDashboardModule.forRoot({
|
|
121
|
+
dashboardAuth: {
|
|
122
|
+
secret: process.env.AGENT_DASHBOARD_SECRET,
|
|
123
|
+
session: (request) => resolveAdmin(request),
|
|
124
|
+
unauthenticatedPage: ({ request, response, basePath }) => {
|
|
125
|
+
// `request`/`response` are your platform's own objects (Express here). Render however you
|
|
126
|
+
// already render — a template engine, @dudousxd/nestjs-inertia, a plain string.
|
|
127
|
+
(response as Response).status(401).render('console-locked', { returnTo: basePath });
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The hook **owns** the response: it must write AND end it. It replaces what `<basePath>/auth/session-required` *contains*, not the flow that reaches it — a denied navigation is still redirected there.
|
|
134
|
+
|
|
135
|
+
It cannot open the console: it only ever runs on a request that has already been denied. If it throws, or returns without writing, the library logs one warning and falls back to the built-in page, so a broken page can't hang the request or turn a denial into a `500`. It is also not a mode — `unauthenticatedPage` alone still fails boot on the "at least one of `session` or `login`" check.
|
|
136
|
+
|
|
137
|
+
Under Mode B this route stays `404`: there is no session-required page to customise, because a denied navigation goes to the login form instead. To ship your own **login** UI, combine Mode A with this hook and `POST <basePath>/auth/session` from your page — that mint endpoint is the supported primitive for it.
|
|
138
|
+
|
|
115
139
|
#### Mode B — `login` (built-in login screen)
|
|
116
140
|
|
|
117
141
|
No host frontend/IdP to lean on — the console serves its own small, dependency-free, server-rendered login page. An unauthenticated page visit is redirected (302) to a built-in login form at `<basePath>/auth/login`.
|
package/dist/server/index.cjs
CHANGED
|
@@ -189,6 +189,20 @@ function resolveRawResponse(response) {
|
|
|
189
189
|
return null;
|
|
190
190
|
}
|
|
191
191
|
__name(resolveRawResponse, "resolveRawResponse");
|
|
192
|
+
function responseAlreadyWritten(response) {
|
|
193
|
+
const raw = resolveRawResponse(response);
|
|
194
|
+
return raw?.headersSent === true;
|
|
195
|
+
}
|
|
196
|
+
__name(responseAlreadyWritten, "responseAlreadyWritten");
|
|
197
|
+
function sendHtml(response, status, html) {
|
|
198
|
+
const raw = resolveRawResponse(response);
|
|
199
|
+
if (!raw) return;
|
|
200
|
+
raw.statusCode = status;
|
|
201
|
+
raw.setHeader("content-type", "text/html; charset=utf-8");
|
|
202
|
+
raw.setHeader("cache-control", "no-store, must-revalidate");
|
|
203
|
+
raw.end(html);
|
|
204
|
+
}
|
|
205
|
+
__name(sendHtml, "sendHtml");
|
|
192
206
|
function sendRedirect(response, status, location) {
|
|
193
207
|
const raw = resolveRawResponse(response);
|
|
194
208
|
if (!raw) return;
|
|
@@ -303,6 +317,30 @@ function clearSessionCookie(context) {
|
|
|
303
317
|
appendSetCookie(context.response, cookie);
|
|
304
318
|
}
|
|
305
319
|
__name(clearSessionCookie, "clearSessionCookie");
|
|
320
|
+
var inFlightRevalidations = /* @__PURE__ */ new Map();
|
|
321
|
+
function revalidationKey(session) {
|
|
322
|
+
return `${session.sub}:${session.iat}`;
|
|
323
|
+
}
|
|
324
|
+
__name(revalidationKey, "revalidationKey");
|
|
325
|
+
function getOrStartRevalidation(auth, session, user) {
|
|
326
|
+
const hook = auth.revalidate;
|
|
327
|
+
if (!hook) return Promise.resolve(true);
|
|
328
|
+
const key = revalidationKey(session);
|
|
329
|
+
const inFlight = inFlightRevalidations.get(key);
|
|
330
|
+
if (inFlight) return inFlight;
|
|
331
|
+
const outcome = (async () => {
|
|
332
|
+
try {
|
|
333
|
+
return await hook(user);
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
})().finally(() => {
|
|
338
|
+
inFlightRevalidations.delete(key);
|
|
339
|
+
});
|
|
340
|
+
inFlightRevalidations.set(key, outcome);
|
|
341
|
+
return outcome;
|
|
342
|
+
}
|
|
343
|
+
__name(getOrStartRevalidation, "getOrStartRevalidation");
|
|
306
344
|
async function maybeRenewSession(auth, session, request, response, now = Date.now()) {
|
|
307
345
|
if (now - session.iat <= auth.ttlMs / 2) return true;
|
|
308
346
|
const user = {
|
|
@@ -312,20 +350,13 @@ async function maybeRenewSession(auth, session, request, response, now = Date.no
|
|
|
312
350
|
} : {},
|
|
313
351
|
roles: session.roles
|
|
314
352
|
};
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (!allowed) {
|
|
323
|
-
clearSessionCookie({
|
|
324
|
-
request,
|
|
325
|
-
response
|
|
326
|
-
});
|
|
327
|
-
return false;
|
|
328
|
-
}
|
|
353
|
+
const allowed = await getOrStartRevalidation(auth, session, user);
|
|
354
|
+
if (!allowed) {
|
|
355
|
+
clearSessionCookie({
|
|
356
|
+
request,
|
|
357
|
+
response
|
|
358
|
+
});
|
|
359
|
+
return false;
|
|
329
360
|
}
|
|
330
361
|
issueSessionCookie(user, {
|
|
331
362
|
auth,
|
|
@@ -1347,7 +1378,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1347
1378
|
}
|
|
1348
1379
|
loginPage(req, res, returnToQuery, error) {
|
|
1349
1380
|
const auth = this.requireAuth();
|
|
1350
|
-
if (!auth.login) throw new import_common7.NotFoundException();
|
|
1381
|
+
if (!auth.modes.includes("login")) throw new import_common7.NotFoundException();
|
|
1351
1382
|
const returnTo = sanitizeReturnTo(this.basePath, returnToQuery);
|
|
1352
1383
|
if (this.currentSession(req)) {
|
|
1353
1384
|
res.status(302);
|
|
@@ -1362,7 +1393,7 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1362
1393
|
}
|
|
1363
1394
|
async login(body, req, res) {
|
|
1364
1395
|
const auth = this.requireAuth();
|
|
1365
|
-
if (!auth.login) throw new import_common7.NotFoundException();
|
|
1396
|
+
if (!auth.modes.includes("login")) throw new import_common7.NotFoundException();
|
|
1366
1397
|
const returnTo = sanitizeReturnTo(this.basePath, body?.returnTo);
|
|
1367
1398
|
const username = body?.username;
|
|
1368
1399
|
const password = body?.password;
|
|
@@ -1390,14 +1421,14 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1390
1421
|
response: res
|
|
1391
1422
|
});
|
|
1392
1423
|
res.status(302);
|
|
1393
|
-
res.setHeader("Location", this.auth && !this.auth.login ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
|
|
1424
|
+
res.setHeader("Location", this.auth && !this.auth.modes.includes("login") ? `${this.basePath}/auth/session-required` : `${this.basePath}/auth/login`);
|
|
1394
1425
|
return "";
|
|
1395
1426
|
}
|
|
1396
1427
|
// Mode A: the host frontend (carrying its own auth) POSTs here; the host hook validates the raw
|
|
1397
1428
|
// request and returns the session user, or `null` to deny. No credential reaches this library.
|
|
1398
1429
|
async session(request, response) {
|
|
1399
1430
|
const auth = this.requireAuth();
|
|
1400
|
-
if (!auth.session) throw new import_common7.NotFoundException();
|
|
1431
|
+
if (!auth.modes.includes("session")) throw new import_common7.NotFoundException();
|
|
1401
1432
|
const user = await this.runHook("session", () => auth.session?.(request) ?? null);
|
|
1402
1433
|
if (!user) throw new import_common7.UnauthorizedException();
|
|
1403
1434
|
issueSessionCookie(user, {
|
|
@@ -1409,10 +1440,31 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1409
1440
|
// Mode-A-only landing: the host mints the session, so there is nothing to submit here. Serves
|
|
1410
1441
|
// as the redirect target `DashboardAuthPageGuard` bounces to instead of `/auth/login`, which
|
|
1411
1442
|
// 404s under Mode A (see `loginPage` above).
|
|
1412
|
-
|
|
1443
|
+
//
|
|
1444
|
+
// Non-passthrough `@Res()` (unlike its sibling routes) because `dashboardAuth.unauthenticatedPage`
|
|
1445
|
+
// hands the response to the HOST, which writes and ends it itself — there is no return value for
|
|
1446
|
+
// Nest to send, and letting Nest also send one would double-write. The `Content-Type` /
|
|
1447
|
+
// `Cache-Control` this route used to carry as decorators move into `sendHtml` for the built-in
|
|
1448
|
+
// path; the host owns its own headers on the hook path.
|
|
1449
|
+
async sessionRequiredPage(req, res) {
|
|
1413
1450
|
const auth = this.requireAuth();
|
|
1414
|
-
if (auth.login) throw new import_common7.NotFoundException();
|
|
1415
|
-
|
|
1451
|
+
if (auth.modes.includes("login")) throw new import_common7.NotFoundException();
|
|
1452
|
+
const hook = auth.unauthenticatedPage;
|
|
1453
|
+
if (hook) {
|
|
1454
|
+
try {
|
|
1455
|
+
await hook({
|
|
1456
|
+
request: req,
|
|
1457
|
+
response: res,
|
|
1458
|
+
basePath: this.basePath
|
|
1459
|
+
});
|
|
1460
|
+
if (responseAlreadyWritten(res)) return;
|
|
1461
|
+
this.warnHookOnce("unauthenticatedPage", "dashboardAuth.unauthenticatedPage returned without writing a response; falling back to the built-in page. The hook must write AND end the response it is given.");
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
this.warnHookOnce("unauthenticatedPage", `dashboardAuth.unauthenticatedPage threw; falling back to the built-in page. ${String(error)}`);
|
|
1464
|
+
if (responseAlreadyWritten(res)) return;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
sendHtml(res, 200, renderSessionRequiredPage());
|
|
1416
1468
|
}
|
|
1417
1469
|
/** Uniform failure: same redirect (generic error flag) whether the user is unknown or the password is wrong. */
|
|
1418
1470
|
redirectToLoginError(res, returnTo) {
|
|
@@ -1437,13 +1489,19 @@ var AgentDashboardAuthController = class _AgentDashboardAuthController {
|
|
|
1437
1489
|
try {
|
|
1438
1490
|
return await run() ?? null;
|
|
1439
1491
|
} catch (error) {
|
|
1440
|
-
|
|
1441
|
-
this.warnedHooks.add(kind);
|
|
1442
|
-
this.logger.warn(`AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
|
|
1443
|
-
}
|
|
1492
|
+
this.warnHookOnce(kind, `AgentDashboardModule dashboardAuth ${kind} hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`);
|
|
1444
1493
|
return null;
|
|
1445
1494
|
}
|
|
1446
1495
|
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Warn once per hook kind for the life of the process, not once per request: a broken hook fires
|
|
1498
|
+
* on every denied navigation and would otherwise flood the logs.
|
|
1499
|
+
*/
|
|
1500
|
+
warnHookOnce(kind, message) {
|
|
1501
|
+
if (this.warnedHooks.has(kind)) return;
|
|
1502
|
+
this.warnedHooks.add(kind);
|
|
1503
|
+
this.logger.warn(message);
|
|
1504
|
+
}
|
|
1447
1505
|
requireAuth() {
|
|
1448
1506
|
if (!this.auth) throw new import_common7.NotFoundException();
|
|
1449
1507
|
return this.auth;
|
|
@@ -1512,11 +1570,14 @@ _ts_decorate4([
|
|
|
1512
1570
|
], AgentDashboardAuthController.prototype, "session", null);
|
|
1513
1571
|
_ts_decorate4([
|
|
1514
1572
|
(0, import_common7.Get)("session-required"),
|
|
1515
|
-
(0, import_common7.
|
|
1516
|
-
(0, import_common7.
|
|
1573
|
+
_ts_param4(0, (0, import_common7.Req)()),
|
|
1574
|
+
_ts_param4(1, (0, import_common7.Res)()),
|
|
1517
1575
|
_ts_metadata4("design:type", Function),
|
|
1518
|
-
_ts_metadata4("design:paramtypes", [
|
|
1519
|
-
|
|
1576
|
+
_ts_metadata4("design:paramtypes", [
|
|
1577
|
+
typeof AuthPageRequest === "undefined" ? Object : AuthPageRequest,
|
|
1578
|
+
Object
|
|
1579
|
+
]),
|
|
1580
|
+
_ts_metadata4("design:returntype", Promise)
|
|
1520
1581
|
], AgentDashboardAuthController.prototype, "sessionRequiredPage", null);
|
|
1521
1582
|
AgentDashboardAuthController = _ts_decorate4([
|
|
1522
1583
|
(0, import_common7.Controller)("auth"),
|
|
@@ -1657,7 +1718,7 @@ var DashboardAuthPageGuard = class {
|
|
|
1657
1718
|
* asserting away) the nullability.
|
|
1658
1719
|
*/
|
|
1659
1720
|
deny(auth, request) {
|
|
1660
|
-
if (!auth.login) {
|
|
1721
|
+
if (!auth.modes.includes("login")) {
|
|
1661
1722
|
throw new DashboardAuthRedirect(`${this.basePath}/auth/session-required`);
|
|
1662
1723
|
}
|
|
1663
1724
|
const returnTo = readOriginalUrl(request);
|
|
@@ -1846,6 +1907,9 @@ function resolveDashboardAuth(options) {
|
|
|
1846
1907
|
if (options.revalidate !== void 0 && typeof options.revalidate !== "function") {
|
|
1847
1908
|
throw new Error("AgentDashboardModule dashboardAuth: `revalidate` must be a function.");
|
|
1848
1909
|
}
|
|
1910
|
+
if (options.unauthenticatedPage !== void 0 && typeof options.unauthenticatedPage !== "function") {
|
|
1911
|
+
throw new Error("AgentDashboardModule dashboardAuth: `unauthenticatedPage` must be a function.");
|
|
1912
|
+
}
|
|
1849
1913
|
if (modes.length === 0) {
|
|
1850
1914
|
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.");
|
|
1851
1915
|
}
|
|
@@ -1861,6 +1925,9 @@ function resolveDashboardAuth(options) {
|
|
|
1861
1925
|
} : {},
|
|
1862
1926
|
...options.revalidate !== void 0 ? {
|
|
1863
1927
|
revalidate: options.revalidate
|
|
1928
|
+
} : {},
|
|
1929
|
+
...options.unauthenticatedPage !== void 0 ? {
|
|
1930
|
+
unauthenticatedPage: options.unauthenticatedPage
|
|
1864
1931
|
} : {}
|
|
1865
1932
|
};
|
|
1866
1933
|
}
|