@dudousxd/nestjs-media-dashboard 0.8.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.
@@ -66,6 +66,9 @@ function resolveConsoleAuth(options) {
66
66
  if (modes.length === 0) {
67
67
  throw new Error("MediaDashboardModule: auth needs a `login` and/or `session` hook.");
68
68
  }
69
+ if (options.unauthenticatedPage !== void 0 && typeof options.unauthenticatedPage !== "function") {
70
+ throw new Error("MediaDashboardModule: auth.unauthenticatedPage must be a function.");
71
+ }
69
72
  return {
70
73
  secret: options.secret,
71
74
  ttlMs: durationToMs(options.ttl),
@@ -75,6 +78,12 @@ function resolveConsoleAuth(options) {
75
78
  } : {},
76
79
  ...options.login ? {
77
80
  login: options.login
81
+ } : {},
82
+ ...options.revalidate ? {
83
+ revalidate: options.revalidate
84
+ } : {},
85
+ ...options.unauthenticatedPage ? {
86
+ unauthenticatedPage: options.unauthenticatedPage
78
87
  } : {}
79
88
  };
80
89
  }
@@ -221,6 +230,26 @@ function appendSetCookie(response, cookie) {
221
230
  ]);
222
231
  }
223
232
  __name(appendSetCookie, "appendSetCookie");
233
+ function resolveEndableResponse(response) {
234
+ const isEndable = /* @__PURE__ */ __name((value) => isRawNodeResponse(value) && typeof value.end === "function", "isEndable");
235
+ if (isEndable(response)) return response;
236
+ if (isRecord2(response) && isEndable(response.raw)) return response.raw;
237
+ return null;
238
+ }
239
+ __name(resolveEndableResponse, "resolveEndableResponse");
240
+ function responseAlreadyWritten(response) {
241
+ return resolveEndableResponse(response)?.headersSent === true;
242
+ }
243
+ __name(responseAlreadyWritten, "responseAlreadyWritten");
244
+ function sendHtml(response, status, html) {
245
+ const raw = resolveEndableResponse(response);
246
+ if (!raw) return;
247
+ raw.statusCode = status;
248
+ raw.setHeader("content-type", "text/html; charset=utf-8");
249
+ raw.setHeader("cache-control", "no-store, must-revalidate");
250
+ raw.end(html);
251
+ }
252
+ __name(sendHtml, "sendHtml");
224
253
 
225
254
  // src/server/auth/session-cookie.ts
226
255
  var import_node_crypto = require("crypto");
@@ -349,14 +378,16 @@ var MediaConsoleGuard = class {
349
378
  this.auth = auth;
350
379
  this.cookiePath = cookiePath;
351
380
  }
352
- canActivate(context) {
381
+ async canActivate(context) {
353
382
  if (!this.auth) return true;
354
383
  const http = context.switchToHttp();
355
384
  const request = http.getRequest();
356
385
  const session = this.verifyRequestSession(request);
357
386
  if (!session) throw new import_common.UnauthorizedException();
358
387
  attachSession(request, session);
359
- this.maybeRenew(http.getResponse(), request, session);
388
+ if (!await this.maybeRenew(http.getResponse(), request, session)) {
389
+ throw new import_common.UnauthorizedException();
390
+ }
360
391
  return true;
361
392
  }
362
393
  verifyRequestSession(request) {
@@ -368,26 +399,46 @@ var MediaConsoleGuard = class {
368
399
  });
369
400
  }
370
401
  /**
371
- * Sliding renewal: when a valid cookie is past half its TTL, re-issue a fresh one so active users
372
- * never get logged out mid-session. Appends a new Set-Cookie (preserving any others already set).
402
+ * Sliding renewal + revalidation: when a valid cookie is past half its TTL, re-issue a fresh one
403
+ * so active users never get logged out mid-session — but first let the host's `revalidate` hook
404
+ * re-check the user, so a deactivated or demoted operator loses access instead of riding a
405
+ * self-renewing cookie. Returns `false` when the session was revoked (cookie already cleared).
373
406
  */
