@camstack/system 1.2.380 → 1.2.382

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.
Files changed (117) hide show
  1. package/dist/addon-runner.js +4 -2
  2. package/dist/addon-runner.mjs +4 -2
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/auth/admin-token-manager.d.ts +75 -0
  6. package/dist/auth/api-key-manager.d.ts +1 -14
  7. package/dist/auth/auth-audit-log.d.ts +59 -0
  8. package/dist/auth/auth-manager.d.ts +28 -16
  9. package/dist/auth/cidr.d.ts +22 -0
  10. package/dist/auth/login-session-manager.d.ts +158 -0
  11. package/dist/auth/user-management-internal.d.ts +69 -0
  12. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  13. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  14. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  15. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  16. package/dist/builtins/alerts/alerts.addon.js +1 -1
  17. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  18. package/dist/builtins/autotrack/index.js +1 -1
  19. package/dist/builtins/autotrack/index.mjs +1 -1
  20. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +14 -0
  21. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +291 -3631
  22. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +284 -3617
  23. package/dist/builtins/backup-orchestrator/encryption-key-store.d.ts +36 -0
  24. package/dist/builtins/backup-orchestrator/manifest-store.d.ts +19 -1
  25. package/dist/builtins/backup-orchestrator/restore-archive.d.ts +24 -0
  26. package/dist/builtins/calendars/calendars.addon.js +1 -1
  27. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  28. package/dist/builtins/camera-grid/index.js +1 -1
  29. package/dist/builtins/camera-grid/index.mjs +1 -1
  30. package/dist/builtins/composer/composer.addon.js +2 -2
  31. package/dist/builtins/composer/composer.addon.mjs +2 -2
  32. package/dist/builtins/console-logging/index.js +1 -1
  33. package/dist/builtins/console-logging/index.mjs +1 -1
  34. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  35. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  36. package/dist/builtins/device-manager/device-aggregation.d.ts +12 -0
  37. package/dist/builtins/device-manager/device-form-secrets.d.ts +25 -0
  38. package/dist/builtins/device-manager/device-manager.addon.d.ts +2 -0
  39. package/dist/builtins/device-manager/device-manager.addon.js +165 -26
  40. package/dist/builtins/device-manager/device-manager.addon.mjs +165 -26
  41. package/dist/builtins/hub-forwarder/index.js +1 -1
  42. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  43. package/dist/builtins/keychain/index.js +1108 -733
  44. package/dist/builtins/keychain/index.mjs +1108 -733
  45. package/dist/builtins/keychain/keychain-reset-trigger.d.ts +25 -0
  46. package/dist/builtins/keychain/keychain-service.d.ts +36 -3
  47. package/dist/builtins/keychain/keychain-store.d.ts +24 -1
  48. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  49. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  50. package/dist/builtins/local-auth/local-auth.addon.d.ts +16 -0
  51. package/dist/builtins/local-auth/local-auth.addon.js +1021 -118
  52. package/dist/builtins/local-auth/local-auth.addon.mjs +1034 -141
  53. package/dist/builtins/local-network/local-network.addon.js +3 -3
  54. package/dist/builtins/local-network/local-network.addon.mjs +3 -3
  55. package/dist/builtins/loki-logging/index.js +97 -29
  56. package/dist/builtins/loki-logging/index.mjs +97 -29
  57. package/dist/builtins/loki-logging/loki-logging.addon.d.ts +7 -23
  58. package/dist/builtins/loki-logging/retire-enabled-flag.d.ts +23 -0
  59. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  60. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  61. package/dist/builtins/platform-probe/index.js +1 -1
  62. package/dist/builtins/platform-probe/index.mjs +1 -1
  63. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  64. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  65. package/dist/builtins/snapshot/index.js +1 -1
  66. package/dist/builtins/snapshot/index.mjs +1 -1
  67. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  68. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  69. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  70. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  71. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +2 -2
  72. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -2
  73. package/dist/builtins/system-backup/backup-encryption.d.ts +57 -0
  74. package/dist/builtins/system-backup/system-backup.service.d.ts +16 -1
  75. package/dist/builtins/system-config/cluster-secret-keeper.d.ts +7 -0
  76. package/dist/builtins/system-config/system-config.addon.d.ts +13 -3
  77. package/dist/builtins/system-config/system-config.addon.js +324 -38
  78. package/dist/builtins/system-config/system-config.addon.mjs +312 -39
  79. package/dist/builtins/winston-logging/index.js +1 -1
  80. package/dist/builtins/winston-logging/index.mjs +1 -1
  81. package/dist/{child-cap-dispatch-CZeRUgYF.js → child-cap-dispatch-BrMdKHfp.js} +6 -3
  82. package/dist/{child-cap-dispatch-CpjohToR.mjs → child-cap-dispatch-WsYPNvnK.mjs} +10 -7
  83. package/dist/{cluster-secret-Dg0OOOSr.js → cluster-secret-BEPLh3LL.js} +1 -1
  84. package/dist/{cluster-secret-BDue-uJj.mjs → cluster-secret-DhPdZ0VA.mjs} +1 -1
  85. package/dist/{composition-sources-CHvLMrFI.js → composition-sources-C6ZynCZ2.js} +1 -1
  86. package/dist/{composition-sources-DT_uJwI3.mjs → composition-sources-XqRX51db.mjs} +1 -1
  87. package/dist/{dist-B9oFw2jh.mjs → dist-CP5TTjwd.mjs} +1713 -157
  88. package/dist/{dist-Cc6KOdY-.js → dist-tnAO12ak.js} +1754 -156
  89. package/dist/{event-loop-stall-monitor-mk6WGxir.js → event-loop-stall-monitor-9kR0sAPS.js} +15 -15
  90. package/dist/{event-loop-stall-monitor-anUH2IE1.mjs → event-loop-stall-monitor-WkssIXjq.mjs} +15 -15
  91. package/dist/{hub-cap-forward-CH6_VWVO.mjs → hub-cap-forward-CDiN1p98.mjs} +18 -2
  92. package/dist/{hub-cap-forward-COCmYaup.js → hub-cap-forward-W8oEm4ME.js} +23 -1
  93. package/dist/index.d.ts +16 -1
  94. package/dist/index.js +3341 -79
  95. package/dist/index.mjs +3567 -354
  96. package/dist/{jwt-signing-key-Qhde8r82.mjs → jwt-signing-key-CAbozwvF.mjs} +1 -1
  97. package/dist/{jwt-signing-key-C2rE3wH6.js → jwt-signing-key-Dgf8sIXY.js} +1 -1
  98. package/dist/kernel/index.d.ts +12 -0
  99. package/dist/kernel/moleculer/broker-factory.d.ts +8 -1
  100. package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +1 -0
  101. package/dist/kernel/moleculer/hub-service.d.ts +9 -0
  102. package/dist/kernel/moleculer/mesh-access-middleware.d.ts +28 -0
  103. package/dist/kernel/moleculer/mesh-auth-mode.d.ts +64 -0
  104. package/dist/kernel/moleculer/mesh-authenticator.d.ts +71 -0
  105. package/dist/kernel/moleculer/mesh-auto-enforce.d.ts +26 -0
  106. package/dist/kernel/moleculer/mesh-join-client.d.ts +51 -0
  107. package/dist/kernel/moleculer/mesh-join-proof.d.ts +39 -0
  108. package/dist/kernel/system-settings-policy.d.ts +4 -3
  109. package/dist/kernel/transport/hub-cap-forward.d.ts +14 -3
  110. package/dist/kernel/transport/index.d.ts +1 -1
  111. package/dist/restore-archive-BV39nY46.mjs +4045 -0
  112. package/dist/restore-archive-CbvrfPuB.js +4139 -0
  113. package/dist/{retired-settings-keys-YOOKLA3v.js → retired-settings-keys-4bHpL-0m.js} +1 -1
  114. package/dist/{retired-settings-keys-DlZ3VV6x.mjs → retired-settings-keys-CzpIkwS-.mjs} +1 -1
  115. package/dist/{tls-CJHogwTl.mjs → tls-4cRH24Id.mjs} +3 -3
  116. package/package.json +1 -1
  117. package/dist/{tls-CCMX7MjX.js → tls-CljF057D.js} +1 -1
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-Cc6KOdY-.js");
7
- const require_jwt_signing_key = require("../../jwt-signing-key-C2rE3wH6.js");
6
+ const require_dist = require("../../dist-tnAO12ak.js");
7
+ const require_jwt_signing_key = require("../../jwt-signing-key-Dgf8sIXY.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  node_crypto = require_chunk.__toESM(node_crypto);
10
10
  let crypto$1 = require("crypto");
@@ -14,7 +14,7 @@ node_path = require_chunk.__toESM(node_path);
14
14
  let node_fs = require("node:fs");
15
15
  node_fs = require_chunk.__toESM(node_fs);
16
16
  //#region src/auth/parse-record.ts
17
- function parseRecord(kind, schema, data) {
17
+ function parseRecord$1(kind, schema, data) {
18
18
  try {
19
19
  return schema.parse(data);
20
20
  } catch (err) {
@@ -26,64 +26,26 @@ function parseRecord(kind, schema, data) {
26
26
  }
27
27
  //#endregion
28
28
  //#region src/auth/api-key-manager.ts
29
+ /**
30
+ * Legacy API keys — LIST and REVOKE only.
31
+ *
32
+ * No boundary accepts one (the tRPC context has no branch for them) and
33
+ * nothing creates them any more: what is left is the rows a hub may still
34
+ * hold, so an operator can see and delete them. Scoped tokens (`cst_`) are
35
+ * the credential for scripts and integrations.
36
+ */
29
37
  var API_KEYS_COLLECTION$1 = "api_keys";
30
38
  function parseApiKey(data) {
31
- return parseRecord("api-key", require_dist.ApiKeyRecordSchema, data);
39
+ return parseRecord$1("api-key", require_dist.ApiKeyRecordSchema, data);
32
40
  }
33
41
  var ApiKeyManager = class {
34
42
  storageAccess;
35
- auth;
36
- constructor(storageAccess, auth) {
43
+ constructor(storageAccess) {
37
44
  this.storageAccess = storageAccess;
38
- this.auth = auth;
39
45
  }
40
46
  get store() {
41
47
  return this.storageAccess.getStore();
42
48
  }
43
- async create(input) {
44
- const { token: rawToken, hash, prefix } = this.auth.generateApiKey();
45
- const now = Date.now();
46
- const record = {
47
- id: node_crypto.randomUUID(),
48
- label: input.label,
49
- isAdmin: input.isAdmin ?? false,
50
- allowedProviders: input.allowedProviders ?? "*",
51
- allowedDevices: input.allowedDevices ?? {},
52
- tokenHash: hash,
53
- tokenPrefix: prefix,
54
- createdAt: now
55
- };
56
- await this.store.insert.mutate({
57
- collection: API_KEYS_COLLECTION$1,
58
- record: {
59
- id: record.id,
60
- data: { ...record }
61
- }
62
- });
63
- return {
64
- record,
65
- token: rawToken
66
- };
67
- }
68
- async validateToken(token) {
69
- const allKeys = await this.store.query.query({ collection: API_KEYS_COLLECTION$1 });
70
- for (const entry of allKeys) {
71
- const record = parseApiKey(entry.data);
72
- if (this.auth.validateApiKey(token, record.tokenHash)) {
73
- const updatedData = {
74
- ...record,
75
- lastUsedAt: Date.now()
76
- };
77
- await this.store.update.mutate({
78
- collection: API_KEYS_COLLECTION$1,
79
- id: record.id,
80
- data: { ...updatedData }
81
- });
82
- return updatedData;
83
- }
84
- }
85
- return null;
86
- }
87
49
  async listAll() {
88
50
  return (await this.store.query.query({ collection: API_KEYS_COLLECTION$1 })).map((r) => {
89
51
  const { tokenHash: _th, ...rest } = parseApiKey(r.data);
@@ -196,7 +158,7 @@ function genSalt(rounds, seed_length, callback) {
196
158
  * @returns {!Promise} If `callback` has been omitted
197
159
  * @throws {Error} If `callback` is present but not a function
198
160
  */
199
- function hash(password, salt, callback, progressCallback) {
161
+ function hash$1(password, salt, callback, progressCallback) {
200
162
  function _async(callback) {
201
163
  if (typeof password === "string" && typeof salt === "number") genSalt(salt, function(err, salt) {
202
164
  _hash(password, salt, callback, progressCallback);
@@ -249,7 +211,7 @@ function compare(password, hashValue, callback, progressCallback) {
249
211
  nextTick(callback.bind(this, null, false));
250
212
  return;
251
213
  }
252
- hash(password, hashValue.substring(0, 29), function(err, comp) {
214
+ hash$1(password, hashValue.substring(0, 29), function(err, comp) {
253
215
  if (err) callback(err);
254
216
  else callback(null, safeStringCompare(comp, hashValue));
255
217
  }, progressCallback);
@@ -6231,7 +6193,15 @@ var AuthManager = class {
6231
6193
  get jwtSecret() {
6232
6194
  return this.signingKey.current();
6233
6195
  }
6234
- signToken(payload) {
6196
+ /**
6197
+ * @param options.expiresInSec an explicit lifetime (a session's access
6198
+ * token): wins over `auth.tokenExpiry`, bounded to at least one minute.
6199
+ */
6200
+ signToken(payload, options = {}) {
6201
+ if (options.expiresInSec !== void 0) {
6202
+ const seconds = Math.max(60, Math.floor(options.expiresInSec));
6203
+ return import_jsonwebtoken.sign({ ...payload }, this.jwtSecret, { expiresIn: seconds });
6204
+ }
6235
6205
  const configured = this.readConfiguredExpiry();
6236
6206
  const parsed = parseTokenExpiry(configured);
6237
6207
  if (!parsed.ok) {
@@ -6273,42 +6243,14 @@ var AuthManager = class {
6273
6243
  return import_jsonwebtoken.verify(token, this.jwtSecret);
6274
6244
  }
6275
6245
  async hashPassword(password) {
6276
- return hash(password, 10);
6246
+ return hash$1(password, 10);
6277
6247
  }
6278
6248
  async comparePassword(password, hash) {
6279
6249
  return compare(password, hash);
6280
6250
  }
6281
- generateApiKey() {
6282
- const token = node_crypto.randomBytes(32).toString("hex");
6283
- return {
6284
- token,
6285
- hash: node_crypto.createHash("sha256").update(token).digest("hex"),
6286
- prefix: token.slice(0, 8)
6287
- };
6288
- }
6289
- validateApiKey(token, storedHash) {
6290
- return node_crypto.createHash("sha256").update(token).digest("hex") === storedHash;
6291
- }
6292
- /**
6293
- * Create a service token for agent/worker authentication.
6294
- * Used when forking workers or when agents register.
6295
- */
6296
- createServiceToken(opts) {
6297
- const payload = {
6298
- userId: opts.agentId,
6299
- username: opts.agentId,
6300
- isAdmin: true,
6301
- type: "service",
6302
- agentId: opts.agentId,
6303
- allowedProviders: "*",
6304
- allowedDevices: {}
6305
- };
6306
- const expiresIn = opts.expiresIn ?? "24h";
6307
- return import_jsonwebtoken.sign(payload, this.jwtSecret, { expiresIn });
6308
- }
6309
6251
  /**
6310
6252
  * Mint a short-lived HMAC-signed bridge token used by SSO-style auth
6311
- * providers (OIDC, SAML, magic-link, …) to hand the post-callback
6253
+ * providers (OIDC, SAML, …) to hand the post-callback
6312
6254
  * claims to `/api/auth/sso/finish` without trusting unsigned query
6313
6255
  * parameters. The endpoint verifies the token signature with the same
6314
6256
  * `jwtSecret` and only then mints the user session JWT. This closes
@@ -6338,7 +6280,8 @@ var AuthManager = class {
6338
6280
  ...payload.sessionId !== void 0 ? { sessionId: payload.sessionId } : {},
6339
6281
  ...payload.refreshTtl !== void 0 ? { refreshTtl: payload.refreshTtl } : {},
6340
6282
  ...payload.linkIntent !== void 0 ? { linkIntent: payload.linkIntent } : {},
6341
- ...payload.issuer !== void 0 ? { issuer: payload.issuer } : {}
6283
+ ...payload.issuer !== void 0 ? { issuer: payload.issuer } : {},
6284
+ ...payload.browserBinding !== void 0 ? { browserBinding: payload.browserBinding } : {}
6342
6285
  };
6343
6286
  /**
6344
6287
  * `'never'` mints with NO `exp` claim, and the option must be OMITTED to do
@@ -6382,6 +6325,7 @@ var AuthManager = class {
6382
6325
  const sessionId = typeof decoded["sessionId"] === "string" ? decoded["sessionId"] : void 0;
6383
6326
  const linkIntent = typeof decoded["linkIntent"] === "string" ? decoded["linkIntent"] : void 0;
6384
6327
  const issuer = typeof decoded["issuer"] === "string" ? decoded["issuer"] : void 0;
6328
+ const browserBinding = typeof decoded["browserBinding"] === "string" ? decoded["browserBinding"] : void 0;
6385
6329
  const rawRefreshTtl = decoded["refreshTtl"];
6386
6330
  const refreshTtl = rawRefreshTtl === "never" || typeof rawRefreshTtl === "number" && rawRefreshTtl > 0 ? rawRefreshTtl : void 0;
6387
6331
  return {
@@ -6399,7 +6343,8 @@ var AuthManager = class {
6399
6343
  ...sessionId !== void 0 ? { sessionId } : {},
6400
6344
  ...refreshTtl !== void 0 ? { refreshTtl } : {},
6401
6345
  ...linkIntent !== void 0 ? { linkIntent } : {},
6402
- ...issuer !== void 0 ? { issuer } : {}
6346
+ ...issuer !== void 0 ? { issuer } : {},
6347
+ ...browserBinding !== void 0 ? { browserBinding } : {}
6403
6348
  };
6404
6349
  } catch {
6405
6350
  return null;
@@ -6425,10 +6370,43 @@ var AuthManager = class {
6425
6370
  kind: "totp-challenge",
6426
6371
  userId: payload.userId,
6427
6372
  username: payload.username,
6428
- isAdmin: payload.isAdmin
6373
+ isAdmin: payload.isAdmin,
6374
+ ...payload.method !== void 0 ? { method: payload.method } : {}
6429
6375
  }, this.jwtSecret, { expiresIn: ttlSec });
6430
6376
  }
6431
6377
  /**
6378
+ * A short-lived proof that the signed-in user just re-entered their password
6379
+ * or used a passkey. Sensitive self-service actions (linking a sign-in
6380
+ * account, disabling 2FA, removing a passkey, minting an admin token)
6381
+ * require one. `kind: 'reauth'` keeps it from ever being a bearer.
6382
+ */
6383
+ signReauthToken(claims, ttlSec = 300) {
6384
+ return import_jsonwebtoken.sign({
6385
+ kind: "reauth",
6386
+ userId: claims.userId,
6387
+ method: claims.method,
6388
+ ...claims.sessionId !== void 0 ? { sid: claims.sessionId } : {}
6389
+ }, this.jwtSecret, { expiresIn: ttlSec });
6390
+ }
6391
+ /** Decode a re-authentication proof, or `null` (forged, expired, another kind). */
6392
+ verifyReauthToken(token) {
6393
+ try {
6394
+ const decoded = import_jsonwebtoken.verify(token, this.jwtSecret);
6395
+ if (decoded["kind"] !== "reauth") return null;
6396
+ const userId = decoded["userId"];
6397
+ const method = decoded["method"];
6398
+ const sid = decoded["sid"];
6399
+ if (typeof userId !== "string" || method !== "password" && method !== "passkey") return null;
6400
+ return {
6401
+ userId,
6402
+ method,
6403
+ ...typeof sid === "string" ? { sessionId: sid } : {}
6404
+ };
6405
+ } catch {
6406
+ return null;
6407
+ }
6408
+ }
6409
+ /**
6432
6410
  * Verify + decode a TOTP challenge token. Returns `null` for any
6433
6411
  * failure (invalid signature, expired, wrong `kind`, missing
6434
6412
  * claims). The caller MUST also verify the 6-digit code against
@@ -6443,10 +6421,12 @@ var AuthManager = class {
6443
6421
  const username = decoded["username"];
6444
6422
  const isAdmin = decoded["isAdmin"];
6445
6423
  if (typeof userId !== "string" || typeof username !== "string" || typeof isAdmin !== "boolean") return null;
6424
+ const method = decoded["method"];
6446
6425
  return {
6447
6426
  userId,
6448
6427
  username,
6449
- isAdmin
6428
+ isAdmin,
6429
+ ...method === "password" || method === "passkey" || method === "sso" ? { method } : {}
6450
6430
  };
6451
6431
  } catch {
6452
6432
  return null;
@@ -6458,7 +6438,7 @@ var AuthManager = class {
6458
6438
  var TOKENS_COLLECTION = "scoped_tokens";
6459
6439
  var TOKEN_PREFIX = "cst_";
6460
6440
  function parseToken(data) {
6461
- return parseRecord("scoped-token", require_dist.ScopedTokenSchema, data);
6441
+ return parseRecord$1("scoped-token", require_dist.ScopedTokenSchema, data);
6462
6442
  }
6463
6443
  /**
6464
6444
  * Manages scoped API tokens with restricted addon/route/capability access.
@@ -6620,6 +6600,808 @@ var ScopedTokenManager = class {
6620
6600
  }
6621
6601
  };
6622
6602
  //#endregion
6603
+ //#region src/auth/login-session-manager.ts
6604
+ /**
6605
+ * Login sessions — the server-side record behind every session token.
6606
+ *
6607
+ * A session token used to be a self-contained 30-day JWT: nothing on the hub
6608
+ * knew it existed, so logout revoked nothing, a password change left every
6609
+ * other device signed in, and a deleted user's sessions lived until they
6610
+ * expired. Now every login creates a row here and the token carries its id
6611
+ * (`sid`); the hub checks the row on every request (`check`, an in-memory
6612
+ * lookup), so revoking a row ends the session at once.
6613
+ *
6614
+ * Two kinds of client:
6615
+ * - **refreshable** — gets a short access token (default 2 h) and an opaque
6616
+ * refresh token (`csr_…`, stored hashed). `refresh` rotates the refresh
6617
+ * token and slides the session's expiry; presenting a rotated-out refresh
6618
+ * token after a short grace window is treated as theft and revokes the
6619
+ * whole session.
6620
+ * - **legacy** — a client from before refresh tokens: its access token lives
6621
+ * as long as the session, and it renews through the handoff-code pair
6622
+ * (`extend`). Kept for one release so installed viewer apps keep working.
6623
+ *
6624
+ * `legacyCutoff(userId)` covers tokens minted before sessions existed (no
6625
+ * `sid`): a token of that user issued before the cutoff is refused. Revoking
6626
+ * all of a user's sessions moves the cutoff, so a password change or a user
6627
+ * deletion also ends their pre-session tokens.
6628
+ *
6629
+ * Mirror: every row is held in memory (a homelab hub has tens of sessions) and
6630
+ * written through to the store; revocations update the mirror FIRST, so a
6631
+ * store hiccup can delay persistence but never the revocation itself.
6632
+ */
6633
+ /**
6634
+ * @durable class=ledger owner=local-auth
6635
+ * write="one row per login (password, passkey, SSO, handoff, session exchange);
6636
+ * rewritten on every refresh / renewal (expiry slides, refresh hash rotates)
6637
+ * and on revocation"
6638
+ * retention="the daily reap deletes rows expired more than 7 days ago and rows
6639
+ * revoked more than 30 days ago. A session the user keeps refreshing lives as
6640
+ * long as it is used."
6641
+ */
6642
+ var LOGIN_SESSIONS_COLLECTION = "login_sessions";
6643
+ /**
6644
+ * @durable class=config owner=local-auth
6645
+ * write="revoking all of a user's sessions (logout everywhere, password change or
6646
+ * reset, user deletion) sets that user's cutoff to now"
6647
+ * retention="none — one row per user who ever had all sessions revoked; it only
6648
+ * ever matters for tokens minted before sessions existed, and is harmless after."
6649
+ */
6650
+ var LOGIN_SESSION_CUTOFFS_COLLECTION = "login_session_cutoffs";
6651
+ var LOGIN_SESSIONS_COLUMNS = [
6652
+ {
6653
+ name: "id",
6654
+ type: "TEXT",
6655
+ primaryKey: true,
6656
+ notNull: true
6657
+ },
6658
+ {
6659
+ name: "userId",
6660
+ type: "TEXT",
6661
+ notNull: true
6662
+ },
6663
+ {
6664
+ name: "method",
6665
+ type: "TEXT",
6666
+ notNull: true
6667
+ },
6668
+ {
6669
+ name: "refreshable",
6670
+ type: "BOOLEAN",
6671
+ notNull: true
6672
+ },
6673
+ {
6674
+ name: "createdAt",
6675
+ type: "INTEGER",
6676
+ notNull: true
6677
+ },
6678
+ {
6679
+ name: "lastUsedAt",
6680
+ type: "INTEGER",
6681
+ notNull: true
6682
+ },
6683
+ {
6684
+ name: "expiresAt",
6685
+ type: "INTEGER",
6686
+ notNull: true
6687
+ },
6688
+ {
6689
+ name: "revokedAt",
6690
+ type: "INTEGER"
6691
+ },
6692
+ {
6693
+ name: "revokedReason",
6694
+ type: "TEXT"
6695
+ },
6696
+ {
6697
+ name: "refreshHash",
6698
+ type: "TEXT"
6699
+ },
6700
+ {
6701
+ name: "previousRefreshHash",
6702
+ type: "TEXT"
6703
+ },
6704
+ {
6705
+ name: "rotatedAt",
6706
+ type: "INTEGER"
6707
+ },
6708
+ {
6709
+ name: "client",
6710
+ type: "TEXT"
6711
+ },
6712
+ {
6713
+ name: "ip",
6714
+ type: "TEXT"
6715
+ }
6716
+ ];
6717
+ var LOGIN_SESSION_CUTOFFS_COLUMNS = [{
6718
+ name: "userId",
6719
+ type: "TEXT",
6720
+ primaryKey: true,
6721
+ notNull: true
6722
+ }, {
6723
+ name: "notBefore",
6724
+ type: "INTEGER",
6725
+ notNull: true
6726
+ }];
6727
+ var METHODS = [
6728
+ "password",
6729
+ "passkey",
6730
+ "sso",
6731
+ "handoff",
6732
+ "exchange"
6733
+ ];
6734
+ var REFRESH_TOKEN_PREFIX = "csr_";
6735
+ /**
6736
+ * A rotated-out refresh token is still honoured this long: two tabs (or a
6737
+ * retry after a dropped response) may present the same token a moment apart.
6738
+ * After it, the old token is evidence of a copy and ends the session.
6739
+ */
6740
+ var REFRESH_REUSE_GRACE_MS = 3e4;
6741
+ var EXPIRED_RETENTION_MS = 7 * 864e5;
6742
+ var REVOKED_RETENTION_MS = 30 * 864e5;
6743
+ /** Longest client label kept (a User-Agent can be huge). */
6744
+ var MAX_CLIENT_LABEL = 200;
6745
+ function hashToken(token) {
6746
+ return node_crypto.createHash("sha256").update(token).digest("hex");
6747
+ }
6748
+ function str(data, key) {
6749
+ const value = data[key];
6750
+ return typeof value === "string" && value.length > 0 ? value : null;
6751
+ }
6752
+ function num(data, key) {
6753
+ const value = data[key];
6754
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
6755
+ }
6756
+ function parseSession$1(data) {
6757
+ const id = str(data, "id");
6758
+ const userId = str(data, "userId");
6759
+ const createdAt = num(data, "createdAt");
6760
+ const lastUsedAt = num(data, "lastUsedAt");
6761
+ const expiresAt = num(data, "expiresAt");
6762
+ const method = METHODS.find((m) => m === data["method"]);
6763
+ if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
6764
+ return {
6765
+ id,
6766
+ userId,
6767
+ method: method ?? "password",
6768
+ refreshable: data["refreshable"] === true || data["refreshable"] === 1,
6769
+ createdAt,
6770
+ lastUsedAt: lastUsedAt ?? createdAt,
6771
+ expiresAt,
6772
+ revokedAt: num(data, "revokedAt"),
6773
+ revokedReason: str(data, "revokedReason"),
6774
+ refreshHash: str(data, "refreshHash"),
6775
+ previousRefreshHash: str(data, "previousRefreshHash"),
6776
+ rotatedAt: num(data, "rotatedAt"),
6777
+ client: str(data, "client"),
6778
+ ip: str(data, "ip")
6779
+ };
6780
+ }
6781
+ function toLoginSessionSummary(record) {
6782
+ const { refreshHash: _r, previousRefreshHash: _p, rotatedAt: _t, ...summary } = record;
6783
+ return summary;
6784
+ }
6785
+ var LoginSessionManager = class {
6786
+ store;
6787
+ sessions = /* @__PURE__ */ new Map();
6788
+ /** refresh hash (current or previous) → session id */
6789
+ byRefreshHash = /* @__PURE__ */ new Map();
6790
+ cutoffs = /* @__PURE__ */ new Map();
6791
+ now;
6792
+ logger;
6793
+ constructor(store, options = {}) {
6794
+ this.store = store;
6795
+ this.now = options.now ?? Date.now;
6796
+ this.logger = options.logger ?? null;
6797
+ }
6798
+ /** Load every row into the mirror. Call once at init, before serving. */
6799
+ async load() {
6800
+ const rows = await this.store.query.query({ collection: LOGIN_SESSIONS_COLLECTION });
6801
+ for (const row of rows) {
6802
+ const record = parseSession$1(row.data);
6803
+ if (record !== null) this.remember(record);
6804
+ }
6805
+ const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
6806
+ for (const row of cutoffs) {
6807
+ const userId = str(row.data, "userId");
6808
+ const notBefore = num(row.data, "notBefore");
6809
+ if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
6810
+ }
6811
+ }
6812
+ async create(input) {
6813
+ const at = this.now();
6814
+ const refreshToken = input.refreshable ? this.newRefreshToken() : null;
6815
+ const session = {
6816
+ id: node_crypto.randomUUID(),
6817
+ userId: input.userId,
6818
+ method: input.method,
6819
+ refreshable: input.refreshable,
6820
+ createdAt: at,
6821
+ lastUsedAt: at,
6822
+ expiresAt: at + input.lifetimeMs,
6823
+ revokedAt: null,
6824
+ revokedReason: null,
6825
+ refreshHash: refreshToken === null ? null : hashToken(refreshToken),
6826
+ previousRefreshHash: null,
6827
+ rotatedAt: null,
6828
+ client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
6829
+ ip: input.ip ?? null
6830
+ };
6831
+ await this.store.insert.mutate({
6832
+ collection: LOGIN_SESSIONS_COLLECTION,
6833
+ record: {
6834
+ id: session.id,
6835
+ data: { ...session }
6836
+ }
6837
+ });
6838
+ this.remember(session);
6839
+ return {
6840
+ session,
6841
+ refreshToken
6842
+ };
6843
+ }
6844
+ /** Is `sessionId` a live session right now? Synchronous: the hub asks on every request. */
6845
+ check(sessionId) {
6846
+ const session = this.sessions.get(sessionId);
6847
+ if (session === void 0) return { state: "unknown" };
6848
+ if (session.revokedAt !== null) return { state: "revoked" };
6849
+ if (session.expiresAt <= this.now()) return { state: "expired" };
6850
+ return {
6851
+ state: "active",
6852
+ session
6853
+ };
6854
+ }
6855
+ /** Epoch ms before which a pre-session (no `sid`) token of `userId` is refused; 0 = none. */
6856
+ legacyCutoff(userId) {
6857
+ return this.cutoffs.get(userId) ?? 0;
6858
+ }
6859
+ /** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
6860
+ async refresh(refreshToken, lifetimeMs) {
6861
+ if (!refreshToken.startsWith("csr_")) return {
6862
+ ok: false,
6863
+ reason: "unknown"
6864
+ };
6865
+ const presented = hashToken(refreshToken);
6866
+ const sessionId = this.byRefreshHash.get(presented);
6867
+ const session = sessionId === void 0 ? void 0 : this.sessions.get(sessionId);
6868
+ if (session === void 0) return {
6869
+ ok: false,
6870
+ reason: "unknown"
6871
+ };
6872
+ if (session.revokedAt !== null) return {
6873
+ ok: false,
6874
+ reason: "revoked"
6875
+ };
6876
+ const at = this.now();
6877
+ if (session.expiresAt <= at) return {
6878
+ ok: false,
6879
+ reason: "expired"
6880
+ };
6881
+ if (presented !== session.refreshHash) {
6882
+ if (!(presented === session.previousRefreshHash && session.rotatedAt !== null && at - session.rotatedAt <= REFRESH_REUSE_GRACE_MS)) {
6883
+ await this.revoke(session.id, "refresh-reuse");
6884
+ this.logger?.warn("A rotated-out refresh token was presented — session revoked", { meta: {
6885
+ sessionId: session.id,
6886
+ userId: session.userId
6887
+ } });
6888
+ return {
6889
+ ok: false,
6890
+ reason: "reused"
6891
+ };
6892
+ }
6893
+ }
6894
+ const next = this.newRefreshToken();
6895
+ const updated = {
6896
+ ...session,
6897
+ lastUsedAt: at,
6898
+ expiresAt: at + lifetimeMs,
6899
+ previousRefreshHash: session.refreshHash,
6900
+ refreshHash: hashToken(next),
6901
+ rotatedAt: at
6902
+ };
6903
+ this.replace(session, updated);
6904
+ await this.persist(updated);
6905
+ return {
6906
+ ok: true,
6907
+ session: updated,
6908
+ refreshToken: next
6909
+ };
6910
+ }
6911
+ /** Renew an active session (legacy client): slide its expiry. `null` when it is not active. */
6912
+ async extend(sessionId, lifetimeMs) {
6913
+ const check = this.check(sessionId);
6914
+ if (check.state !== "active") return null;
6915
+ const at = this.now();
6916
+ const updated = {
6917
+ ...check.session,
6918
+ lastUsedAt: at,
6919
+ expiresAt: at + lifetimeMs
6920
+ };
6921
+ this.replace(check.session, updated);
6922
+ await this.persist(updated);
6923
+ return updated;
6924
+ }
6925
+ /** End one session. `false` when it was unknown or already ended. */
6926
+ async revoke(sessionId, reason) {
6927
+ const session = this.sessions.get(sessionId);
6928
+ if (session === void 0 || session.revokedAt !== null) return false;
6929
+ const updated = {
6930
+ ...session,
6931
+ revokedAt: this.now(),
6932
+ revokedReason: reason
6933
+ };
6934
+ this.replace(session, updated);
6935
+ await this.persist(updated);
6936
+ return true;
6937
+ }
6938
+ /**
6939
+ * End every live session of `userId` except `exceptSessionId`, and move the
6940
+ * user's legacy cutoff to now (pre-session tokens die too). Returns how many
6941
+ * sessions ended.
6942
+ */
6943
+ async revokeAllForUser(userId, options) {
6944
+ const at = this.now();
6945
+ this.cutoffs.set(userId, at);
6946
+ const ended = [];
6947
+ for (const session of this.sessions.values()) {
6948
+ if (session.userId !== userId || session.revokedAt !== null) continue;
6949
+ if (session.id === options.exceptSessionId) continue;
6950
+ const updated = {
6951
+ ...session,
6952
+ revokedAt: at,
6953
+ revokedReason: options.reason
6954
+ };
6955
+ this.replace(session, updated);
6956
+ ended.push(updated);
6957
+ }
6958
+ await this.persistCutoff(userId, at);
6959
+ for (const session of ended) await this.persist(session);
6960
+ return ended.length;
6961
+ }
6962
+ /** `userId`'s live sessions, newest first. */
6963
+ listForUser(userId) {
6964
+ return this.listActive().filter((s) => s.userId === userId);
6965
+ }
6966
+ /** Every live session, newest first. */
6967
+ listActive() {
6968
+ const at = this.now();
6969
+ return [...this.sessions.values()].filter((s) => s.revokedAt === null && s.expiresAt > at).toSorted((a, b) => b.createdAt - a.createdAt).map(toLoginSessionSummary);
6970
+ }
6971
+ /** Delete rows expired more than 7 days ago or revoked more than 30. Returns how many. */
6972
+ async reap() {
6973
+ const at = this.now();
6974
+ const dead = [...this.sessions.values()].filter((s) => s.revokedAt !== null && at - s.revokedAt > REVOKED_RETENTION_MS || s.revokedAt === null && at - s.expiresAt > EXPIRED_RETENTION_MS);
6975
+ for (const session of dead) {
6976
+ this.forget(session);
6977
+ await this.store.delete.mutate({
6978
+ collection: LOGIN_SESSIONS_COLLECTION,
6979
+ key: session.id
6980
+ });
6981
+ }
6982
+ return dead.length;
6983
+ }
6984
+ newRefreshToken() {
6985
+ return `${REFRESH_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
6986
+ }
6987
+ remember(session) {
6988
+ this.sessions.set(session.id, session);
6989
+ if (session.refreshHash !== null) this.byRefreshHash.set(session.refreshHash, session.id);
6990
+ if (session.previousRefreshHash !== null) this.byRefreshHash.set(session.previousRefreshHash, session.id);
6991
+ }
6992
+ forget(session) {
6993
+ this.sessions.delete(session.id);
6994
+ if (session.refreshHash !== null) this.byRefreshHash.delete(session.refreshHash);
6995
+ if (session.previousRefreshHash !== null) this.byRefreshHash.delete(session.previousRefreshHash);
6996
+ }
6997
+ replace(previous, next) {
6998
+ this.forget(previous);
6999
+ this.remember(next);
7000
+ }
7001
+ async persist(session) {
7002
+ try {
7003
+ await this.store.update.mutate({
7004
+ collection: LOGIN_SESSIONS_COLLECTION,
7005
+ id: session.id,
7006
+ data: { ...session }
7007
+ });
7008
+ } catch (error) {
7009
+ this.logger?.error("Login session write failed — the change holds until restart", { meta: {
7010
+ sessionId: session.id,
7011
+ error: error instanceof Error ? error.message : String(error)
7012
+ } });
7013
+ }
7014
+ }
7015
+ async persistCutoff(userId, notBefore) {
7016
+ try {
7017
+ await this.store.delete.mutate({
7018
+ collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
7019
+ key: userId
7020
+ });
7021
+ await this.store.insert.mutate({
7022
+ collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
7023
+ record: {
7024
+ id: userId,
7025
+ data: {
7026
+ userId,
7027
+ notBefore
7028
+ }
7029
+ }
7030
+ });
7031
+ } catch (error) {
7032
+ this.logger?.error("Login session cutoff write failed — it holds until restart", { meta: {
7033
+ userId,
7034
+ error: error instanceof Error ? error.message : String(error)
7035
+ } });
7036
+ }
7037
+ }
7038
+ };
7039
+ //#endregion
7040
+ //#region src/auth/admin-token-manager.ts
7041
+ /**
7042
+ * Admin tokens (`csa_…`) — the one credential an ops script can hold that
7043
+ * acts as an administrator, minted deliberately and kept on a short leash.
7044
+ *
7045
+ * Scoped tokens (`cst_`) are never admin, and a script that needs admin (the
7046
+ * keychain migration, a release's `applyServerUpdate`) otherwise needs a human
7047
+ * login. This is that credential, with the rules the operator set:
7048
+ *
7049
+ * - created only by an admin, from the UI, after re-authentication (the hub
7050
+ * router enforces both);
7051
+ * - expiry MANDATORY, at most 90 days;
7052
+ * - shown once — only a SHA-256 hash is stored, like `cst_`;
7053
+ * - every use audit-logged (the hub, per request);
7054
+ * - revocable, and dead the moment its creator is deleted or loses admin;
7055
+ * - NOT a session: an opaque token, so it survives a signing-key change.
7056
+ */
7057
+ /**
7058
+ * @durable class=config owner=local-auth
7059
+ * write="an admin creates a token (after re-authentication); `lastUsedAt` is
7060
+ * rewritten at most once a minute while it is used; `revoke` stamps revokedAt"
7061
+ * retention="the daily reap deletes rows expired or revoked more than 30 days ago"
7062
+ */
7063
+ var ADMIN_TOKENS_COLLECTION = "admin_tokens";
7064
+ var ADMIN_TOKENS_COLUMNS = [
7065
+ {
7066
+ name: "id",
7067
+ type: "TEXT",
7068
+ primaryKey: true,
7069
+ notNull: true
7070
+ },
7071
+ {
7072
+ name: "createdBy",
7073
+ type: "TEXT",
7074
+ notNull: true
7075
+ },
7076
+ {
7077
+ name: "name",
7078
+ type: "TEXT",
7079
+ notNull: true
7080
+ },
7081
+ {
7082
+ name: "tokenHash",
7083
+ type: "TEXT",
7084
+ notNull: true,
7085
+ unique: true
7086
+ },
7087
+ {
7088
+ name: "tokenPrefix",
7089
+ type: "TEXT",
7090
+ notNull: true
7091
+ },
7092
+ {
7093
+ name: "createdAt",
7094
+ type: "INTEGER",
7095
+ notNull: true
7096
+ },
7097
+ {
7098
+ name: "expiresAt",
7099
+ type: "INTEGER",
7100
+ notNull: true
7101
+ },
7102
+ {
7103
+ name: "lastUsedAt",
7104
+ type: "INTEGER"
7105
+ },
7106
+ {
7107
+ name: "revokedAt",
7108
+ type: "INTEGER"
7109
+ }
7110
+ ];
7111
+ var ADMIN_TOKEN_PREFIX = "csa_";
7112
+ var ADMIN_TOKEN_MAX_LIFETIME_MS = 90 * 864e5;
7113
+ var DEAD_RETENTION_MS = 30 * 864e5;
7114
+ var TOUCH_EVERY_MS = 6e4;
7115
+ function hash(token) {
7116
+ return node_crypto.createHash("sha256").update(token).digest("hex");
7117
+ }
7118
+ function parse(data) {
7119
+ const { id, createdBy, name, tokenHash, tokenPrefix, createdAt, expiresAt } = data;
7120
+ if (typeof id !== "string" || typeof createdBy !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || typeof tokenPrefix !== "string" || typeof createdAt !== "number" || typeof expiresAt !== "number") return null;
7121
+ return {
7122
+ id,
7123
+ createdBy,
7124
+ name,
7125
+ tokenHash,
7126
+ tokenPrefix,
7127
+ createdAt,
7128
+ expiresAt,
7129
+ lastUsedAt: typeof data["lastUsedAt"] === "number" ? data["lastUsedAt"] : null,
7130
+ revokedAt: typeof data["revokedAt"] === "number" ? data["revokedAt"] : null
7131
+ };
7132
+ }
7133
+ function summary(stored) {
7134
+ const { tokenHash: _hash, ...record } = stored;
7135
+ return record;
7136
+ }
7137
+ var AdminTokenManager = class {
7138
+ store;
7139
+ now;
7140
+ constructor(store, now = Date.now) {
7141
+ this.store = store;
7142
+ this.now = now;
7143
+ }
7144
+ /** Mint a token for `createdBy`. Refuses an expiry in the past or past 90 days. */
7145
+ async create(input) {
7146
+ const at = this.now();
7147
+ if (!(input.expiresAt > at) || input.expiresAt > at + 7776e6) throw new Error("An admin token must expire within 90 days");
7148
+ const name = input.name.trim().slice(0, 80);
7149
+ if (name.length === 0) throw new Error("An admin token needs a name");
7150
+ const token = `${ADMIN_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
7151
+ const stored = {
7152
+ id: node_crypto.randomUUID(),
7153
+ createdBy: input.createdBy,
7154
+ name,
7155
+ tokenHash: hash(token),
7156
+ tokenPrefix: token.slice(0, 12),
7157
+ createdAt: at,
7158
+ expiresAt: input.expiresAt,
7159
+ lastUsedAt: null,
7160
+ revokedAt: null
7161
+ };
7162
+ await this.store.insert.mutate({
7163
+ collection: ADMIN_TOKENS_COLLECTION,
7164
+ record: {
7165
+ id: stored.id,
7166
+ data: { ...stored }
7167
+ }
7168
+ });
7169
+ return {
7170
+ token,
7171
+ record: summary(stored)
7172
+ };
7173
+ }
7174
+ /** The live record behind `token`, or `null` (unknown, revoked, expired, not a `csa_`). */
7175
+ async validate(token) {
7176
+ if (!token.startsWith("csa_")) return null;
7177
+ const first = (await this.store.query.query({
7178
+ collection: ADMIN_TOKENS_COLLECTION,
7179
+ filter: { where: { tokenHash: hash(token) } }
7180
+ }))[0];
7181
+ const stored = first === void 0 ? null : parse(first.data);
7182
+ if (stored === null || stored.revokedAt !== null) return null;
7183
+ const at = this.now();
7184
+ if (stored.expiresAt <= at) return null;
7185
+ if (stored.lastUsedAt === null || at - stored.lastUsedAt > TOUCH_EVERY_MS) this.store.update.mutate({
7186
+ collection: ADMIN_TOKENS_COLLECTION,
7187
+ id: stored.id,
7188
+ data: {
7189
+ ...stored,
7190
+ lastUsedAt: at
7191
+ }
7192
+ }).catch(() => {});
7193
+ return summary(stored);
7194
+ }
7195
+ /** Every token not yet reaped, newest first (no hashes). */
7196
+ async list() {
7197
+ return (await this.store.query.query({ collection: ADMIN_TOKENS_COLLECTION })).map((row) => parse(row.data)).filter((r) => r !== null).map(summary).toSorted((a, b) => b.createdAt - a.createdAt);
7198
+ }
7199
+ /** Revoke one token. `false` when unknown or already revoked. */
7200
+ async revoke(id) {
7201
+ const first = (await this.store.query.query({
7202
+ collection: ADMIN_TOKENS_COLLECTION,
7203
+ filter: { where: { id } }
7204
+ }))[0];
7205
+ const stored = first === void 0 ? null : parse(first.data);
7206
+ if (stored === null || stored.revokedAt !== null) return false;
7207
+ await this.store.update.mutate({
7208
+ collection: ADMIN_TOKENS_COLLECTION,
7209
+ id,
7210
+ data: {
7211
+ ...stored,
7212
+ revokedAt: this.now()
7213
+ }
7214
+ });
7215
+ return true;
7216
+ }
7217
+ /** Revoke every live token `userId` created (the user was deleted). */
7218
+ async revokeAllCreatedBy(userId) {
7219
+ let revoked = 0;
7220
+ for (const token of await this.list()) if (token.createdBy === userId && token.revokedAt === null) {
7221
+ if (await this.revoke(token.id)) revoked++;
7222
+ }
7223
+ return revoked;
7224
+ }
7225
+ /** Delete rows expired or revoked more than 30 days ago. */
7226
+ async reap() {
7227
+ const horizon = this.now() - DEAD_RETENTION_MS;
7228
+ let deleted = 0;
7229
+ for (const token of await this.list()) if ((token.revokedAt ?? token.expiresAt) < horizon) {
7230
+ await this.store.delete.mutate({
7231
+ collection: ADMIN_TOKENS_COLLECTION,
7232
+ key: token.id
7233
+ });
7234
+ deleted++;
7235
+ }
7236
+ return deleted;
7237
+ }
7238
+ };
7239
+ //#endregion
7240
+ //#region src/auth/auth-audit-log.ts
7241
+ /**
7242
+ * The authentication audit trail: what an operator must be able to answer
7243
+ * after the fact — "who skipped 2FA from the LAN, and from where?", "which
7244
+ * admin token ran this, and when?".
7245
+ *
7246
+ * One row per event, kept 90 days. Writing never throws: an audit row that
7247
+ * cannot be stored is logged loudly instead, and never blocks the action
7248
+ * (a login must not fail because the audit table is busy).
7249
+ */
7250
+ /**
7251
+ * @durable class=ledger owner=local-auth
7252
+ * write="one row per audited auth event: a second factor skipped from a trusted
7253
+ * network, an admin token created / used / revoked"
7254
+ * retention="the daily reap deletes rows older than 90 days"
7255
+ */
7256
+ var AUTH_AUDIT_COLLECTION = "auth_audit";
7257
+ var AUTH_AUDIT_COLUMNS = [
7258
+ {
7259
+ name: "id",
7260
+ type: "TEXT",
7261
+ primaryKey: true,
7262
+ notNull: true
7263
+ },
7264
+ {
7265
+ name: "at",
7266
+ type: "INTEGER",
7267
+ notNull: true
7268
+ },
7269
+ {
7270
+ name: "action",
7271
+ type: "TEXT",
7272
+ notNull: true
7273
+ },
7274
+ {
7275
+ name: "userId",
7276
+ type: "TEXT"
7277
+ },
7278
+ {
7279
+ name: "ip",
7280
+ type: "TEXT"
7281
+ },
7282
+ {
7283
+ name: "detail",
7284
+ type: "TEXT"
7285
+ }
7286
+ ];
7287
+ var AUTH_AUDIT_ACTIONS = [
7288
+ "second-factor-skipped",
7289
+ "admin-token-created",
7290
+ "admin-token-used",
7291
+ "admin-token-revoked"
7292
+ ];
7293
+ var RETENTION_MS = 90 * 864e5;
7294
+ var MAX_LIST = 500;
7295
+ function parseRecord(data) {
7296
+ const id = data["id"];
7297
+ const at = data["at"];
7298
+ const action = data["action"];
7299
+ const known = AUTH_AUDIT_ACTIONS.find((a) => a === action);
7300
+ if (typeof id !== "string" || typeof at !== "number" || known === void 0) return null;
7301
+ let detail;
7302
+ if (typeof data["detail"] === "string") try {
7303
+ const parsed = JSON.parse(data["detail"]);
7304
+ if (parsed !== null && typeof parsed === "object") detail = Object.fromEntries(Object.entries(parsed).filter((e) => typeof e[1] === "string" || typeof e[1] === "number" || typeof e[1] === "boolean"));
7305
+ } catch {
7306
+ detail = void 0;
7307
+ }
7308
+ return {
7309
+ id,
7310
+ at,
7311
+ action: known,
7312
+ ...typeof data["userId"] === "string" ? { userId: data["userId"] } : {},
7313
+ ...typeof data["ip"] === "string" ? { ip: data["ip"] } : {},
7314
+ ...detail !== void 0 ? { detail } : {}
7315
+ };
7316
+ }
7317
+ var AuthAuditLog = class {
7318
+ store;
7319
+ logger;
7320
+ now;
7321
+ constructor(store, logger = null, now = Date.now) {
7322
+ this.store = store;
7323
+ this.logger = logger;
7324
+ this.now = now;
7325
+ }
7326
+ /** Record `event`. Never throws. */
7327
+ async record(event) {
7328
+ const row = {
7329
+ id: node_crypto.randomUUID(),
7330
+ at: this.now(),
7331
+ action: event.action,
7332
+ userId: event.userId ?? null,
7333
+ ip: event.ip ?? null,
7334
+ detail: event.detail === void 0 ? null : JSON.stringify(event.detail)
7335
+ };
7336
+ this.logger?.info(`auth audit: ${event.action}`, { meta: {
7337
+ userId: event.userId,
7338
+ ip: event.ip,
7339
+ ...event.detail
7340
+ } });
7341
+ try {
7342
+ await this.store.insert.mutate({
7343
+ collection: AUTH_AUDIT_COLLECTION,
7344
+ record: {
7345
+ id: row.id,
7346
+ data: row
7347
+ }
7348
+ });
7349
+ } catch (error) {
7350
+ this.logger?.error("auth audit row not written", { meta: {
7351
+ action: event.action,
7352
+ error: error instanceof Error ? error.message : String(error)
7353
+ } });
7354
+ }
7355
+ }
7356
+ /** Newest first, at most `limit` (≤ 500), optionally one user's. */
7357
+ async list(input = {}) {
7358
+ const rows = await this.store.query.query({
7359
+ collection: AUTH_AUDIT_COLLECTION,
7360
+ ...input.userId !== void 0 ? { filter: { where: { userId: input.userId } } } : {}
7361
+ });
7362
+ const limit = Math.min(MAX_LIST, Math.max(1, input.limit ?? 100));
7363
+ return rows.map((row) => parseRecord(row.data)).filter((r) => r !== null).toSorted((a, b) => b.at - a.at).slice(0, limit);
7364
+ }
7365
+ /** Delete rows older than 90 days. Returns how many. */
7366
+ async reap() {
7367
+ const horizon = this.now() - RETENTION_MS;
7368
+ const rows = await this.store.query.query({ collection: AUTH_AUDIT_COLLECTION });
7369
+ let deleted = 0;
7370
+ for (const row of rows) {
7371
+ const at = row.data["at"];
7372
+ if (typeof at === "number" && at < horizon) {
7373
+ await this.store.delete.mutate({
7374
+ collection: AUTH_AUDIT_COLLECTION,
7375
+ key: String(row.id)
7376
+ });
7377
+ deleted++;
7378
+ }
7379
+ }
7380
+ return deleted;
7381
+ }
7382
+ };
7383
+ //#endregion
7384
+ //#region src/auth/user-management-internal.ts
7385
+ /** Process-global, so every bundled copy of this module agrees on the key. */
7386
+ var USER_MANAGEMENT_INTERNAL = Symbol.for("camstack.user-management.internal");
7387
+ /** Attach `internal` to `provider` (returns the same object, now carrying it). */
7388
+ function attachUserManagementInternal(provider, internal) {
7389
+ Object.defineProperty(provider, USER_MANAGEMENT_INTERNAL, {
7390
+ value: internal,
7391
+ enumerable: false,
7392
+ writable: false,
7393
+ configurable: false
7394
+ });
7395
+ return provider;
7396
+ }
7397
+ /** The hub-internal surface of a `user-management` provider, or `null` when it carries none. */
7398
+ function getUserManagementInternal(provider) {
7399
+ if (provider === null || typeof provider !== "object") return null;
7400
+ const internal = Reflect.get(provider, USER_MANAGEMENT_INTERNAL);
7401
+ if (internal === null || typeof internal !== "object") return null;
7402
+ return internal;
7403
+ }
7404
+ //#endregion
6623
7405
  //#region src/auth/user-manager.ts
6624
7406
  var USERS_COLLECTION$1 = "users";
6625
7407
  /**
@@ -6631,7 +7413,7 @@ var USERS_COLLECTION$1 = "users";
6631
7413
  var FIRST_BOOT_ADMIN_USERNAME = "admin";
6632
7414
  var FIRST_BOOT_ADMIN_PASSWORD = "changeme";
6633
7415
  function parseUser(data) {
6634
- return parseRecord("user", require_dist.UserRecordSchema, data);
7416
+ return parseRecord$1("user", require_dist.UserRecordSchema, data);
6635
7417
  }
6636
7418
  var UserManager = class {
6637
7419
  storageAccess;
@@ -7205,6 +7987,34 @@ async function declareAuthSchema(store) {
7205
7987
  columns: ["userId"]
7206
7988
  }]
7207
7989
  });
7990
+ await store.declareCollection.mutate({
7991
+ collection: LOGIN_SESSIONS_COLLECTION,
7992
+ columns: LOGIN_SESSIONS_COLUMNS,
7993
+ indexes: [{
7994
+ name: "idx_login_sessions_user_id",
7995
+ columns: ["userId"]
7996
+ }]
7997
+ });
7998
+ await store.declareCollection.mutate({
7999
+ collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
8000
+ columns: LOGIN_SESSION_CUTOFFS_COLUMNS
8001
+ });
8002
+ await store.declareCollection.mutate({
8003
+ collection: ADMIN_TOKENS_COLLECTION,
8004
+ columns: ADMIN_TOKENS_COLUMNS,
8005
+ indexes: [{
8006
+ name: "idx_admin_tokens_created_by",
8007
+ columns: ["createdBy"]
8008
+ }]
8009
+ });
8010
+ await store.declareCollection.mutate({
8011
+ collection: AUTH_AUDIT_COLLECTION,
8012
+ columns: AUTH_AUDIT_COLUMNS,
8013
+ indexes: [{
8014
+ name: "idx_auth_audit_user_id",
8015
+ columns: ["userId"]
8016
+ }]
8017
+ });
7208
8018
  }
7209
8019
  //#endregion
7210
8020
  //#region src/auth/external-identity-manager.ts
@@ -10078,6 +10888,9 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10078
10888
  totpManager = null;
10079
10889
  externalIdentities = null;
10080
10890
  signingKeyKeeper = null;
10891
+ loginSessions = null;
10892
+ adminTokens = null;
10893
+ auditLog = null;
10081
10894
  constructor() {
10082
10895
  super({});
10083
10896
  }
@@ -10098,11 +10911,15 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10098
10911
  return store;
10099
10912
  } };
10100
10913
  this.userManager = new UserManager(storageAccess, this.authManager);
10101
- this.apiKeyManager = new ApiKeyManager(storageAccess, this.authManager);
10914
+ this.apiKeyManager = new ApiKeyManager(storageAccess);
10102
10915
  this.scopedTokenManager = new ScopedTokenManager(store);
10103
10916
  this.oauthSessionManager = new OauthSessionManager(store);
10104
10917
  this.totpManager = new TotpManager(store);
10105
10918
  this.externalIdentities = new ExternalIdentityManager(store);
10919
+ this.loginSessions = new LoginSessionManager(store, { logger: this.ctx.logger });
10920
+ await this.loginSessions.load();
10921
+ this.adminTokens = new AdminTokenManager(store);
10922
+ this.auditLog = new AuthAuditLog(store, this.ctx.logger);
10106
10923
  try {
10107
10924
  await reconcileAdminAccountAtBoot({
10108
10925
  users: this.userManager,
@@ -10195,12 +11012,13 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10195
11012
  deleteUser: async (input) => {
10196
11013
  if (!this.userManager) throw new Error("User management not available");
10197
11014
  await this.userManager.delete(input.id);
10198
- await this.externalIdentities?.removeAllForUser(input.id);
11015
+ await this.endAllAccess(input.id);
10199
11016
  return { success: true };
10200
11017
  },
10201
11018
  resetPassword: async (input) => {
10202
11019
  if (!this.userManager) throw new Error("User management not available");
10203
11020
  await this.userManager.resetPassword(input.id, input.newPassword);
11021
+ await this.loginSessions?.revokeAllForUser(input.id, { reason: "password-reset" });
10204
11022
  return { success: true };
10205
11023
  },
10206
11024
  setUserScopes: async (input) => {
@@ -10222,27 +11040,11 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10222
11040
  if (!this.apiKeyManager) return [];
10223
11041
  return this.apiKeyManager.listAll();
10224
11042
  },
10225
- createApiKey: async (input) => {
10226
- if (!this.apiKeyManager) throw new Error("API key management not available");
10227
- const { record, token } = await this.apiKeyManager.create(input);
10228
- const { tokenHash: _, ...summary } = record;
10229
- return {
10230
- token,
10231
- record: summary
10232
- };
10233
- },
10234
11043
  revokeApiKey: async (input) => {
10235
11044
  if (!this.apiKeyManager) throw new Error("API key management not available");
10236
11045
  await this.apiKeyManager.revoke(input.id);
10237
11046
  return { success: true };
10238
11047
  },
10239
- validateApiKey: async (input) => {
10240
- if (!this.apiKeyManager) return null;
10241
- const record = await this.apiKeyManager.validateToken(input.token);
10242
- if (!record) return null;
10243
- const { tokenHash: _, ...summary } = record;
10244
- return summary;
10245
- },
10246
11048
  createScopedToken: async (input) => {
10247
11049
  if (!this.scopedTokenManager) throw new Error("Scoped token management not available");
10248
11050
  const { token, record } = await this.scopedTokenManager.create(input.userId, input.name, input.scopes, input.expiresAt);
@@ -10257,8 +11059,10 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10257
11059
  return { success: true };
10258
11060
  },
10259
11061
  validateScopedToken: async (input) => {
10260
- if (!this.scopedTokenManager) return null;
10261
- return this.scopedTokenManager.validate(input.token);
11062
+ if (!this.scopedTokenManager || !this.userManager) return null;
11063
+ const record = await this.scopedTokenManager.validate(input.token);
11064
+ if (record === null) return null;
11065
+ return await this.userManager.findById(record.userId) === null ? null : record;
10262
11066
  },
10263
11067
  listScopedTokens: async (input) => {
10264
11068
  if (!this.scopedTokenManager) return [];
@@ -10301,12 +11105,6 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10301
11105
  issuer: link.issuer
10302
11106
  };
10303
11107
  },
10304
- linkExternalIdentity: async (input) => {
10305
- if (!this.externalIdentities || !this.userManager) throw new Error("External sign-in linking not available");
10306
- if (!await this.userManager.findById(input.userId)) throw new Error(`User not found: ${input.userId}`);
10307
- await this.externalIdentities.link(input);
10308
- return { success: true };
10309
- },
10310
11108
  unlinkExternalIdentity: async (input) => {
10311
11109
  if (!this.externalIdentities) return { success: false };
10312
11110
  return { success: await this.externalIdentities.unlink(input) };
@@ -10344,6 +11142,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10344
11142
  }
10345
11143
  };
10346
11144
  this.ctx.logger.info("registered auth-provider + user-management capabilities");
11145
+ attachUserManagementInternal(userMgmt, this.buildInternal());
10347
11146
  return [{
10348
11147
  capability: require_dist.authProviderCapability,
10349
11148
  provider: authProvider
@@ -10353,6 +11152,93 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10353
11152
  }];
10354
11153
  }
10355
11154
  /**
11155
+ * The hub-internal surface (`user-management-internal.ts`): operations no
11156
+ * wire may reach. Called only by hub code holding this provider object.
11157
+ */
11158
+ buildInternal() {
11159
+ const sessions = () => {
11160
+ if (this.loginSessions === null) throw new Error("Login sessions not available");
11161
+ return this.loginSessions;
11162
+ };
11163
+ return {
11164
+ sessions: {
11165
+ create: (input) => sessions().create(input),
11166
+ refresh: (token, lifetimeMs) => sessions().refresh(token, lifetimeMs),
11167
+ extend: (id, lifetimeMs) => sessions().extend(id, lifetimeMs),
11168
+ check: (id) => sessions().check(id),
11169
+ legacyCutoff: (userId) => sessions().legacyCutoff(userId),
11170
+ revoke: (id, reason) => sessions().revoke(id, reason),
11171
+ revokeAllForUser: (userId, options) => sessions().revokeAllForUser(userId, options),
11172
+ listForUser: (userId) => sessions().listForUser(userId),
11173
+ listActive: () => sessions().listActive()
11174
+ },
11175
+ adminTokens: {
11176
+ create: async (input) => {
11177
+ if (this.adminTokens === null) throw new Error("Admin tokens not available");
11178
+ return this.adminTokens.create(input);
11179
+ },
11180
+ validate: async (token) => {
11181
+ if (this.adminTokens === null || this.userManager === null) return null;
11182
+ const record = await this.adminTokens.validate(token);
11183
+ if (record === null) return null;
11184
+ const creator = await this.userManager.findById(record.createdBy);
11185
+ return creator !== null && creator.isAdmin ? record : null;
11186
+ },
11187
+ list: async () => this.adminTokens === null ? [] : this.adminTokens.list(),
11188
+ revoke: async (id) => this.adminTokens === null ? false : this.adminTokens.revoke(id)
11189
+ },
11190
+ audit: {
11191
+ record: async (event) => {
11192
+ await this.auditLog?.record(event);
11193
+ },
11194
+ list: async (input) => this.auditLog === null ? [] : this.auditLog.list(input)
11195
+ },
11196
+ setPassword: async (input) => {
11197
+ if (!this.userManager) throw new Error("User management not available");
11198
+ await this.userManager.resetPassword(input.userId, input.newPassword);
11199
+ await sessions().revokeAllForUser(input.userId, {
11200
+ ...input.keepSessionId !== void 0 ? { exceptSessionId: input.keepSessionId } : {},
11201
+ reason: "password-changed"
11202
+ });
11203
+ },
11204
+ linkExternalIdentity: async (input) => {
11205
+ if (!this.externalIdentities || !this.userManager) throw new Error("External sign-in linking not available");
11206
+ if (!await this.userManager.findById(input.userId)) throw new Error(`User not found: ${input.userId}`);
11207
+ await this.externalIdentities.link(input);
11208
+ return { success: true };
11209
+ }
11210
+ };
11211
+ }
11212
+ /**
11213
+ * End every way `userId` could still get in: login sessions (and the
11214
+ * pre-session tokens, via the cutoff), scoped tokens, OAuth account links,
11215
+ * the TOTP row and the external sign-in links. Each step is attempted even
11216
+ * if an earlier one fails, and a failure is logged — a half-cleaned user is
11217
+ * worse than a loud one.
11218
+ */
11219
+ async endAllAccess(userId) {
11220
+ const steps = [
11221
+ ["sessions", async () => this.loginSessions?.revokeAllForUser(userId, { reason: "user-deleted" })],
11222
+ ["scoped tokens", async () => {
11223
+ for (const token of await this.scopedTokenManager?.listForUser(userId) ?? []) await this.scopedTokenManager?.revoke(token.id);
11224
+ }],
11225
+ ["oauth sessions", async () => {
11226
+ for (const session of await this.oauthSessionManager?.list() ?? []) if (session.userId === userId && session.revokedAt === null) await this.oauthSessionManager?.markRevoked(session.id);
11227
+ }],
11228
+ ["admin tokens", async () => this.adminTokens?.revokeAllCreatedBy(userId)],
11229
+ ["totp", async () => this.totpManager?.disable(userId)],
11230
+ ["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)]
11231
+ ];
11232
+ for (const [what, step] of steps) try {
11233
+ await step();
11234
+ } catch (err) {
11235
+ this.ctx.logger.error(`deleting a user: could not remove its ${what}`, { meta: {
11236
+ userId,
11237
+ error: err instanceof Error ? err.message : String(err)
11238
+ } });
11239
+ }
11240
+ }
11241
+ /**
10356
11242
  * Drop auth rows that are dead but were never removed: scoped tokens past
10357
11243
  * their expiry, and OAuth sessions revoked longer ago than
10358
11244
  * {@link OAUTH_REVOKED_RETENTION_MS}.
@@ -10366,9 +11252,13 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10366
11252
  try {
10367
11253
  const tokens = await this.scopedTokenManager?.reapExpired() ?? 0;
10368
11254
  const sessions = await this.oauthSessionManager?.reapRevoked(Date.now() - OAUTH_REVOKED_RETENTION_MS) ?? 0;
10369
- if (tokens > 0 || sessions > 0) this.ctx.logger.info("auth retention swept", { meta: {
11255
+ const logins = await this.loginSessions?.reap() ?? 0;
11256
+ await this.adminTokens?.reap();
11257
+ await this.auditLog?.reap();
11258
+ if (tokens > 0 || sessions > 0 || logins > 0) this.ctx.logger.info("auth retention swept", { meta: {
10370
11259
  expiredTokens: tokens,
10371
- revokedSessions: sessions
11260
+ revokedSessions: sessions,
11261
+ deadLoginSessions: logins
10372
11262
  } });
10373
11263
  } catch (err) {
10374
11264
  this.ctx.logger.warn("auth retention sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -10387,19 +11277,32 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10387
11277
  this.scopedTokenManager = null;
10388
11278
  this.oauthSessionManager = null;
10389
11279
  this.externalIdentities = null;
11280
+ this.loginSessions = null;
11281
+ this.adminTokens = null;
11282
+ this.auditLog = null;
10390
11283
  }
10391
11284
  };
10392
11285
  //#endregion
11286
+ exports.ADMIN_TOKEN_MAX_LIFETIME_MS = ADMIN_TOKEN_MAX_LIFETIME_MS;
11287
+ exports.ADMIN_TOKEN_PREFIX = ADMIN_TOKEN_PREFIX;
11288
+ exports.AdminTokenManager = AdminTokenManager;
10393
11289
  exports.ApiKeyManager = ApiKeyManager;
11290
+ exports.AuthAuditLog = AuthAuditLog;
10394
11291
  exports.AuthManager = AuthManager;
10395
11292
  exports.FIRST_BOOT_ADMIN_PASSWORD = FIRST_BOOT_ADMIN_PASSWORD;
10396
11293
  exports.FIRST_BOOT_ADMIN_USERNAME = FIRST_BOOT_ADMIN_USERNAME;
10397
11294
  exports.LocalAuthAddon = LocalAuthAddon;
10398
11295
  exports.default = LocalAuthAddon;
11296
+ exports.LoginSessionManager = LoginSessionManager;
11297
+ exports.REFRESH_TOKEN_PREFIX = REFRESH_TOKEN_PREFIX;
10399
11298
  exports.ScopedTokenManager = ScopedTokenManager;
10400
11299
  exports.SettingsReadUnavailableError = SettingsReadUnavailableError;
11300
+ exports.USER_MANAGEMENT_INTERNAL = USER_MANAGEMENT_INTERNAL;
10401
11301
  exports.UserManager = UserManager;
11302
+ exports.attachUserManagementInternal = attachUserManagementInternal;
11303
+ exports.getUserManagementInternal = getUserManagementInternal;
10402
11304
  exports.isSettingsReadUnavailable = isSettingsReadUnavailable;
11305
+ exports.parseTokenExpiry = parseTokenExpiry;
10403
11306
  Object.defineProperty(exports, "require_ms", {
10404
11307
  enumerable: true,
10405
11308
  get: function() {