374
- maybeRenew(response, request, session) {
375
- if (!this.auth) return;
407
+ async maybeRenew(response, request, session) {
408
+ if (!this.auth) return true;
376
409
  const now = Date.now();
377
- if (now - session.iat <= this.auth.ttlMs / 2) return;
378
- issueSessionCookie({
410
+ if (now - session.iat <= this.auth.ttlMs / 2) return true;
411
+ const user = {
379
412
  id: session.sub,
380
413
  ...session.name !== void 0 ? {
381
414
  name: session.name
382
415
  } : {},
383
416
  roles: session.roles
384
- }, {
417
+ };
418
+ if (this.auth.revalidate) {
419
+ let allowed;
420
+ try {
421
+ allowed = await this.auth.revalidate(user);
422
+ } catch {
423
+ allowed = false;
424
+ }
425
+ if (!allowed) {
426
+ clearSessionCookie({
427
+ cookiePath: this.cookiePath ?? "/",
428
+ request,
429
+ response
430
+ });
431
+ return false;
432
+ }
433
+ }
434
+ issueSessionCookie(user, {
385
435
  auth: this.auth,
386
436
  cookiePath: this.cookiePath ?? "/",
387
437
  request,
388
438
  response,
389
439
  now
390
440
  });
441
+ return true;
391
442
  }
392
443
  };
393
444
  MediaConsoleGuard = _ts_decorate([
@@ -1458,8 +1509,14 @@ var MediaConsoleApiModule = class _MediaConsoleApiModule {
1458
1509
  // none — it's used as-is.
1459
1510
  ...(options.guards ?? []).filter(isGuardClass)
1460
1511
  ],
1512
+ // MEDIA_CONSOLE_AUTH is exported (not just provided) because MediaDashboardUiController —
1513
+ // hosted in MediaDashboardModule, which imports THIS module — reads it to decide whether to
1514
+ // hand an unauthenticated navigation to the host's `unauthenticatedPage`. Exporting keeps the
1515
+ // single `authProvider` instance shared; re-providing the same factory in both modules would
1516
+ // run a `forRootAsync` host's `useAuth` twice.
1461
1517
  exports: [
1462
- MediaConsoleService
1518
+ MediaConsoleService,
1519
+ MEDIA_CONSOLE_AUTH
1463
1520
  ]
1464
1521
  };
1465
1522
  }
@@ -1504,27 +1561,77 @@ var CONTENT_TYPES = {
1504
1561
  ".woff2": "font/woff2",
1505
1562
  ".ico": "image/x-icon"
1506
1563
  };
1507
- var MediaDashboardUiController = class {
1564
+ var MediaDashboardUiController = class _MediaDashboardUiController {
1508
1565
  static {
1509
1566
  __name(this, "MediaDashboardUiController");
1510
1567
  }
1511
1568
  basePath;
1512
1569
  apiBasePath;
1570
+ auth;
1513
1571
  dir = spaDir();
1514
- constructor(basePath, apiBasePath) {
1572
+ logger = new import_common7.Logger(_MediaDashboardUiController.name);
1573
+ warned = false;
1574
+ constructor(basePath, apiBasePath, auth = null) {
1515
1575
  this.basePath = basePath;
1516
1576
  this.apiBasePath = apiBasePath;
1577
+ this.auth = auth;
1517
1578
  }
1518
1579
  // index.html references hash-named bundles, so it MUST NOT be cached (stale bundle = "stuck
1519
- // loading after a deploy"). The hashed assets below are immutable.
1520
- index() {
1580
+ // loading after a deploy"). The hashed assets below are immutable. `sendHtml` sets both headers;
1581
+ // they are no longer `@Header` decorators because this route is now non-passthrough (see below).
1582
+ //
1583
+ // Non-passthrough `@Res()` so a host's `auth.unauthenticatedPage` can OWN the response and serve
1584
+ // its own page in place of the SPA. Without a hook this behaves exactly as before: the shell is
1585
+ // served to everyone, and the SPA's own auth screen renders once its API calls come back 401.
1586
+ async index(req, res) {
1587
+ if (await this.serveUnauthenticatedPage(req, res)) return;
1521
1588
  const indexPath = (0, import_node_path.join)(this.dir, "index.html");
1522
1589
  if (!(0, import_node_fs.existsSync)(indexPath)) {
1523
1590
  throw new import_common7.NotFoundException("Console SPA is not built. Run the package build.");
1524
1591
  }
1525
1592
  const html = (0, import_node_fs.readFileSync)(indexPath, "utf8").replaceAll(`="${BUILD_BASE}/`, `="${this.basePath}/`);
1526
1593
  const inject = `<script>window.__MEDIA_BASE__='${this.basePath}';window.__MEDIA_API__='${this.apiBasePath}';</script>`;
1527
- return html.includes("</head>") ? html.replace("</head>", `${inject}</head>`) : inject + html;
1594
+ sendHtml(res, 200, html.includes("</head>") ? html.replace("</head>", `${inject}</head>`) : inject + html);
1595
+ }
1596
+ /**
1597
+ * Hand the response to the host's `unauthenticatedPage` when this navigation has no valid session.
1598
+ * Returns `true` when the host wrote a page (the caller must not also serve the SPA).
1599
+ *
1600
+ * Mode-A-only, deliberately: with `login` configured the SPA's own login form is the way in, so
1601
+ * replacing the shell would lock a Mode B host out of its own console.
1602
+ */
1603
+ async serveUnauthenticatedPage(req, res) {
1604
+ const auth = this.auth;
1605
+ const hook = auth?.unauthenticatedPage;
1606
+ if (!auth || !hook) return false;
1607
+ if (auth.modes.includes("login")) return false;
1608
+ if (this.hasValidSession(auth, req)) return false;
1609
+ try {
1610
+ await hook({
1611
+ request: req,
1612
+ response: res,
1613
+ basePath: this.basePath
1614
+ });
1615
+ if (responseAlreadyWritten(res)) return true;
1616
+ this.warnOnce("auth.unauthenticatedPage returned without writing a response; serving the console SPA instead. The hook must write AND end the response it is given.");
1617
+ } catch (error) {
1618
+ this.warnOnce(`auth.unauthenticatedPage threw; serving the console SPA instead. ${String(error)}`);
1619
+ if (responseAlreadyWritten(res)) return true;
1620
+ }
1621
+ return false;
1622
+ }
1623
+ hasValidSession(auth, req) {
1624
+ const cookieValue = parseCookieHeader(readCookieHeader(req))[SESSION_COOKIE_NAME];
1625
+ if (cookieValue === void 0) return false;
1626
+ return verifySessionCookie(cookieValue, {
1627
+ secret: auth.secret
1628
+ }) !== null;
1629
+ }
1630
+ /** Once per process, not once per request: a broken hook fires on every unauthenticated visit. */
1631
+ warnOnce(message) {
1632
+ if (this.warned) return;
1633
+ this.warned = true;
1634
+ this.logger.warn(message);
1528
1635
  }
1529
1636
  asset(file) {
1530
1637
  const safe = (0, import_node_path.basename)(file);
@@ -1542,11 +1649,14 @@ var MediaDashboardUiController = class {
1542
1649
  };
1543
1650
  _ts_decorate7([
1544
1651
  (0, import_common7.Get)(),
1545
- (0, import_common7.Header)("Content-Type", "text/html; charset=utf-8"),
1546
- (0, import_common7.Header)("Cache-Control", "no-store, must-revalidate"),
1652
+ _ts_param6(0, (0, import_common7.Req)()),
1653
+ _ts_param6(1, (0, import_common7.Res)()),
1547
1654
  _ts_metadata6("design:type", Function),
1548
- _ts_metadata6("design:paramtypes", []),
1549
- _ts_metadata6("design:returntype", String)
1655
+ _ts_metadata6("design:paramtypes", [
1656
+ Object,
1657
+ Object
1658
+ ]),
1659
+ _ts_metadata6("design:returntype", Promise)
1550
1660
  ], MediaDashboardUiController.prototype, "index", null);
1551
1661
  _ts_decorate7([
1552
1662
  (0, import_common7.Get)("assets/:file"),
@@ -1562,10 +1672,13 @@ MediaDashboardUiController = _ts_decorate7([
1562
1672
  (0, import_common7.Controller)(),
1563
1673
  _ts_param6(0, (0, import_common7.Inject)(MEDIA_DASHBOARD_BASE_PATH)),
1564
1674
  _ts_param6(1, (0, import_common7.Inject)(MEDIA_DASHBOARD_API_PATH)),
1675
+ _ts_param6(2, (0, import_common7.Optional)()),
1676
+ _ts_param6(2, (0, import_common7.Inject)(MEDIA_CONSOLE_AUTH)),
1565
1677
  _ts_metadata6("design:type", Function),
1566
1678
  _ts_metadata6("design:paramtypes", [
1567
1679
  String,
1568
- String
1680
+ String,
1681
+ Object
1569
1682
  ])
1570
1683
  ], MediaDashboardUiController);
1571
1684