@camstack/system 1.2.455 → 1.2.456

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 (98) hide show
  1. package/dist/{agent-https-CLWrOO3o.mjs → agent-https-fm4AR8XL.mjs} +32 -5
  2. package/dist/{agent-https-CCvlRQKV.js → agent-https-yjxdfJDA.js} +37 -4
  3. package/dist/auth/auth-manager.d.ts +9 -0
  4. package/dist/auth/legacy-token-tables.d.ts +16 -0
  5. package/dist/auth/user-management-internal.d.ts +9 -28
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.js +2 -2
  11. package/dist/builtins/alerts/alerts.addon.mjs +2 -2
  12. package/dist/builtins/autotrack/index.js +1 -1
  13. package/dist/builtins/autotrack/index.mjs +1 -1
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  15. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  16. package/dist/builtins/calendars/calendars.addon.js +1 -1
  17. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  18. package/dist/builtins/camera-grid/index.js +1 -1
  19. package/dist/builtins/camera-grid/index.mjs +1 -1
  20. package/dist/builtins/composer/composer.addon.js +2 -2
  21. package/dist/builtins/composer/composer.addon.mjs +2 -2
  22. package/dist/builtins/console-logging/index.js +1 -1
  23. package/dist/builtins/console-logging/index.mjs +1 -1
  24. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  25. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  26. package/dist/builtins/device-history/device-history.addon.js +1 -1
  27. package/dist/builtins/device-history/device-history.addon.mjs +1 -1
  28. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  29. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  30. package/dist/builtins/hub-forwarder/index.js +1 -1
  31. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  32. package/dist/builtins/keychain/index.js +4 -4
  33. package/dist/builtins/keychain/index.mjs +4 -4
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  35. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  36. package/dist/builtins/local-auth/auth-schema.d.ts +4 -7
  37. package/dist/builtins/local-auth/local-auth.addon.d.ts +7 -8
  38. package/dist/builtins/local-auth/local-auth.addon.js +1086 -1329
  39. package/dist/builtins/local-auth/local-auth.addon.mjs +1085 -1326
  40. package/dist/builtins/local-auth/preferences-record-kinds.d.ts +27 -0
  41. package/dist/builtins/local-auth/record-kinds.d.ts +4 -3
  42. package/dist/builtins/local-auth/user-preferences-store.d.ts +56 -0
  43. package/dist/builtins/local-network/local-network.addon.d.ts +8 -0
  44. package/dist/builtins/local-network/local-network.addon.js +30 -5
  45. package/dist/builtins/local-network/local-network.addon.mjs +30 -5
  46. package/dist/builtins/loki-logging/index.js +1 -1
  47. package/dist/builtins/loki-logging/index.mjs +1 -1
  48. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  49. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  50. package/dist/builtins/platform-probe/index.js +1 -1
  51. package/dist/builtins/platform-probe/index.mjs +1 -1
  52. package/dist/builtins/record-entries/index.js +1 -1
  53. package/dist/builtins/record-entries/index.mjs +1 -1
  54. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  55. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  56. package/dist/builtins/snapshot/index.js +1 -1
  57. package/dist/builtins/snapshot/index.mjs +1 -1
  58. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  59. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  60. package/dist/builtins/sqlite-storage/index.js +1 -1
  61. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  62. package/dist/builtins/sqlite-storage/records/records-engine-types.d.ts +1 -0
  63. package/dist/builtins/sqlite-storage/records/records-provider.d.ts +13 -0
  64. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +52 -6
  65. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +52 -6
  66. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  67. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  68. package/dist/builtins/system-config/system-config.addon.js +3 -3
  69. package/dist/builtins/system-config/system-config.addon.mjs +3 -3
  70. package/dist/builtins/winston-logging/index.js +1 -1
  71. package/dist/builtins/winston-logging/index.mjs +1 -1
  72. package/dist/{composition-sources-CSzD5_qX.mjs → composition-sources-Ds0Vr6eD.mjs} +1 -1
  73. package/dist/{composition-sources-BxUkLNSo.js → composition-sources-gB4coDJR.js} +1 -1
  74. package/dist/{constants-DruwCkfp.js → constants-CavyKfHZ.js} +1 -1
  75. package/dist/{constants-DOlYt1t1.mjs → constants-i5oD_gLB.mjs} +1 -1
  76. package/dist/{device-ca-QPBgfqVY.js → device-ca-bpoEr0zG.js} +1 -1
  77. package/dist/{device-ca-uFbTyY_o.mjs → device-ca-qz1Pmeu1.mjs} +1 -1
  78. package/dist/{dist-BpSENkRJ.mjs → dist-BSF_C8W2.mjs} +216 -109
  79. package/dist/{dist-DjQ8C-ku.js → dist-BpqA2CT0.js} +251 -108
  80. package/dist/file-host-worker.js +1 -1
  81. package/dist/file-host-worker.mjs +1 -1
  82. package/dist/{hub-mdns-ciao-DlgkFJVY.mjs → hub-mdns-ciao-CHaGDzyz.mjs} +20 -13
  83. package/dist/{hub-mdns-ciao-Bkrvgnma.js → hub-mdns-ciao-CZlZRxxA.js} +20 -13
  84. package/dist/index.d.ts +2 -4
  85. package/dist/index.js +17 -11
  86. package/dist/index.mjs +17 -9
  87. package/dist/{phase6-declare-gate-JQUqgQgq.mjs → phase6-declare-gate-B4yfsf2n.mjs} +1 -1
  88. package/dist/{phase6-declare-gate-DrmTtz7F.js → phase6-declare-gate-n-IZRklX.js} +1 -1
  89. package/dist/{restore-archive-4OP0EYg6.js → restore-archive-B4QG7XmX.js} +1 -1
  90. package/dist/{restore-archive-YFSJRtKy.mjs → restore-archive-BGUM4FKf.mjs} +1 -1
  91. package/dist/tls/cert-issue.d.ts +16 -1
  92. package/dist/tls/cert-manager.d.ts +6 -0
  93. package/dist/tls/index.d.ts +1 -1
  94. package/dist/{wal-maintenance-TyuOEVy1.mjs → wal-maintenance-AerF4XE6.mjs} +0 -0
  95. package/dist/{wal-maintenance-Bl0ZKjVR.js → wal-maintenance-CNEs_I94.js} +0 -0
  96. package/package.json +1 -1
  97. package/dist/auth/admin-token-manager.d.ts +0 -51
  98. package/dist/auth/scoped-token-manager.d.ts +0 -25
@@ -3,12 +3,12 @@ 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-DjQ8C-ku.js");
6
+ const require_dist = require("../../dist-BpqA2CT0.js");
7
7
  const require_read_all_rows$1 = require("../../read-all-rows-CVoO1ONM.js");
8
8
  const require_safe_buffer$1 = require("../../safe-buffer-D3aoa-7X.js");
9
9
  const require_ms$1 = require("../../ms-LB3giGoU.js");
10
- const require_device_ca = require("../../device-ca-QPBgfqVY.js");
11
- const require_phase6_declare_gate = require("../../phase6-declare-gate-DrmTtz7F.js");
10
+ const require_device_ca = require("../../device-ca-bpoEr0zG.js");
11
+ const require_phase6_declare_gate = require("../../phase6-declare-gate-n-IZRklX.js");
12
12
  let zod = require("zod");
13
13
  let node_crypto = require("node:crypto");
14
14
  node_crypto = require_chunk.__toESM(node_crypto);
@@ -36,7 +36,7 @@ function parseRecord$2(kind, schema, data) {
36
36
  *
37
37
  * No boundary accepts one (the tRPC context has no branch for them) and
38
38
  * nothing creates them any more: what is left is the rows a hub may still
39
- * hold, so an operator can see and delete them. Scoped tokens (`cst_`) are
39
+ * hold, so an operator can see and delete them. Tokens (`auth.mintToken`) are
40
40
  * the credential for scripts and integrations.
41
41
  */
42
42
  var import_read_all_rows = require_read_all_rows$1.require_read_all_rows();
@@ -189,7 +189,7 @@ function genSalt(rounds, seed_length, callback) {
189
189
  * @returns {!Promise} If `callback` has been omitted
190
190
  * @throws {Error} If `callback` is present but not a function
191
191
  */
192
- function hash$2(password, salt, callback, progressCallback) {
192
+ function hash$1(password, salt, callback, progressCallback) {
193
193
  function _async(callback) {
194
194
  if (typeof password === "string" && typeof salt === "number") genSalt(salt, function(err, salt) {
195
195
  _hash(password, salt, callback, progressCallback);
@@ -242,7 +242,7 @@ function compare(password, hashValue, callback, progressCallback) {
242
242
  nextTick(callback.bind(this, null, false));
243
243
  return;
244
244
  }
245
- hash$2(password, hashValue.substring(0, 29), function(err, comp) {
245
+ hash$1(password, hashValue.substring(0, 29), function(err, comp) {
246
246
  if (err) callback(err);
247
247
  else callback(null, safeStringCompare(comp, hashValue));
248
248
  }, progressCallback);
@@ -6118,13 +6118,29 @@ var AuthManager = class {
6118
6118
  * before signing, so nothing outside the schema is ever signed.
6119
6119
  */
6120
6120
  signCredentialToken(claims) {
6121
- return import_jsonwebtoken.sign(require_dist.CredentialTokenClaimsSchema.parse(claims), this.jwtSecret);
6121
+ return import_jsonwebtoken.sign(require_dist.CredentialTokenClaimsSchema.parse(claims), this.jwtSecret, { audience: require_dist.CREDENTIAL_TOKEN_AUDIENCE });
6122
6122
  }
6123
+ /**
6124
+ * Verify a session-family JWT: a login session, an account link, a bridge or
6125
+ * challenge token. A MINTED token is refused here even though it carries a
6126
+ * valid signature (D895): every surface that calls this wants a person's
6127
+ * session, and a share link or a script's token must never pass for one.
6128
+ * Minted tokens verify only through {@link verifyCredentialToken}.
6129
+ */
6123
6130
  verifyToken(token) {
6124
- return import_jsonwebtoken.verify(token, this.jwtSecret);
6131
+ const payload = import_jsonwebtoken.verify(token, this.jwtSecret);
6132
+ if (require_dist.isCredentialTokenPayload(payload)) throw new Error("A minted token is not a session credential");
6133
+ return payload;
6134
+ }
6135
+ /** Verify a minted token (`auth.mintToken`): its signature AND its audience. Throws when invalid. */
6136
+ verifyCredentialToken(token) {
6137
+ const payload = import_jsonwebtoken.verify(token, this.jwtSecret, { audience: require_dist.CREDENTIAL_TOKEN_AUDIENCE });
6138
+ const claims = require_dist.CredentialTokenClaimsSchema.safeParse(payload);
6139
+ if (!claims.success) throw new Error("Not a minted token");
6140
+ return claims.data;
6125
6141
  }
6126
6142
  async hashPassword(password) {
6127
- return hash$2(password, 10);
6143
+ return hash$1(password, 10);
6128
6144
  }
6129
6145
  async comparePassword(password, hash) {
6130
6146
  return compare(password, hash);
@@ -6515,19 +6531,11 @@ var AuthAuditLog = class {
6515
6531
  }
6516
6532
  };
6517
6533
  //#endregion
6518
- //#region src/auth/admin-token-manager.ts
6519
- /**
6520
- * Admin tokens (`csa_…`) — the one credential an ops script can hold that
6521
- * acts as an administrator, minted deliberately and kept on a short leash.
6522
- *
6523
- * Before phase-6 cutover: mint a spare `csa_` token, verify it against the hub,
6524
- * and keep the records-adopt snapshot — a boot guard mistake locks operators out.
6525
- */
6534
+ //#region src/auth/legacy-token-tables.ts
6526
6535
  /**
6527
6536
  * @durable class=config owner=local-auth
6528
- * write="an admin creates a token (after re-authentication); `lastUsedAt` is
6529
- * rewritten at most once a minute while it is used; `revoke` stamps revokedAt"
6530
- * retention="the daily reap deletes rows expired or revoked more than 30 days ago"
6537
+ * write="none since D895 — the pre-D892 rows are kept on disk, read by nothing"
6538
+ * retention="the operator drops the table by hand"
6531
6539
  */
6532
6540
  var ADMIN_TOKENS_COLLECTION = "admin_tokens";
6533
6541
  var ADMIN_TOKENS_COLUMNS = [
@@ -6577,210 +6585,6 @@ var ADMIN_TOKENS_COLUMNS = [
6577
6585
  type: "INTEGER"
6578
6586
  }
6579
6587
  ];
6580
- var ADMIN_TOKEN_PREFIX = "csa_";
6581
- var ADMIN_TOKEN_MAX_LIFETIME_MS = 90 * 864e5;
6582
- var DEAD_RETENTION_MS = 30 * 864e5;
6583
- var TOUCH_EVERY_MS = 6e4;
6584
- function hash$1(token) {
6585
- return node_crypto.createHash("sha256").update(token).digest("hex");
6586
- }
6587
- function parse(data) {
6588
- const { id, createdBy, name, tokenHash, tokenPrefix, createdAt, expiresAt } = data;
6589
- if (typeof id !== "string" || typeof createdBy !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || typeof tokenPrefix !== "string" || typeof createdAt !== "number" || typeof expiresAt !== "number") return null;
6590
- return {
6591
- id,
6592
- createdBy,
6593
- name,
6594
- tokenHash,
6595
- tokenPrefix,
6596
- createdAt,
6597
- expiresAt,
6598
- lastUsedAt: typeof data["lastUsedAt"] === "number" ? data["lastUsedAt"] : null,
6599
- revokedAt: typeof data["revokedAt"] === "number" ? data["revokedAt"] : null
6600
- };
6601
- }
6602
- function parseBody(id, body) {
6603
- return parse({
6604
- id,
6605
- ...body
6606
- });
6607
- }
6608
- function storedToBody(stored) {
6609
- return {
6610
- createdBy: stored.createdBy,
6611
- name: stored.name,
6612
- tokenHash: stored.tokenHash,
6613
- tokenPrefix: stored.tokenPrefix,
6614
- createdAt: stored.createdAt,
6615
- expiresAt: stored.expiresAt,
6616
- lastUsedAt: stored.lastUsedAt,
6617
- revokedAt: stored.revokedAt
6618
- };
6619
- }
6620
- function summary(stored) {
6621
- const { tokenHash: _hash, ...record } = stored;
6622
- return record;
6623
- }
6624
- var AdminTokenManager = class {
6625
- now;
6626
- kind;
6627
- store;
6628
- constructor(deps, now = Date.now) {
6629
- this.now = now;
6630
- if ("adminTokensKind" in deps) {
6631
- this.kind = deps.adminTokensKind;
6632
- this.store = deps.store ?? null;
6633
- } else {
6634
- this.kind = null;
6635
- this.store = deps;
6636
- }
6637
- }
6638
- /** Mint a token for `createdBy`. Refuses an expiry in the past or past 90 days. */
6639
- async create(input) {
6640
- const at = this.now();
6641
- if (!(input.expiresAt > at) || input.expiresAt > at + 7776e6) throw require_dist.userError("system.admin-token-expiry-too-long", void 0, "An admin token must expire within 90 days");
6642
- const name = input.name.trim().slice(0, 80);
6643
- if (name.length === 0) throw require_dist.userError("system.admin-token-name-required", void 0, "An admin token needs a name");
6644
- const token = `${ADMIN_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
6645
- const stored = {
6646
- id: node_crypto.randomUUID(),
6647
- createdBy: input.createdBy,
6648
- name,
6649
- tokenHash: hash$1(token),
6650
- tokenPrefix: token.slice(0, 12),
6651
- createdAt: at,
6652
- expiresAt: input.expiresAt,
6653
- lastUsedAt: null,
6654
- revokedAt: null
6655
- };
6656
- const kind = this.kind;
6657
- if (kind !== null) {
6658
- const body = storedToBody(stored);
6659
- const parsed = localAuthRecordKinds["admin-tokens"].schema.safeParse(body);
6660
- if (!parsed.success) throw new Error(parsed.error.message);
6661
- await kind.put({
6662
- id: stored.id,
6663
- expiresAt: stored.expiresAt,
6664
- data: parsed.data
6665
- });
6666
- return {
6667
- token,
6668
- record: summary(stored)
6669
- };
6670
- }
6671
- await this.store.insert.mutate({
6672
- collection: ADMIN_TOKENS_COLLECTION,
6673
- record: {
6674
- id: stored.id,
6675
- data: { ...stored }
6676
- }
6677
- });
6678
- return {
6679
- token,
6680
- record: summary(stored)
6681
- };
6682
- }
6683
- /** The live record behind `token`, or `null` (unknown, revoked, expired, not a `csa_`). */
6684
- async validate(token) {
6685
- if (!token.startsWith("csa_")) return null;
6686
- const tokenHash = hash$1(token);
6687
- const kind = this.kind;
6688
- let stored = null;
6689
- if (kind !== null) {
6690
- const row = (await kind.query({
6691
- where: { tokenHash },
6692
- limit: 1
6693
- })).records[0];
6694
- stored = row === void 0 ? null : parseBody(row.id, row.data);
6695
- } else {
6696
- const first = (await this.store.query.query({
6697
- collection: ADMIN_TOKENS_COLLECTION,
6698
- filter: { where: { tokenHash } }
6699
- }))[0];
6700
- stored = first === void 0 ? null : parse(first.data);
6701
- }
6702
- if (stored === null || stored.revokedAt !== null) return null;
6703
- const at = this.now();
6704
- if (stored.expiresAt <= at) return null;
6705
- if (stored.lastUsedAt === null || at - stored.lastUsedAt > TOUCH_EVERY_MS) {
6706
- const next = {
6707
- ...stored,
6708
- lastUsedAt: at
6709
- };
6710
- if (kind !== null) kind.put({
6711
- id: stored.id,
6712
- expiresAt: stored.expiresAt,
6713
- data: storedToBody(next)
6714
- }).catch(() => {});
6715
- else this.store.update.mutate({
6716
- collection: ADMIN_TOKENS_COLLECTION,
6717
- id: stored.id,
6718
- data: { ...next }
6719
- }).catch(() => {});
6720
- }
6721
- return summary(stored);
6722
- }
6723
- /** Every token not yet reaped, newest first (no hashes). */
6724
- async list() {
6725
- const kind = this.kind;
6726
- if (kind !== null) return (await (0, import_read_all_rows.readAllRows)(kind)).map((row) => parseBody(row.id, row.data)).filter((r) => r !== null).map(summary).toSorted((a, b) => b.createdAt - a.createdAt);
6727
- 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);
6728
- }
6729
- /** Revoke one token. `false` when unknown or already revoked. */
6730
- async revoke(id) {
6731
- const kind = this.kind;
6732
- let stored = null;
6733
- if (kind !== null) {
6734
- const row = await kind.get(id);
6735
- stored = row === null ? null : parseBody(row.id, row.data);
6736
- } else {
6737
- const first = (await this.store.query.query({
6738
- collection: ADMIN_TOKENS_COLLECTION,
6739
- filter: { where: { id } }
6740
- }))[0];
6741
- stored = first === void 0 ? null : parse(first.data);
6742
- }
6743
- if (stored === null || stored.revokedAt !== null) return false;
6744
- const next = {
6745
- ...stored,
6746
- revokedAt: this.now()
6747
- };
6748
- if (kind !== null) await kind.put({
6749
- id,
6750
- expiresAt: next.expiresAt,
6751
- data: storedToBody(next)
6752
- });
6753
- else await this.store.update.mutate({
6754
- collection: ADMIN_TOKENS_COLLECTION,
6755
- id,
6756
- data: { ...next }
6757
- });
6758
- return true;
6759
- }
6760
- /** Revoke every live token `userId` created (the user was deleted). */
6761
- async revokeAllCreatedBy(userId) {
6762
- let revoked = 0;
6763
- for (const token of await this.list()) if (token.createdBy === userId && token.revokedAt === null) {
6764
- if (await this.revoke(token.id)) revoked++;
6765
- }
6766
- return revoked;
6767
- }
6768
- /** Delete rows expired or revoked more than 30 days ago. */
6769
- async reap() {
6770
- const horizon = this.now() - DEAD_RETENTION_MS;
6771
- let deleted = 0;
6772
- for (const token of await this.list()) if ((token.revokedAt ?? token.expiresAt) < horizon) {
6773
- const kind = this.kind;
6774
- if (kind !== null) await kind.delete(token.id);
6775
- else await this.store.delete.mutate({
6776
- collection: ADMIN_TOKENS_COLLECTION,
6777
- key: token.id
6778
- });
6779
- deleted++;
6780
- }
6781
- return deleted;
6782
- }
6783
- };
6784
6588
  //#endregion
6785
6589
  //#region src/auth/device-credential-manager.ts
6786
6590
  /**
@@ -7392,82 +7196,80 @@ var DeviceCredentialManager = class {
7392
7196
  }
7393
7197
  };
7394
7198
  //#endregion
7395
- //#region src/auth/session-revocation-bus.ts
7396
- var BUS_KEY = Symbol.for("camstack.login-sessions.revocation-bus");
7397
- function listeners() {
7398
- const existing = Reflect.get(globalThis, BUS_KEY);
7399
- if (existing instanceof Set) return existing;
7400
- const created = /* @__PURE__ */ new Set();
7401
- Reflect.set(globalThis, BUS_KEY, created);
7402
- return created;
7403
- }
7404
- /** Subscribe; returns the unsubscribe. */
7405
- function onSessionsRevoked(listener) {
7406
- const set = listeners();
7407
- set.add(listener);
7408
- return () => {
7409
- set.delete(listener);
7410
- };
7411
- }
7412
- /** Announce an ended batch. A listener that throws never reaches the revoke. */
7413
- function announceSessionsRevoked(event, onListenerError) {
7414
- if (event.sessionIds.length === 0 && !event.allOfUser) return;
7415
- for (const listener of [...listeners()]) try {
7416
- listener(event);
7417
- } catch (error) {
7418
- onListenerError?.(error);
7419
- }
7420
- }
7421
- //#endregion
7422
- //#region src/auth/login-session-manager.ts
7199
+ //#region src/builtins/local-auth/auth-schema.ts
7423
7200
  /**
7424
- * Login sessions — the server-side record behind every session token.
7425
- *
7426
- * A session token used to be a self-contained 30-day JWT: nothing on the hub
7427
- * knew it existed, so logout revoked nothing, a password change left every
7428
- * other device signed in, and a deleted user's sessions lived until they
7429
- * expired. Now every login creates a row here and the token carries its id
7430
- * (`sid`); the hub checks the row on every request (`check`, an in-memory
7431
- * lookup), so revoking a row ends the session at once.
7432
- *
7433
- * Two kinds of client:
7434
- * - **refreshable** — gets a short access token (default 2 h) and an opaque
7435
- * refresh token (`csr_…`, stored hashed). `refresh` rotates the refresh
7436
- * token and slides the session's expiry; presenting a rotated-out refresh
7437
- * token after a short grace window is treated as theft and revokes the
7438
- * whole session.
7439
- * - **legacy** — a client from before refresh tokens: its access token lives
7440
- * as long as the session, and it renews through the handoff-code pair
7441
- * (`extend`). Kept for one release so installed viewer apps keep working.
7442
- *
7443
- * `legacyCutoff(userId)` covers tokens minted before sessions existed (no
7444
- * `sid`): a token of that user issued before the cutoff is refused. Revoking
7445
- * all of a user's sessions moves the cutoff, so a password change or a user
7446
- * deletion also ends their pre-session tokens.
7447
- *
7448
- * Mirror: every row is held in memory (a homelab hub has tens of sessions) and
7449
- * written through to the store; revocations update the mirror FIRST, so a
7450
- * store hiccup can delay persistence but never the revocation itself.
7201
+ * @durable class=config owner=local-auth
7202
+ * write="an operator creates a user, or `ensureAdminExists` seeds the configured
7203
+ * admin on first boot (or resets its password on the operator's one-shot
7204
+ * RESET_ADMIN_PASSWORD trigger); updated on scope, isAdmin and password edits"
7205
+ * retention="none — a row goes only when an operator deletes the user. A
7206
+ * `declareCollection` that adds a PK/UNIQUE/NOT-NULL-without-default column
7207
+ * REBUILDS the table: every account is wiped and only the configured admin
7208
+ * comes back on the next boot."
7451
7209
  */
7210
+ var USERS_COLLECTION$1 = "users";
7211
+ /**
7212
+ * @durable class=config owner=local-auth
7213
+ * write="an operator mints an API key; `validateToken` rewrites `lastUsedAt` on
7214
+ * every request the key authorises"
7215
+ * retention="none — an explicit `revoke` is the only delete. API keys have no
7216
+ * expiry column and no sweep, so a key lives until someone removes it (or the
7217
+ * destructive `declareCollection` rebuild takes the table)."
7218
+ */
7219
+ var API_KEYS_COLLECTION = "api_keys";
7220
+ /**
7221
+ * @durable class=config owner=local-auth
7222
+ * write="none since D895 — the pre-D892 scoped-token rows are kept on disk,
7223
+ * read by nothing (a scoped token is a `credential-tokens` row)"
7224
+ * retention="the operator drops the table by hand"
7225
+ */
7226
+ var SCOPED_TOKENS_COLLECTION = "scoped_tokens";
7452
7227
  /**
7453
7228
  * @durable class=ledger owner=local-auth
7454
- * write="one row per login (password, passkey, SSO, handoff, session exchange);
7455
- * rewritten on every refresh / renewal (expiry slides, refresh hash rotates)
7456
- * and on revocation"
7457
- * retention="the daily reap deletes rows expired more than 7 days ago and rows
7458
- * revoked more than 30 days ago. A session the user keeps refreshing lives as
7459
- * long as it is used."
7229
+ * write="one row per redeemed OAuth code — every completed account link through
7230
+ * `oauthExchangeCode`; `touch` rewrites `lastUsedAt` on each refresh"
7231
+ * retention="ONLY revoked rows are pruned: the daily sweep deletes sessions
7232
+ * revoked more than OAUTH_REVOKED_RETENTION_MS (30 d) ago. An ACTIVE session
7233
+ * has `revokedAt` NULL, never expires and is never swept — the table grows one
7234
+ * row per login until an operator revokes the link."
7460
7235
  */
7461
- var LOGIN_SESSIONS_COLLECTION = "login_sessions";
7236
+ var OAUTH_SESSIONS_COLLECTION = "oauth_sessions";
7237
+ /**
7238
+ * TOTP enrollment table — split off from `users` to avoid the
7239
+ * destructive drop-+-recreate migration `ensureTable` performs when a
7240
+ * new column is added to a typed schema (see `sqlite-settings-backend.ts`).
7241
+ *
7242
+ * Lifecycle:
7243
+ * - Setup: row inserted with `confirmedAt = null` and a fresh secret.
7244
+ * - Confirm: a valid code from the secret flips `confirmedAt` to now().
7245
+ * - Disable: the row is deleted (next setup starts fresh).
7246
+ *
7247
+ * "Enabled" means `confirmedAt != null`. A half-enrolled row (`confirmedAt
7248
+ * = null`) is INACTIVE — `verifyTotp` returns false until confirmation.
7249
+ *
7250
+ * @durable class=config owner=local-auth
7251
+ * write="`setup` inserts a keychain marker for the user's seed (D878; deleting any pending row
7252
+ * first); `confirm` flips `confirmedAt` once a valid code proves possession"
7253
+ * retention="`disable` deletes the row, and `setup` deletes before re-inserting.
7254
+ * Nothing else prunes — deleting a USER does not cascade here, so their
7255
+ * enrollment row outlives the account."
7256
+ */
7257
+ var USER_TOTP_COLLECTION = "user_totp";
7462
7258
  /**
7259
+ * External sign-in identities linked to local users (D782): one row per
7260
+ * `(provider, subject)` — the row id IS the identity, so one identity maps to
7261
+ * one user by primary key. Written only by the identity's owner completing a
7262
+ * "Link" flow from their profile (bound to their session).
7263
+ *
7463
7264
  * @durable class=config owner=local-auth
7464
- * write="revoking all of a user's sessions (logout everywhere, password change or
7465
- * reset, user deletion) sets that user's cutoff to now"
7466
- * retention="none — one row per user who ever had all sessions revoked; it only
7467
- * ever matters for tokens minted before sessions existed, and is harmless after."
7265
+ * write="`ExternalIdentityManager.link` when a signed-in user completes a
7266
+ * link flow; `unlink` from the profile or the Users page"
7267
+ * retention="`unlink` deletes one row; deleting the user deletes all of its
7268
+ * rows (`deleteUser` → `removeAllForUser`). A row whose provider instance
7269
+ * was renamed or removed stays, inert, until someone unlinks it."
7468
7270
  */
7469
- var LOGIN_SESSION_CUTOFFS_COLLECTION = "login_session_cutoffs";
7470
- var LOGIN_SESSIONS_COLUMNS = [
7271
+ var USER_EXTERNAL_IDENTITIES_COLLECTION = "user_external_identities";
7272
+ var USERS_COLUMNS = [
7471
7273
  {
7472
7274
  name: "id",
7473
7275
  type: "TEXT",
@@ -7475,813 +7277,241 @@ var LOGIN_SESSIONS_COLUMNS = [
7475
7277
  notNull: true
7476
7278
  },
7477
7279
  {
7478
- name: "userId",
7280
+ name: "username",
7479
7281
  type: "TEXT",
7480
- notNull: true
7282
+ notNull: true,
7283
+ unique: true
7481
7284
  },
7482
7285
  {
7483
- name: "method",
7286
+ name: "passwordHash",
7484
7287
  type: "TEXT",
7485
7288
  notNull: true
7486
7289
  },
7487
7290
  {
7488
- name: "refreshable",
7291
+ name: "isAdmin",
7489
7292
  type: "BOOLEAN",
7490
7293
  notNull: true
7491
7294
  },
7492
7295
  {
7493
- name: "createdAt",
7494
- type: "INTEGER",
7495
- notNull: true
7296
+ name: "allowedProviders",
7297
+ type: "JSON"
7496
7298
  },
7497
7299
  {
7498
- name: "lastUsedAt",
7499
- type: "INTEGER",
7500
- notNull: true
7300
+ name: "allowedDevices",
7301
+ type: "JSON"
7501
7302
  },
7502
7303
  {
7503
- name: "expiresAt",
7504
- type: "INTEGER",
7505
- notNull: true
7304
+ name: "scopes",
7305
+ type: "JSON"
7506
7306
  },
7507
7307
  {
7508
- name: "revokedAt",
7509
- type: "INTEGER"
7308
+ name: "createdAt",
7309
+ type: "INTEGER",
7310
+ notNull: true
7510
7311
  },
7511
7312
  {
7512
- name: "revokedReason",
7513
- type: "TEXT"
7313
+ name: "updatedAt",
7314
+ type: "INTEGER",
7315
+ notNull: true
7316
+ }
7317
+ ];
7318
+ var API_KEYS_COLUMNS = [
7319
+ {
7320
+ name: "id",
7321
+ type: "TEXT",
7322
+ primaryKey: true,
7323
+ notNull: true
7514
7324
  },
7515
7325
  {
7516
- name: "refreshHash",
7517
- type: "TEXT"
7326
+ name: "label",
7327
+ type: "TEXT",
7328
+ notNull: true
7518
7329
  },
7519
7330
  {
7520
- name: "previousRefreshHash",
7521
- type: "TEXT"
7331
+ name: "isAdmin",
7332
+ type: "BOOLEAN",
7333
+ notNull: true
7522
7334
  },
7523
7335
  {
7524
- name: "rotatedAt",
7336
+ name: "tokenHash",
7337
+ type: "TEXT",
7338
+ notNull: true,
7339
+ unique: true
7340
+ },
7341
+ {
7342
+ name: "tokenPrefix",
7343
+ type: "TEXT",
7344
+ notNull: true
7345
+ },
7346
+ {
7347
+ name: "allowedProviders",
7348
+ type: "JSON"
7349
+ },
7350
+ {
7351
+ name: "allowedDevices",
7352
+ type: "JSON"
7353
+ },
7354
+ {
7355
+ name: "createdAt",
7356
+ type: "INTEGER",
7357
+ notNull: true
7358
+ },
7359
+ {
7360
+ name: "lastUsedAt",
7525
7361
  type: "INTEGER"
7362
+ }
7363
+ ];
7364
+ var USER_TOTP_COLUMNS = [
7365
+ {
7366
+ name: "userId",
7367
+ type: "TEXT",
7368
+ primaryKey: true,
7369
+ notNull: true
7526
7370
  },
7527
7371
  {
7528
- name: "client",
7529
- type: "TEXT"
7372
+ name: "secret",
7373
+ type: "TEXT",
7374
+ notNull: true
7530
7375
  },
7531
7376
  {
7532
- name: "ip",
7533
- type: "TEXT"
7377
+ name: "confirmedAt",
7378
+ type: "INTEGER"
7534
7379
  },
7535
7380
  {
7536
- name: "credentialId",
7381
+ name: "createdAt",
7382
+ type: "INTEGER",
7383
+ notNull: true
7384
+ }
7385
+ ];
7386
+ var USER_EXTERNAL_IDENTITIES_COLUMNS = [
7387
+ {
7388
+ name: "id",
7389
+ type: "TEXT",
7390
+ primaryKey: true,
7391
+ notNull: true
7392
+ },
7393
+ {
7394
+ name: "provider",
7395
+ type: "TEXT",
7396
+ notNull: true
7397
+ },
7398
+ {
7399
+ name: "subject",
7400
+ type: "TEXT",
7401
+ notNull: true
7402
+ },
7403
+ {
7404
+ name: "issuer",
7405
+ type: "TEXT",
7406
+ notNull: true
7407
+ },
7408
+ {
7409
+ name: "userId",
7410
+ type: "TEXT",
7411
+ notNull: true
7412
+ },
7413
+ {
7414
+ name: "label",
7537
7415
  type: "TEXT"
7416
+ },
7417
+ {
7418
+ name: "linkedAt",
7419
+ type: "INTEGER",
7420
+ notNull: true
7538
7421
  }
7539
7422
  ];
7540
- var LOGIN_SESSION_CUTOFFS_COLUMNS = [{
7541
- name: "userId",
7542
- type: "TEXT",
7543
- primaryKey: true,
7544
- notNull: true
7545
- }, {
7546
- name: "notBefore",
7547
- type: "INTEGER",
7548
- notNull: true
7549
- }];
7550
- var METHODS = [
7551
- "password",
7552
- "passkey",
7553
- "sso",
7554
- "handoff",
7555
- "exchange",
7556
- "device-certificate"
7423
+ var SCOPED_TOKENS_COLUMNS = [
7424
+ {
7425
+ name: "id",
7426
+ type: "TEXT",
7427
+ primaryKey: true,
7428
+ notNull: true
7429
+ },
7430
+ {
7431
+ name: "userId",
7432
+ type: "TEXT",
7433
+ notNull: true
7434
+ },
7435
+ {
7436
+ name: "name",
7437
+ type: "TEXT",
7438
+ notNull: true
7439
+ },
7440
+ {
7441
+ name: "tokenHash",
7442
+ type: "TEXT",
7443
+ notNull: true,
7444
+ unique: true
7445
+ },
7446
+ {
7447
+ name: "tokenPrefix",
7448
+ type: "TEXT",
7449
+ notNull: true
7450
+ },
7451
+ {
7452
+ name: "scopes",
7453
+ type: "JSON"
7454
+ },
7455
+ {
7456
+ name: "expiresAt",
7457
+ type: "INTEGER"
7458
+ },
7459
+ {
7460
+ name: "lastUsedAt",
7461
+ type: "INTEGER"
7462
+ },
7463
+ {
7464
+ name: "createdAt",
7465
+ type: "INTEGER",
7466
+ notNull: true
7467
+ }
7468
+ ];
7469
+ var OAUTH_SESSIONS_COLUMNS = [
7470
+ {
7471
+ name: "id",
7472
+ type: "TEXT",
7473
+ primaryKey: true,
7474
+ notNull: true
7475
+ },
7476
+ {
7477
+ name: "userId",
7478
+ type: "TEXT",
7479
+ notNull: true
7480
+ },
7481
+ {
7482
+ name: "username",
7483
+ type: "TEXT",
7484
+ notNull: true
7485
+ },
7486
+ {
7487
+ name: "integrationId",
7488
+ type: "TEXT",
7489
+ notNull: true
7490
+ },
7491
+ {
7492
+ name: "scopes",
7493
+ type: "JSON"
7494
+ },
7495
+ {
7496
+ name: "createdAt",
7497
+ type: "INTEGER",
7498
+ notNull: true
7499
+ },
7500
+ {
7501
+ name: "lastUsedAt",
7502
+ type: "INTEGER",
7503
+ notNull: true
7504
+ },
7505
+ {
7506
+ name: "revokedAt",
7507
+ type: "INTEGER"
7508
+ }
7557
7509
  ];
7558
- var REFRESH_TOKEN_PREFIX = "csr_";
7559
7510
  /**
7560
- * A rotated-out refresh token is still honoured this long: two tabs (or a
7561
- * retry after a dropped response) may present the same token a moment apart.
7562
- * After it, the old token is evidence of a copy and ends the session.
7563
- */
7564
- var REFRESH_REUSE_GRACE_MS = 3e4;
7565
- var EXPIRED_RETENTION_MS = 7 * 864e5;
7566
- var REVOKED_RETENTION_MS = 30 * 864e5;
7567
- /** Longest client label kept (a User-Agent can be huge). */
7568
- var MAX_CLIENT_LABEL = 200;
7569
- function hashToken(token) {
7570
- return node_crypto.createHash("sha256").update(token).digest("hex");
7571
- }
7572
- function str(data, key) {
7573
- const value = data[key];
7574
- return typeof value === "string" && value.length > 0 ? value : null;
7575
- }
7576
- function num(data, key) {
7577
- const value = data[key];
7578
- return typeof value === "number" && Number.isFinite(value) ? value : null;
7579
- }
7580
- function parseSession$1(data) {
7581
- const id = str(data, "id");
7582
- const userId = str(data, "userId");
7583
- const createdAt = num(data, "createdAt");
7584
- const lastUsedAt = num(data, "lastUsedAt");
7585
- const expiresAt = num(data, "expiresAt");
7586
- const method = METHODS.find((m) => m === data["method"]);
7587
- if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
7588
- return {
7589
- id,
7590
- userId,
7591
- method: method ?? "password",
7592
- refreshable: data["refreshable"] === true || data["refreshable"] === 1,
7593
- createdAt,
7594
- lastUsedAt: lastUsedAt ?? createdAt,
7595
- expiresAt,
7596
- revokedAt: num(data, "revokedAt"),
7597
- revokedReason: str(data, "revokedReason"),
7598
- refreshHash: str(data, "refreshHash"),
7599
- previousRefreshHash: str(data, "previousRefreshHash"),
7600
- rotatedAt: num(data, "rotatedAt"),
7601
- client: str(data, "client"),
7602
- ip: str(data, "ip"),
7603
- credentialId: str(data, "credentialId")
7604
- };
7605
- }
7606
- function toLoginSessionSummary(record) {
7607
- const { refreshHash: _r, previousRefreshHash: _p, rotatedAt: _t, ...summary } = record;
7608
- return summary;
7609
- }
7610
- function sessionToBody$1(session) {
7611
- const { id: _id, ...body } = session;
7612
- return body;
7613
- }
7614
- var LoginSessionManager = class {
7615
- sessions = /* @__PURE__ */ new Map();
7616
- /** refresh hash (current or previous) → session id */
7617
- byRefreshHash = /* @__PURE__ */ new Map();
7618
- cutoffs = /* @__PURE__ */ new Map();
7619
- now;
7620
- logger;
7621
- sessionsKind;
7622
- cutoffsKind;
7623
- store;
7624
- constructor(deps, options = {}) {
7625
- if ("sessionsKind" in deps) {
7626
- this.sessionsKind = deps.sessionsKind;
7627
- this.cutoffsKind = deps.cutoffsKind;
7628
- this.store = deps.legacyStore ?? null;
7629
- } else {
7630
- this.sessionsKind = null;
7631
- this.cutoffsKind = null;
7632
- this.store = deps;
7633
- }
7634
- this.now = options.now ?? Date.now;
7635
- this.logger = options.logger ?? null;
7636
- }
7637
- /** Load every row into the mirror. Call once at init, before serving. */
7638
- async load() {
7639
- const sessionsKind = this.sessionsKind;
7640
- if (sessionsKind !== null) {
7641
- const rows = await (0, import_read_all_rows.readAllRows)(sessionsKind);
7642
- for (const row of rows) {
7643
- const record = parseSession$1({
7644
- id: row.id,
7645
- ...row.data
7646
- });
7647
- if (record !== null) this.remember(record);
7648
- }
7649
- const cutoffs = await (0, import_read_all_rows.readAllRows)(this.cutoffsKind);
7650
- for (const row of cutoffs) this.cutoffs.set(row.id, row.data.notBefore);
7651
- return;
7652
- }
7653
- const rows = await this.store.query.query({ collection: LOGIN_SESSIONS_COLLECTION });
7654
- for (const row of rows) {
7655
- const record = parseSession$1(row.data);
7656
- if (record !== null) this.remember(record);
7657
- }
7658
- const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
7659
- for (const row of cutoffs) {
7660
- const userId = str(row.data, "userId");
7661
- const notBefore = num(row.data, "notBefore");
7662
- if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
7663
- }
7664
- }
7665
- async create(input) {
7666
- const at = this.now();
7667
- const refreshToken = input.refreshable ? this.newRefreshToken() : null;
7668
- const session = {
7669
- id: node_crypto.randomUUID(),
7670
- userId: input.userId,
7671
- method: input.method,
7672
- refreshable: input.refreshable,
7673
- createdAt: at,
7674
- lastUsedAt: at,
7675
- expiresAt: at + input.lifetimeMs,
7676
- revokedAt: null,
7677
- revokedReason: null,
7678
- refreshHash: refreshToken === null ? null : hashToken(refreshToken),
7679
- previousRefreshHash: null,
7680
- rotatedAt: null,
7681
- client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
7682
- ip: input.ip ?? null,
7683
- credentialId: input.credentialId ?? null
7684
- };
7685
- const sessionsKind = this.sessionsKind;
7686
- if (sessionsKind !== null) {
7687
- const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
7688
- if (!parsed.success) throw new Error(parsed.error.message);
7689
- await sessionsKind.put({
7690
- id: session.id,
7691
- at: session.createdAt,
7692
- expiresAt: session.expiresAt,
7693
- data: parsed.data
7694
- });
7695
- } else await this.store.insert.mutate({
7696
- collection: LOGIN_SESSIONS_COLLECTION,
7697
- record: {
7698
- id: session.id,
7699
- data: { ...session }
7700
- }
7701
- });
7702
- this.remember(session);
7703
- return {
7704
- session,
7705
- refreshToken
7706
- };
7707
- }
7708
- /** Is `sessionId` a live session right now? Synchronous: the hub asks on every request. */
7709
- check(sessionId) {
7710
- const session = this.sessions.get(sessionId);
7711
- if (session === void 0) return { state: "unknown" };
7712
- if (session.revokedAt !== null) return { state: "revoked" };
7713
- if (session.expiresAt <= this.now()) return { state: "expired" };
7714
- return {
7715
- state: "active",
7716
- session
7717
- };
7718
- }
7719
- /** Epoch ms before which a pre-session (no `sid`) token of `userId` is refused; 0 = none. */
7720
- legacyCutoff(userId) {
7721
- return this.cutoffs.get(userId) ?? 0;
7722
- }
7723
- /**
7724
- * The session a refresh token belongs to, WITHOUT rotating it - so the
7725
- * refresh path can judge the session (its device credential) before it
7726
- * spends the token. `null` when the token names no session.
7727
- */
7728
- peekByRefreshToken(refreshToken) {
7729
- if (!refreshToken.startsWith("csr_")) return null;
7730
- const sessionId = this.byRefreshHash.get(hashToken(refreshToken));
7731
- return sessionId === void 0 ? null : this.sessions.get(sessionId) ?? null;
7732
- }
7733
- /** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
7734
- async refresh(refreshToken, lifetimeMs) {
7735
- if (!refreshToken.startsWith("csr_")) return {
7736
- ok: false,
7737
- reason: "unknown"
7738
- };
7739
- const presented = hashToken(refreshToken);
7740
- const sessionId = this.byRefreshHash.get(presented);
7741
- const session = sessionId === void 0 ? void 0 : this.sessions.get(sessionId);
7742
- if (session === void 0) return {
7743
- ok: false,
7744
- reason: "unknown"
7745
- };
7746
- if (session.revokedAt !== null) return {
7747
- ok: false,
7748
- reason: "revoked"
7749
- };
7750
- const at = this.now();
7751
- if (session.expiresAt <= at) return {
7752
- ok: false,
7753
- reason: "expired"
7754
- };
7755
- if (presented !== session.refreshHash) {
7756
- if (!(presented === session.previousRefreshHash && session.rotatedAt !== null && at - session.rotatedAt <= REFRESH_REUSE_GRACE_MS)) {
7757
- await this.revoke(session.id, "refresh-reuse");
7758
- this.logger?.warn("A rotated-out refresh token was presented — session revoked", { meta: {
7759
- sessionId: session.id,
7760
- userId: session.userId
7761
- } });
7762
- return {
7763
- ok: false,
7764
- reason: "reused"
7765
- };
7766
- }
7767
- }
7768
- const next = this.newRefreshToken();
7769
- const updated = {
7770
- ...session,
7771
- lastUsedAt: at,
7772
- expiresAt: at + lifetimeMs,
7773
- previousRefreshHash: session.refreshHash,
7774
- refreshHash: hashToken(next),
7775
- rotatedAt: at
7776
- };
7777
- this.replace(session, updated);
7778
- await this.persist(updated);
7779
- return {
7780
- ok: true,
7781
- session: updated,
7782
- refreshToken: next
7783
- };
7784
- }
7785
- /** Renew an active session (legacy client): slide its expiry. `null` when it is not active. */
7786
- async extend(sessionId, lifetimeMs) {
7787
- const check = this.check(sessionId);
7788
- if (check.state !== "active") return null;
7789
- const at = this.now();
7790
- const updated = {
7791
- ...check.session,
7792
- lastUsedAt: at,
7793
- expiresAt: at + lifetimeMs
7794
- };
7795
- this.replace(check.session, updated);
7796
- await this.persist(updated);
7797
- return updated;
7798
- }
7799
- /** End one session. `false` when it was unknown or already ended. */
7800
- async revoke(sessionId, reason) {
7801
- const session = this.sessions.get(sessionId);
7802
- if (session === void 0 || session.revokedAt !== null) return false;
7803
- const updated = {
7804
- ...session,
7805
- revokedAt: this.now(),
7806
- revokedReason: reason
7807
- };
7808
- this.replace(session, updated);
7809
- this.announce([updated.id], session.userId, false, null, reason);
7810
- await this.persist(updated);
7811
- return true;
7812
- }
7813
- /**
7814
- * End every live session of `userId` except `exceptSessionId`, and move the
7815
- * user's legacy cutoff to now (pre-session tokens die too). Returns how many
7816
- * sessions ended.
7817
- */
7818
- async revokeAllForUser(userId, options) {
7819
- const at = this.now();
7820
- this.cutoffs.set(userId, at);
7821
- const ended = [];
7822
- for (const session of this.sessions.values()) {
7823
- if (session.userId !== userId || session.revokedAt !== null) continue;
7824
- if (session.id === options.exceptSessionId) continue;
7825
- const updated = {
7826
- ...session,
7827
- revokedAt: at,
7828
- revokedReason: options.reason
7829
- };
7830
- this.replace(session, updated);
7831
- ended.push(updated);
7832
- }
7833
- this.announce(ended.map((s) => s.id), userId, true, options.exceptSessionId ?? null, options.reason);
7834
- await this.persistCutoff(userId, at);
7835
- for (const session of ended) await this.persist(session);
7836
- return ended.length;
7837
- }
7838
- /** End every live session opened by device credential `credentialId`. Returns how many. */
7839
- async revokeByCredential(credentialId, reason = "credential-revoked") {
7840
- const at = this.now();
7841
- const ended = [];
7842
- for (const session of this.sessions.values()) {
7843
- if (session.credentialId !== credentialId || session.revokedAt !== null) continue;
7844
- const updated = {
7845
- ...session,
7846
- revokedAt: at,
7847
- revokedReason: reason
7848
- };
7849
- this.replace(session, updated);
7850
- ended.push(updated);
7851
- }
7852
- this.announce(ended.map((s) => s.id), null, false, null, reason);
7853
- for (const session of ended) await this.persist(session);
7854
- return ended.length;
7855
- }
7856
- /** `userId`'s live sessions, newest first. */
7857
- listForUser(userId) {
7858
- return this.listActive().filter((s) => s.userId === userId);
7859
- }
7860
- /** Every live session, newest first. */
7861
- listActive() {
7862
- const at = this.now();
7863
- return [...this.sessions.values()].filter((s) => s.revokedAt === null && s.expiresAt > at).toSorted((a, b) => b.createdAt - a.createdAt).map(toLoginSessionSummary);
7864
- }
7865
- /** Delete rows expired more than 7 days ago or revoked more than 30. Returns how many. */
7866
- async reap() {
7867
- const at = this.now();
7868
- 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);
7869
- for (const session of dead) {
7870
- this.forget(session);
7871
- const sessionsKind = this.sessionsKind;
7872
- if (sessionsKind !== null) await sessionsKind.delete(session.id);
7873
- else await this.store.delete.mutate({
7874
- collection: LOGIN_SESSIONS_COLLECTION,
7875
- key: session.id
7876
- });
7877
- }
7878
- return dead.length;
7879
- }
7880
- /** Tell the hub which sessions just ended — right after the mirror, before the store write. */
7881
- announce(sessionIds, userId, allOfUser, exceptSessionId, reason) {
7882
- announceSessionsRevoked({
7883
- sessionIds,
7884
- userId,
7885
- allOfUser,
7886
- exceptSessionId,
7887
- reason
7888
- }, (error) => this.logger?.error("A session-revocation listener threw — the revoke stands", { meta: { error: error instanceof Error ? error.message : String(error) } }));
7889
- }
7890
- newRefreshToken() {
7891
- return `${REFRESH_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
7892
- }
7893
- remember(session) {
7894
- this.sessions.set(session.id, session);
7895
- if (session.refreshHash !== null) this.byRefreshHash.set(session.refreshHash, session.id);
7896
- if (session.previousRefreshHash !== null) this.byRefreshHash.set(session.previousRefreshHash, session.id);
7897
- }
7898
- forget(session) {
7899
- this.sessions.delete(session.id);
7900
- if (session.refreshHash !== null) this.byRefreshHash.delete(session.refreshHash);
7901
- if (session.previousRefreshHash !== null) this.byRefreshHash.delete(session.previousRefreshHash);
7902
- }
7903
- replace(previous, next) {
7904
- this.forget(previous);
7905
- this.remember(next);
7906
- }
7907
- async persist(session) {
7908
- try {
7909
- const sessionsKind = this.sessionsKind;
7910
- if (sessionsKind !== null) {
7911
- const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
7912
- if (!parsed.success) throw new Error(parsed.error.message);
7913
- await sessionsKind.put({
7914
- id: session.id,
7915
- at: session.createdAt,
7916
- expiresAt: session.expiresAt,
7917
- data: parsed.data
7918
- });
7919
- } else await this.store.update.mutate({
7920
- collection: LOGIN_SESSIONS_COLLECTION,
7921
- id: session.id,
7922
- data: { ...session }
7923
- });
7924
- } catch (error) {
7925
- this.logger?.error("Login session write failed — the change holds until restart", { meta: {
7926
- sessionId: session.id,
7927
- error: error instanceof Error ? error.message : String(error)
7928
- } });
7929
- }
7930
- }
7931
- async persistCutoff(userId, notBefore) {
7932
- try {
7933
- const cutoffsKind = this.cutoffsKind;
7934
- if (cutoffsKind !== null) {
7935
- const parsed = localAuthRecordKinds["login-session-cutoffs"].schema.safeParse({ notBefore });
7936
- if (!parsed.success) throw new Error(parsed.error.message);
7937
- await cutoffsKind.put({
7938
- id: userId,
7939
- data: parsed.data
7940
- });
7941
- } else {
7942
- await this.store.delete.mutate({
7943
- collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
7944
- key: userId
7945
- });
7946
- await this.store.insert.mutate({
7947
- collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
7948
- record: {
7949
- id: userId,
7950
- data: {
7951
- userId,
7952
- notBefore
7953
- }
7954
- }
7955
- });
7956
- }
7957
- } catch (error) {
7958
- this.logger?.error("Login session cutoff write failed — it holds until restart", { meta: {
7959
- userId,
7960
- error: error instanceof Error ? error.message : String(error)
7961
- } });
7962
- }
7963
- }
7964
- };
7965
- //#endregion
7966
- //#region src/builtins/local-auth/auth-schema.ts
7967
- /**
7968
- * @durable class=config owner=local-auth
7969
- * write="an operator creates a user, or `ensureAdminExists` seeds the configured
7970
- * admin on first boot (or resets its password on the operator's one-shot
7971
- * RESET_ADMIN_PASSWORD trigger); updated on scope, isAdmin and password edits"
7972
- * retention="none — a row goes only when an operator deletes the user. A
7973
- * `declareCollection` that adds a PK/UNIQUE/NOT-NULL-without-default column
7974
- * REBUILDS the table: every account is wiped and only the configured admin
7975
- * comes back on the next boot."
7976
- */
7977
- var USERS_COLLECTION$1 = "users";
7978
- /**
7979
- * @durable class=config owner=local-auth
7980
- * write="an operator mints an API key; `validateToken` rewrites `lastUsedAt` on
7981
- * every request the key authorises"
7982
- * retention="none — an explicit `revoke` is the only delete. API keys have no
7983
- * expiry column and no sweep, so a key lives until someone removes it (or the
7984
- * destructive `declareCollection` rebuild takes the table)."
7985
- */
7986
- var API_KEYS_COLLECTION = "api_keys";
7987
- /**
7988
- * @durable class=config owner=local-auth
7989
- * write="an operator or the CLI mints a scoped token; `validate` fires
7990
- * `updateLastUsed` on every accepted call"
7991
- * retention="`revoke` deletes one. The daily sweep `reapExpired` drops rows past
7992
- * `expiresAt` — a NULL expiry is a deliberate never-expires token and BETWEEN
7993
- * never matches it. `cleanupOrphans` drops tokens owned by the legacy 'system'
7994
- * id or by a user that no longer exists."
7995
- */
7996
- var SCOPED_TOKENS_COLLECTION = "scoped_tokens";
7997
- /**
7998
- * @durable class=ledger owner=local-auth
7999
- * write="one row per redeemed OAuth code — every completed account link through
8000
- * `oauthExchangeCode`; `touch` rewrites `lastUsedAt` on each refresh"
8001
- * retention="ONLY revoked rows are pruned: the daily sweep deletes sessions
8002
- * revoked more than OAUTH_REVOKED_RETENTION_MS (30 d) ago. An ACTIVE session
8003
- * has `revokedAt` NULL, never expires and is never swept — the table grows one
8004
- * row per login until an operator revokes the link."
8005
- */
8006
- var OAUTH_SESSIONS_COLLECTION = "oauth_sessions";
8007
- /**
8008
- * TOTP enrollment table — split off from `users` to avoid the
8009
- * destructive drop-+-recreate migration `ensureTable` performs when a
8010
- * new column is added to a typed schema (see `sqlite-settings-backend.ts`).
8011
- *
8012
- * Lifecycle:
8013
- * - Setup: row inserted with `confirmedAt = null` and a fresh secret.
8014
- * - Confirm: a valid code from the secret flips `confirmedAt` to now().
8015
- * - Disable: the row is deleted (next setup starts fresh).
8016
- *
8017
- * "Enabled" means `confirmedAt != null`. A half-enrolled row (`confirmedAt
8018
- * = null`) is INACTIVE — `verifyTotp` returns false until confirmation.
8019
- *
8020
- * @durable class=config owner=local-auth
8021
- * write="`setup` inserts a keychain marker for the user's seed (D878; deleting any pending row
8022
- * first); `confirm` flips `confirmedAt` once a valid code proves possession"
8023
- * retention="`disable` deletes the row, and `setup` deletes before re-inserting.
8024
- * Nothing else prunes — deleting a USER does not cascade here, so their
8025
- * enrollment row outlives the account."
8026
- */
8027
- var USER_TOTP_COLLECTION = "user_totp";
8028
- /**
8029
- * External sign-in identities linked to local users (D782): one row per
8030
- * `(provider, subject)` — the row id IS the identity, so one identity maps to
8031
- * one user by primary key. Written only by the identity's owner completing a
8032
- * "Link" flow from their profile (bound to their session).
8033
- *
8034
- * @durable class=config owner=local-auth
8035
- * write="`ExternalIdentityManager.link` when a signed-in user completes a
8036
- * link flow; `unlink` from the profile or the Users page"
8037
- * retention="`unlink` deletes one row; deleting the user deletes all of its
8038
- * rows (`deleteUser` → `removeAllForUser`). A row whose provider instance
8039
- * was renamed or removed stays, inert, until someone unlinks it."
8040
- */
8041
- var USER_EXTERNAL_IDENTITIES_COLLECTION = "user_external_identities";
8042
- var USERS_COLUMNS = [
8043
- {
8044
- name: "id",
8045
- type: "TEXT",
8046
- primaryKey: true,
8047
- notNull: true
8048
- },
8049
- {
8050
- name: "username",
8051
- type: "TEXT",
8052
- notNull: true,
8053
- unique: true
8054
- },
8055
- {
8056
- name: "passwordHash",
8057
- type: "TEXT",
8058
- notNull: true
8059
- },
8060
- {
8061
- name: "isAdmin",
8062
- type: "BOOLEAN",
8063
- notNull: true
8064
- },
8065
- {
8066
- name: "allowedProviders",
8067
- type: "JSON"
8068
- },
8069
- {
8070
- name: "allowedDevices",
8071
- type: "JSON"
8072
- },
8073
- {
8074
- name: "scopes",
8075
- type: "JSON"
8076
- },
8077
- {
8078
- name: "createdAt",
8079
- type: "INTEGER",
8080
- notNull: true
8081
- },
8082
- {
8083
- name: "updatedAt",
8084
- type: "INTEGER",
8085
- notNull: true
8086
- }
8087
- ];
8088
- var API_KEYS_COLUMNS = [
8089
- {
8090
- name: "id",
8091
- type: "TEXT",
8092
- primaryKey: true,
8093
- notNull: true
8094
- },
8095
- {
8096
- name: "label",
8097
- type: "TEXT",
8098
- notNull: true
8099
- },
8100
- {
8101
- name: "isAdmin",
8102
- type: "BOOLEAN",
8103
- notNull: true
8104
- },
8105
- {
8106
- name: "tokenHash",
8107
- type: "TEXT",
8108
- notNull: true,
8109
- unique: true
8110
- },
8111
- {
8112
- name: "tokenPrefix",
8113
- type: "TEXT",
8114
- notNull: true
8115
- },
8116
- {
8117
- name: "allowedProviders",
8118
- type: "JSON"
8119
- },
8120
- {
8121
- name: "allowedDevices",
8122
- type: "JSON"
8123
- },
8124
- {
8125
- name: "createdAt",
8126
- type: "INTEGER",
8127
- notNull: true
8128
- },
8129
- {
8130
- name: "lastUsedAt",
8131
- type: "INTEGER"
8132
- }
8133
- ];
8134
- var USER_TOTP_COLUMNS = [
8135
- {
8136
- name: "userId",
8137
- type: "TEXT",
8138
- primaryKey: true,
8139
- notNull: true
8140
- },
8141
- {
8142
- name: "secret",
8143
- type: "TEXT",
8144
- notNull: true
8145
- },
8146
- {
8147
- name: "confirmedAt",
8148
- type: "INTEGER"
8149
- },
8150
- {
8151
- name: "createdAt",
8152
- type: "INTEGER",
8153
- notNull: true
8154
- }
8155
- ];
8156
- var USER_EXTERNAL_IDENTITIES_COLUMNS = [
8157
- {
8158
- name: "id",
8159
- type: "TEXT",
8160
- primaryKey: true,
8161
- notNull: true
8162
- },
8163
- {
8164
- name: "provider",
8165
- type: "TEXT",
8166
- notNull: true
8167
- },
8168
- {
8169
- name: "subject",
8170
- type: "TEXT",
8171
- notNull: true
8172
- },
8173
- {
8174
- name: "issuer",
8175
- type: "TEXT",
8176
- notNull: true
8177
- },
8178
- {
8179
- name: "userId",
8180
- type: "TEXT",
8181
- notNull: true
8182
- },
8183
- {
8184
- name: "label",
8185
- type: "TEXT"
8186
- },
8187
- {
8188
- name: "linkedAt",
8189
- type: "INTEGER",
8190
- notNull: true
8191
- }
8192
- ];
8193
- var SCOPED_TOKENS_COLUMNS = [
8194
- {
8195
- name: "id",
8196
- type: "TEXT",
8197
- primaryKey: true,
8198
- notNull: true
8199
- },
8200
- {
8201
- name: "userId",
8202
- type: "TEXT",
8203
- notNull: true
8204
- },
8205
- {
8206
- name: "name",
8207
- type: "TEXT",
8208
- notNull: true
8209
- },
8210
- {
8211
- name: "tokenHash",
8212
- type: "TEXT",
8213
- notNull: true,
8214
- unique: true
8215
- },
8216
- {
8217
- name: "tokenPrefix",
8218
- type: "TEXT",
8219
- notNull: true
8220
- },
8221
- {
8222
- name: "scopes",
8223
- type: "JSON"
8224
- },
8225
- {
8226
- name: "expiresAt",
8227
- type: "INTEGER"
8228
- },
8229
- {
8230
- name: "lastUsedAt",
8231
- type: "INTEGER"
8232
- },
8233
- {
8234
- name: "createdAt",
8235
- type: "INTEGER",
8236
- notNull: true
8237
- }
8238
- ];
8239
- var OAUTH_SESSIONS_COLUMNS = [
8240
- {
8241
- name: "id",
8242
- type: "TEXT",
8243
- primaryKey: true,
8244
- notNull: true
8245
- },
8246
- {
8247
- name: "userId",
8248
- type: "TEXT",
8249
- notNull: true
8250
- },
8251
- {
8252
- name: "username",
8253
- type: "TEXT",
8254
- notNull: true
8255
- },
8256
- {
8257
- name: "integrationId",
8258
- type: "TEXT",
8259
- notNull: true
8260
- },
8261
- {
8262
- name: "scopes",
8263
- type: "JSON"
8264
- },
8265
- {
8266
- name: "createdAt",
8267
- type: "INTEGER",
8268
- notNull: true
8269
- },
8270
- {
8271
- name: "lastUsedAt",
8272
- type: "INTEGER",
8273
- notNull: true
8274
- },
8275
- {
8276
- name: "revokedAt",
8277
- type: "INTEGER"
8278
- }
8279
- ];
8280
- /**
8281
- * Declare every auth collection with the typed schema. Called by
8282
- * `LocalAuthAddon.onInitialize` before constructing the per-collection
8283
- * managers. Idempotent — `declareCollection` is a no-op when the
8284
- * collection has already been declared (same shape).
7511
+ * Declare every auth collection with the typed schema. Called by
7512
+ * `LocalAuthAddon.onInitialize` before constructing the per-collection
7513
+ * managers. Idempotent — `declareCollection` is a no-op when the
7514
+ * collection has already been declared (same shape).
8285
7515
  */
8286
7516
  async function declareAuthSchema(store, options) {
8287
7517
  const skip = new Set(options?.skipCollections ?? []);
@@ -8371,7 +7601,10 @@ async function declareAuthSchema(store, options) {
8371
7601
  * use `storageAdmin.dropCollection` with `expectRows` after verify (see
8372
7602
  * `docs/design/records-phase-6-auth-migration.md`).
8373
7603
  *
8374
- * Before hub restart: mint and test an admin token (`csa_`); snapshot every kind.
7604
+ * Before hub restart: mint and test an admin token (`auth.mintToken` with `'*'`); snapshot every kind.
7605
+ *
7606
+ * `scoped-tokens` and `admin-tokens` hold pre-D892 rows read by nothing since
7607
+ * D895; they stay declared so the layout is unchanged.
8375
7608
  */
8376
7609
  var DAY_MS = 864e5;
8377
7610
  var UserBodySchema = require_dist.UserRecordSchema.omit({ id: true });
@@ -8480,7 +7713,7 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
8480
7713
  "scoped-tokens": {
8481
7714
  schema: ScopedTokenBodySchema,
8482
7715
  class: "config",
8483
- writeTrigger: "operator or CLI mints a scoped token; validate updates lastUsedAt",
7716
+ writeTrigger: "none since D895 — legacy rows kept on disk for the operator to drop",
8484
7717
  device: "none",
8485
7718
  at: "none",
8486
7719
  indexes: [{
@@ -8491,16 +7724,13 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
8491
7724
  name: "user",
8492
7725
  fields: ["userId"]
8493
7726
  }],
8494
- retention: {
8495
- owner: "local-auth",
8496
- reason: "daily reap of expired and orphan tokens; NULL expiresAt never matches BETWEEN"
8497
- },
7727
+ retention: { none: "the operator drops the legacy rows by hand (D895)" },
8498
7728
  adoptsFrom: SCOPED_TOKENS_COLLECTION
8499
7729
  },
8500
7730
  "admin-tokens": {
8501
7731
  schema: AdminTokenBodySchema,
8502
7732
  class: "config",
8503
- writeTrigger: "admin mints after re-auth; validate touches lastUsedAt; revoke stamps revokedAt",
7733
+ writeTrigger: "none since D895 — legacy rows kept on disk for the operator to drop",
8504
7734
  device: "none",
8505
7735
  at: "none",
8506
7736
  indexes: [{
@@ -8511,10 +7741,7 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
8511
7741
  name: "created_by",
8512
7742
  fields: ["createdBy"]
8513
7743
  }],
8514
- retention: {
8515
- owner: "local-auth",
8516
- reason: "daily reap deletes rows expired or revoked more than 30 days ago"
8517
- },
7744
+ retention: { none: "the operator drops the legacy rows by hand (D895)" },
8518
7745
  adoptsFrom: ADMIN_TOKENS_COLLECTION
8519
7746
  },
8520
7747
  "login-sessions": {
@@ -8626,8 +7853,8 @@ var _localAuthRecordKinds = require_dist.defineRecordKinds({
8626
7853
  var localAuthRecordKinds = _localAuthRecordKinds;
8627
7854
  var localAuthUsersRecordKind = _localAuthRecordKinds.users;
8628
7855
  var localAuthApiKeysRecordKind = _localAuthRecordKinds["api-keys"];
8629
- var localAuthScopedTokensRecordKind = _localAuthRecordKinds["scoped-tokens"];
8630
- var localAuthAdminTokensRecordKind = _localAuthRecordKinds["admin-tokens"];
7856
+ _localAuthRecordKinds["scoped-tokens"];
7857
+ _localAuthRecordKinds["admin-tokens"];
8631
7858
  var localAuthLoginSessionsRecordKind = _localAuthRecordKinds["login-sessions"];
8632
7859
  var localAuthLoginSessionCutoffsRecordKind = _localAuthRecordKinds["login-session-cutoffs"];
8633
7860
  var localAuthOauthSessionsRecordKind = _localAuthRecordKinds["oauth-sessions"];
@@ -8651,214 +7878,586 @@ var LOCAL_AUTH_RECORDS_LEGACY_COLLECTIONS = [
8651
7878
  ];
8652
7879
  function localAuthRecordStoresOf(records) {
8653
7880
  return {
8654
- users: records.kind(localAuthUsersRecordKind),
8655
- apiKeys: records.kind(localAuthApiKeysRecordKind),
8656
- scopedTokens: records.kind(localAuthScopedTokensRecordKind),
8657
- adminTokens: records.kind(localAuthAdminTokensRecordKind),
8658
- loginSessions: records.kind(localAuthLoginSessionsRecordKind),
8659
- loginSessionCutoffs: records.kind(localAuthLoginSessionCutoffsRecordKind),
8660
- oauthSessions: records.kind(localAuthOauthSessionsRecordKind),
8661
- userTotp: records.kind(localAuthUserTotpRecordKind),
8662
- userExternalIdentities: records.kind(localAuthUserExternalIdentitiesRecordKind),
8663
- deviceCredentials: records.kind(localAuthDeviceCredentialsRecordKind),
8664
- authAudit: records.kind(localAuthAuthAuditRecordKind)
7881
+ users: records.kind(localAuthUsersRecordKind),
7882
+ apiKeys: records.kind(localAuthApiKeysRecordKind),
7883
+ loginSessions: records.kind(localAuthLoginSessionsRecordKind),
7884
+ loginSessionCutoffs: records.kind(localAuthLoginSessionCutoffsRecordKind),
7885
+ oauthSessions: records.kind(localAuthOauthSessionsRecordKind),
7886
+ userTotp: records.kind(localAuthUserTotpRecordKind),
7887
+ userExternalIdentities: records.kind(localAuthUserExternalIdentitiesRecordKind),
7888
+ deviceCredentials: records.kind(localAuthDeviceCredentialsRecordKind),
7889
+ authAudit: records.kind(localAuthAuthAuditRecordKind)
7890
+ };
7891
+ }
7892
+ //#endregion
7893
+ //#region src/auth/session-revocation-bus.ts
7894
+ var BUS_KEY = Symbol.for("camstack.login-sessions.revocation-bus");
7895
+ function listeners() {
7896
+ const existing = Reflect.get(globalThis, BUS_KEY);
7897
+ if (existing instanceof Set) return existing;
7898
+ const created = /* @__PURE__ */ new Set();
7899
+ Reflect.set(globalThis, BUS_KEY, created);
7900
+ return created;
7901
+ }
7902
+ /** Subscribe; returns the unsubscribe. */
7903
+ function onSessionsRevoked(listener) {
7904
+ const set = listeners();
7905
+ set.add(listener);
7906
+ return () => {
7907
+ set.delete(listener);
7908
+ };
7909
+ }
7910
+ /** Announce an ended batch. A listener that throws never reaches the revoke. */
7911
+ function announceSessionsRevoked(event, onListenerError) {
7912
+ if (event.sessionIds.length === 0 && !event.allOfUser) return;
7913
+ for (const listener of [...listeners()]) try {
7914
+ listener(event);
7915
+ } catch (error) {
7916
+ onListenerError?.(error);
7917
+ }
7918
+ }
7919
+ //#endregion
7920
+ //#region src/auth/login-session-manager.ts
7921
+ /**
7922
+ * Login sessions — the server-side record behind every session token.
7923
+ *
7924
+ * A session token used to be a self-contained 30-day JWT: nothing on the hub
7925
+ * knew it existed, so logout revoked nothing, a password change left every
7926
+ * other device signed in, and a deleted user's sessions lived until they
7927
+ * expired. Now every login creates a row here and the token carries its id
7928
+ * (`sid`); the hub checks the row on every request (`check`, an in-memory
7929
+ * lookup), so revoking a row ends the session at once.
7930
+ *
7931
+ * Two kinds of client:
7932
+ * - **refreshable** — gets a short access token (default 2 h) and an opaque
7933
+ * refresh token (`csr_…`, stored hashed). `refresh` rotates the refresh
7934
+ * token and slides the session's expiry; presenting a rotated-out refresh
7935
+ * token after a short grace window is treated as theft and revokes the
7936
+ * whole session.
7937
+ * - **legacy** — a client from before refresh tokens: its access token lives
7938
+ * as long as the session, and it renews through the handoff-code pair
7939
+ * (`extend`). Kept for one release so installed viewer apps keep working.
7940
+ *
7941
+ * `legacyCutoff(userId)` covers tokens minted before sessions existed (no
7942
+ * `sid`): a token of that user issued before the cutoff is refused. Revoking
7943
+ * all of a user's sessions moves the cutoff, so a password change or a user
7944
+ * deletion also ends their pre-session tokens.
7945
+ *
7946
+ * Mirror: every row is held in memory (a homelab hub has tens of sessions) and
7947
+ * written through to the store; revocations update the mirror FIRST, so a
7948
+ * store hiccup can delay persistence but never the revocation itself.
7949
+ */
7950
+ /**
7951
+ * @durable class=ledger owner=local-auth
7952
+ * write="one row per login (password, passkey, SSO, handoff, session exchange);
7953
+ * rewritten on every refresh / renewal (expiry slides, refresh hash rotates)
7954
+ * and on revocation"
7955
+ * retention="the daily reap deletes rows expired more than 7 days ago and rows
7956
+ * revoked more than 30 days ago. A session the user keeps refreshing lives as
7957
+ * long as it is used."
7958
+ */
7959
+ var LOGIN_SESSIONS_COLLECTION = "login_sessions";
7960
+ /**
7961
+ * @durable class=config owner=local-auth
7962
+ * write="revoking all of a user's sessions (logout everywhere, password change or
7963
+ * reset, user deletion) sets that user's cutoff to now"
7964
+ * retention="none — one row per user who ever had all sessions revoked; it only
7965
+ * ever matters for tokens minted before sessions existed, and is harmless after."
7966
+ */
7967
+ var LOGIN_SESSION_CUTOFFS_COLLECTION = "login_session_cutoffs";
7968
+ var LOGIN_SESSIONS_COLUMNS = [
7969
+ {
7970
+ name: "id",
7971
+ type: "TEXT",
7972
+ primaryKey: true,
7973
+ notNull: true
7974
+ },
7975
+ {
7976
+ name: "userId",
7977
+ type: "TEXT",
7978
+ notNull: true
7979
+ },
7980
+ {
7981
+ name: "method",
7982
+ type: "TEXT",
7983
+ notNull: true
7984
+ },
7985
+ {
7986
+ name: "refreshable",
7987
+ type: "BOOLEAN",
7988
+ notNull: true
7989
+ },
7990
+ {
7991
+ name: "createdAt",
7992
+ type: "INTEGER",
7993
+ notNull: true
7994
+ },
7995
+ {
7996
+ name: "lastUsedAt",
7997
+ type: "INTEGER",
7998
+ notNull: true
7999
+ },
8000
+ {
8001
+ name: "expiresAt",
8002
+ type: "INTEGER",
8003
+ notNull: true
8004
+ },
8005
+ {
8006
+ name: "revokedAt",
8007
+ type: "INTEGER"
8008
+ },
8009
+ {
8010
+ name: "revokedReason",
8011
+ type: "TEXT"
8012
+ },
8013
+ {
8014
+ name: "refreshHash",
8015
+ type: "TEXT"
8016
+ },
8017
+ {
8018
+ name: "previousRefreshHash",
8019
+ type: "TEXT"
8020
+ },
8021
+ {
8022
+ name: "rotatedAt",
8023
+ type: "INTEGER"
8024
+ },
8025
+ {
8026
+ name: "client",
8027
+ type: "TEXT"
8028
+ },
8029
+ {
8030
+ name: "ip",
8031
+ type: "TEXT"
8032
+ },
8033
+ {
8034
+ name: "credentialId",
8035
+ type: "TEXT"
8036
+ }
8037
+ ];
8038
+ var LOGIN_SESSION_CUTOFFS_COLUMNS = [{
8039
+ name: "userId",
8040
+ type: "TEXT",
8041
+ primaryKey: true,
8042
+ notNull: true
8043
+ }, {
8044
+ name: "notBefore",
8045
+ type: "INTEGER",
8046
+ notNull: true
8047
+ }];
8048
+ var METHODS = [
8049
+ "password",
8050
+ "passkey",
8051
+ "sso",
8052
+ "handoff",
8053
+ "exchange",
8054
+ "device-certificate"
8055
+ ];
8056
+ var REFRESH_TOKEN_PREFIX = "csr_";
8057
+ /**
8058
+ * A rotated-out refresh token is still honoured this long: two tabs (or a
8059
+ * retry after a dropped response) may present the same token a moment apart.
8060
+ * After it, the old token is evidence of a copy and ends the session.
8061
+ */
8062
+ var REFRESH_REUSE_GRACE_MS = 3e4;
8063
+ var EXPIRED_RETENTION_MS = 7 * 864e5;
8064
+ var REVOKED_RETENTION_MS = 30 * 864e5;
8065
+ /** Longest client label kept (a User-Agent can be huge). */
8066
+ var MAX_CLIENT_LABEL = 200;
8067
+ function hashToken(token) {
8068
+ return node_crypto.createHash("sha256").update(token).digest("hex");
8069
+ }
8070
+ function str(data, key) {
8071
+ const value = data[key];
8072
+ return typeof value === "string" && value.length > 0 ? value : null;
8073
+ }
8074
+ function num(data, key) {
8075
+ const value = data[key];
8076
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
8077
+ }
8078
+ function parseSession$1(data) {
8079
+ const id = str(data, "id");
8080
+ const userId = str(data, "userId");
8081
+ const createdAt = num(data, "createdAt");
8082
+ const lastUsedAt = num(data, "lastUsedAt");
8083
+ const expiresAt = num(data, "expiresAt");
8084
+ const method = METHODS.find((m) => m === data["method"]);
8085
+ if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
8086
+ return {
8087
+ id,
8088
+ userId,
8089
+ method: method ?? "password",
8090
+ refreshable: data["refreshable"] === true || data["refreshable"] === 1,
8091
+ createdAt,
8092
+ lastUsedAt: lastUsedAt ?? createdAt,
8093
+ expiresAt,
8094
+ revokedAt: num(data, "revokedAt"),
8095
+ revokedReason: str(data, "revokedReason"),
8096
+ refreshHash: str(data, "refreshHash"),
8097
+ previousRefreshHash: str(data, "previousRefreshHash"),
8098
+ rotatedAt: num(data, "rotatedAt"),
8099
+ client: str(data, "client"),
8100
+ ip: str(data, "ip"),
8101
+ credentialId: str(data, "credentialId")
8665
8102
  };
8666
8103
  }
8667
- //#endregion
8668
- //#region src/auth/scoped-token-manager.ts
8669
- var TOKENS_COLLECTION = "scoped_tokens";
8670
- var TOKEN_PREFIX = "cst_";
8671
- function parseToken(data) {
8672
- return parseRecord$2("scoped-token", require_dist.ScopedTokenSchema, data);
8673
- }
8674
- function tokenToBody(token) {
8675
- const { id: _id, ...body } = token;
8676
- return body;
8104
+ function toLoginSessionSummary(record) {
8105
+ const { refreshHash: _r, previousRefreshHash: _p, rotatedAt: _t, ...summary } = record;
8106
+ return summary;
8677
8107
  }
8678
- function rowToToken(id, body) {
8679
- return parseToken({
8680
- id,
8681
- ...body
8682
- });
8108
+ function sessionToBody$1(session) {
8109
+ const { id: _id, ...body } = session;
8110
+ return body;
8683
8111
  }
8684
- /**
8685
- * Manages scoped API tokens with restricted addon/route/capability access.
8686
- */
8687
- var ScopedTokenManager = class {
8688
- kind;
8112
+ var LoginSessionManager = class {
8113
+ sessions = /* @__PURE__ */ new Map();
8114
+ /** refresh hash (current or previous) → session id */
8115
+ byRefreshHash = /* @__PURE__ */ new Map();
8116
+ cutoffs = /* @__PURE__ */ new Map();
8117
+ now;
8118
+ logger;
8119
+ sessionsKind;
8120
+ cutoffsKind;
8689
8121
  store;
8690
- constructor(deps) {
8691
- if ("scopedTokensKind" in deps) {
8692
- this.kind = deps.scopedTokensKind;
8122
+ constructor(deps, options = {}) {
8123
+ if ("sessionsKind" in deps) {
8124
+ this.sessionsKind = deps.sessionsKind;
8125
+ this.cutoffsKind = deps.cutoffsKind;
8693
8126
  this.store = deps.legacyStore ?? null;
8694
8127
  } else {
8695
- this.kind = null;
8128
+ this.sessionsKind = null;
8129
+ this.cutoffsKind = null;
8696
8130
  this.store = deps;
8697
8131
  }
8132
+ this.now = options.now ?? Date.now;
8133
+ this.logger = options.logger ?? null;
8134
+ }
8135
+ /** Load every row into the mirror. Call once at init, before serving. */
8136
+ async load() {
8137
+ const sessionsKind = this.sessionsKind;
8138
+ if (sessionsKind !== null) {
8139
+ const rows = await (0, import_read_all_rows.readAllRows)(sessionsKind);
8140
+ for (const row of rows) {
8141
+ const record = parseSession$1({
8142
+ id: row.id,
8143
+ ...row.data
8144
+ });
8145
+ if (record !== null) this.remember(record);
8146
+ }
8147
+ const cutoffs = await (0, import_read_all_rows.readAllRows)(this.cutoffsKind);
8148
+ for (const row of cutoffs) this.cutoffs.set(row.id, row.data.notBefore);
8149
+ return;
8150
+ }
8151
+ const rows = await this.store.query.query({ collection: LOGIN_SESSIONS_COLLECTION });
8152
+ for (const row of rows) {
8153
+ const record = parseSession$1(row.data);
8154
+ if (record !== null) this.remember(record);
8155
+ }
8156
+ const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
8157
+ for (const row of cutoffs) {
8158
+ const userId = str(row.data, "userId");
8159
+ const notBefore = num(row.data, "notBefore");
8160
+ if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
8161
+ }
8162
+ }
8163
+ async create(input) {
8164
+ const at = this.now();
8165
+ const refreshToken = input.refreshable ? this.newRefreshToken() : null;
8166
+ const session = {
8167
+ id: node_crypto.randomUUID(),
8168
+ userId: input.userId,
8169
+ method: input.method,
8170
+ refreshable: input.refreshable,
8171
+ createdAt: at,
8172
+ lastUsedAt: at,
8173
+ expiresAt: at + input.lifetimeMs,
8174
+ revokedAt: null,
8175
+ revokedReason: null,
8176
+ refreshHash: refreshToken === null ? null : hashToken(refreshToken),
8177
+ previousRefreshHash: null,
8178
+ rotatedAt: null,
8179
+ client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
8180
+ ip: input.ip ?? null,
8181
+ credentialId: input.credentialId ?? null
8182
+ };
8183
+ const sessionsKind = this.sessionsKind;
8184
+ if (sessionsKind !== null) {
8185
+ const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
8186
+ if (!parsed.success) throw new Error(parsed.error.message);
8187
+ await sessionsKind.put({
8188
+ id: session.id,
8189
+ at: session.createdAt,
8190
+ expiresAt: session.expiresAt,
8191
+ data: parsed.data
8192
+ });
8193
+ } else await this.store.insert.mutate({
8194
+ collection: LOGIN_SESSIONS_COLLECTION,
8195
+ record: {
8196
+ id: session.id,
8197
+ data: { ...session }
8198
+ }
8199
+ });
8200
+ this.remember(session);
8201
+ return {
8202
+ session,
8203
+ refreshToken
8204
+ };
8205
+ }
8206
+ /** Is `sessionId` a live session right now? Synchronous: the hub asks on every request. */
8207
+ check(sessionId) {
8208
+ const session = this.sessions.get(sessionId);
8209
+ if (session === void 0) return { state: "unknown" };
8210
+ if (session.revokedAt !== null) return { state: "revoked" };
8211
+ if (session.expiresAt <= this.now()) return { state: "expired" };
8212
+ return {
8213
+ state: "active",
8214
+ session
8215
+ };
8216
+ }
8217
+ /** Epoch ms before which a pre-session (no `sid`) token of `userId` is refused; 0 = none. */
8218
+ legacyCutoff(userId) {
8219
+ return this.cutoffs.get(userId) ?? 0;
8220
+ }
8221
+ /**
8222
+ * The session a refresh token belongs to, WITHOUT rotating it - so the
8223
+ * refresh path can judge the session (its device credential) before it
8224
+ * spends the token. `null` when the token names no session.
8225
+ */
8226
+ peekByRefreshToken(refreshToken) {
8227
+ if (!refreshToken.startsWith("csr_")) return null;
8228
+ const sessionId = this.byRefreshHash.get(hashToken(refreshToken));
8229
+ return sessionId === void 0 ? null : this.sessions.get(sessionId) ?? null;
8230
+ }
8231
+ /** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
8232
+ async refresh(refreshToken, lifetimeMs) {
8233
+ if (!refreshToken.startsWith("csr_")) return {
8234
+ ok: false,
8235
+ reason: "unknown"
8236
+ };
8237
+ const presented = hashToken(refreshToken);
8238
+ const sessionId = this.byRefreshHash.get(presented);
8239
+ const session = sessionId === void 0 ? void 0 : this.sessions.get(sessionId);
8240
+ if (session === void 0) return {
8241
+ ok: false,
8242
+ reason: "unknown"
8243
+ };
8244
+ if (session.revokedAt !== null) return {
8245
+ ok: false,
8246
+ reason: "revoked"
8247
+ };
8248
+ const at = this.now();
8249
+ if (session.expiresAt <= at) return {
8250
+ ok: false,
8251
+ reason: "expired"
8252
+ };
8253
+ if (presented !== session.refreshHash) {
8254
+ if (!(presented === session.previousRefreshHash && session.rotatedAt !== null && at - session.rotatedAt <= REFRESH_REUSE_GRACE_MS)) {
8255
+ await this.revoke(session.id, "refresh-reuse");
8256
+ this.logger?.warn("A rotated-out refresh token was presented — session revoked", { meta: {
8257
+ sessionId: session.id,
8258
+ userId: session.userId
8259
+ } });
8260
+ return {
8261
+ ok: false,
8262
+ reason: "reused"
8263
+ };
8264
+ }
8265
+ }
8266
+ const next = this.newRefreshToken();
8267
+ const updated = {
8268
+ ...session,
8269
+ lastUsedAt: at,
8270
+ expiresAt: at + lifetimeMs,
8271
+ previousRefreshHash: session.refreshHash,
8272
+ refreshHash: hashToken(next),
8273
+ rotatedAt: at
8274
+ };
8275
+ this.replace(session, updated);
8276
+ await this.persist(updated);
8277
+ return {
8278
+ ok: true,
8279
+ session: updated,
8280
+ refreshToken: next
8281
+ };
8282
+ }
8283
+ /** Renew an active session (legacy client): slide its expiry. `null` when it is not active. */
8284
+ async extend(sessionId, lifetimeMs) {
8285
+ const check = this.check(sessionId);
8286
+ if (check.state !== "active") return null;
8287
+ const at = this.now();
8288
+ const updated = {
8289
+ ...check.session,
8290
+ lastUsedAt: at,
8291
+ expiresAt: at + lifetimeMs
8292
+ };
8293
+ this.replace(check.session, updated);
8294
+ await this.persist(updated);
8295
+ return updated;
8296
+ }
8297
+ /** End one session. `false` when it was unknown or already ended. */
8298
+ async revoke(sessionId, reason) {
8299
+ const session = this.sessions.get(sessionId);
8300
+ if (session === void 0 || session.revokedAt !== null) return false;
8301
+ const updated = {
8302
+ ...session,
8303
+ revokedAt: this.now(),
8304
+ revokedReason: reason
8305
+ };
8306
+ this.replace(session, updated);
8307
+ this.announce([updated.id], session.userId, false, null, reason);
8308
+ await this.persist(updated);
8309
+ return true;
8310
+ }
8311
+ /**
8312
+ * End every live session of `userId` except `exceptSessionId`, and move the
8313
+ * user's legacy cutoff to now (pre-session tokens die too). Returns how many
8314
+ * sessions ended.
8315
+ */
8316
+ async revokeAllForUser(userId, options) {
8317
+ const at = this.now();
8318
+ this.cutoffs.set(userId, at);
8319
+ const ended = [];
8320
+ for (const session of this.sessions.values()) {
8321
+ if (session.userId !== userId || session.revokedAt !== null) continue;
8322
+ if (session.id === options.exceptSessionId) continue;
8323
+ const updated = {
8324
+ ...session,
8325
+ revokedAt: at,
8326
+ revokedReason: options.reason
8327
+ };
8328
+ this.replace(session, updated);
8329
+ ended.push(updated);
8330
+ }
8331
+ this.announce(ended.map((s) => s.id), userId, true, options.exceptSessionId ?? null, options.reason);
8332
+ await this.persistCutoff(userId, at);
8333
+ for (const session of ended) await this.persist(session);
8334
+ return ended.length;
8698
8335
  }
8699
- async create(userId, name, scopes, expiresAt) {
8700
- const rawToken = `${TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("hex")}`;
8701
- const tokenHash = node_crypto.createHash("sha256").update(rawToken).digest("hex");
8702
- const tokenPrefix = rawToken.slice(0, 12);
8703
- const record = {
8704
- id: node_crypto.randomUUID(),
8705
- userId,
8706
- name,
8707
- tokenHash,
8708
- tokenPrefix,
8709
- scopes: scopes.map((s) => ({ ...s })),
8710
- expiresAt,
8711
- lastUsedAt: void 0,
8712
- createdAt: Date.now()
8713
- };
8714
- const kind = this.kind;
8715
- if (kind !== null) {
8716
- const body = tokenToBody(record);
8717
- const parsed = localAuthRecordKinds["scoped-tokens"].schema.safeParse(body);
8718
- if (!parsed.success) throw new Error(parsed.error.message);
8719
- await kind.put({
8720
- id: record.id,
8721
- expiresAt: record.expiresAt ?? void 0,
8722
- data: parsed.data
8723
- });
8724
- return {
8725
- token: rawToken,
8726
- record
8336
+ /** End every live session opened by device credential `credentialId`. Returns how many. */
8337
+ async revokeByCredential(credentialId, reason = "credential-revoked") {
8338
+ const at = this.now();
8339
+ const ended = [];
8340
+ for (const session of this.sessions.values()) {
8341
+ if (session.credentialId !== credentialId || session.revokedAt !== null) continue;
8342
+ const updated = {
8343
+ ...session,
8344
+ revokedAt: at,
8345
+ revokedReason: reason
8727
8346
  };
8347
+ this.replace(session, updated);
8348
+ ended.push(updated);
8728
8349
  }
8729
- await this.store.insert.mutate({
8730
- collection: TOKENS_COLLECTION,
8731
- record: {
8732
- id: record.id,
8733
- data: { ...record }
8734
- }
8735
- });
8736
- return {
8737
- token: rawToken,
8738
- record
8739
- };
8350
+ this.announce(ended.map((s) => s.id), null, false, null, reason);
8351
+ for (const session of ended) await this.persist(session);
8352
+ return ended.length;
8740
8353
  }
8741
- async validate(rawToken) {
8742
- if (!rawToken.startsWith(TOKEN_PREFIX)) return null;
8743
- const tokenHash = node_crypto.createHash("sha256").update(rawToken).digest("hex");
8744
- const kind = this.kind;
8745
- let record = null;
8746
- if (kind !== null) {
8747
- const row = (await kind.query({
8748
- where: { tokenHash },
8749
- limit: 1
8750
- })).records[0];
8751
- record = row === void 0 ? null : rowToToken(row.id, row.data);
8752
- } else {
8753
- const results = await this.store.query.query({
8754
- collection: TOKENS_COLLECTION,
8755
- filter: { where: { tokenHash } }
8354
+ /** `userId`'s live sessions, newest first. */
8355
+ listForUser(userId) {
8356
+ return this.listActive().filter((s) => s.userId === userId);
8357
+ }
8358
+ /** Every live session, newest first. */
8359
+ listActive() {
8360
+ const at = this.now();
8361
+ return [...this.sessions.values()].filter((s) => s.revokedAt === null && s.expiresAt > at).toSorted((a, b) => b.createdAt - a.createdAt).map(toLoginSessionSummary);
8362
+ }
8363
+ /** Delete rows expired more than 7 days ago or revoked more than 30. Returns how many. */
8364
+ async reap() {
8365
+ const at = this.now();
8366
+ 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);
8367
+ for (const session of dead) {
8368
+ this.forget(session);
8369
+ const sessionsKind = this.sessionsKind;
8370
+ if (sessionsKind !== null) await sessionsKind.delete(session.id);
8371
+ else await this.store.delete.mutate({
8372
+ collection: LOGIN_SESSIONS_COLLECTION,
8373
+ key: session.id
8756
8374
  });
8757
- record = results.length === 0 ? null : parseToken(results[0].data);
8758
8375
  }
8759
- if (record === null) return null;
8760
- if (record.expiresAt !== void 0 && record.expiresAt !== null && Date.now() > record.expiresAt) return null;
8761
- this.updateLastUsed(record.id).catch(() => {});
8762
- return record;
8376
+ return dead.length;
8763
8377
  }
8764
- matchesScope(token, addonId, capability) {
8765
- for (const scope of token.scopes) switch (scope.type) {
8766
- case "addon":
8767
- if (addonId && scope.target === addonId) return true;
8768
- break;
8769
- case "capability":
8770
- if (capability && scope.target === capability) return true;
8771
- break;
8772
- }
8773
- return false;
8378
+ /** Tell the hub which sessions just ended — right after the mirror, before the store write. */
8379
+ announce(sessionIds, userId, allOfUser, exceptSessionId, reason) {
8380
+ announceSessionsRevoked({
8381
+ sessionIds,
8382
+ userId,
8383
+ allOfUser,
8384
+ exceptSessionId,
8385
+ reason
8386
+ }, (error) => this.logger?.error("A session-revocation listener threw — the revoke stands", { meta: { error: error instanceof Error ? error.message : String(error) } }));
8774
8387
  }
8775
- async revoke(tokenId) {
8776
- const kind = this.kind;
8777
- if (kind !== null) {
8778
- if (await kind.get(tokenId) === null) return false;
8779
- await kind.delete(tokenId);
8780
- return true;
8781
- }
8782
- if ((await this.store.query.query({
8783
- collection: TOKENS_COLLECTION,
8784
- filter: { where: { id: tokenId } }
8785
- })).length === 0) return false;
8786
- await this.store.delete.mutate({
8787
- collection: TOKENS_COLLECTION,
8788
- key: tokenId
8789
- });
8790
- return true;
8388
+ newRefreshToken() {
8389
+ return `${REFRESH_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
8791
8390
  }
8792
- async listForUser(userId) {
8793
- const kind = this.kind;
8794
- if (kind !== null) return (await (0, import_read_all_rows.readAllRows)(kind, { where: { userId } })).map((row) => rowToToken(row.id, row.data));
8795
- return (await this.store.query.query({
8796
- collection: TOKENS_COLLECTION,
8797
- filter: { where: { userId } }
8798
- })).map((r) => parseToken(r.data));
8391
+ remember(session) {
8392
+ this.sessions.set(session.id, session);
8393
+ if (session.refreshHash !== null) this.byRefreshHash.set(session.refreshHash, session.id);
8394
+ if (session.previousRefreshHash !== null) this.byRefreshHash.set(session.previousRefreshHash, session.id);
8799
8395
  }
8800
- async updateLastUsed(tokenId) {
8801
- const kind = this.kind;
8802
- if (kind !== null) {
8803
- const row = await kind.get(tokenId);
8804
- if (row === null) return;
8805
- const next = {
8806
- ...rowToToken(row.id, row.data),
8807
- lastUsedAt: Date.now()
8808
- };
8809
- const body = tokenToBody(next);
8810
- const parsed = localAuthRecordKinds["scoped-tokens"].schema.safeParse(body);
8811
- if (!parsed.success) return;
8812
- await kind.put({
8813
- id: tokenId,
8814
- expiresAt: next.expiresAt ?? void 0,
8815
- data: parsed.data
8816
- });
8817
- return;
8818
- }
8819
- const results = await this.store.query.query({
8820
- collection: TOKENS_COLLECTION,
8821
- filter: { where: { id: tokenId } }
8822
- });
8823
- if (results.length === 0) return;
8824
- const existing = parseToken(results[0].data);
8825
- await this.store.update.mutate({
8826
- collection: TOKENS_COLLECTION,
8827
- id: tokenId,
8828
- data: {
8829
- ...existing,
8830
- lastUsedAt: Date.now()
8831
- }
8832
- });
8396
+ forget(session) {
8397
+ this.sessions.delete(session.id);
8398
+ if (session.refreshHash !== null) this.byRefreshHash.delete(session.refreshHash);
8399
+ if (session.previousRefreshHash !== null) this.byRefreshHash.delete(session.previousRefreshHash);
8833
8400
  }
8834
- async reapExpired(nowMs = Date.now()) {
8835
- const kind = this.kind;
8836
- if (kind !== null) return kind.deleteWhere({ where: { expiresAt: {
8837
- gte: 1,
8838
- lte: nowMs - 1
8839
- } } });
8840
- const { deleted } = await this.store.deleteWhere.mutate({
8841
- collection: TOKENS_COLLECTION,
8842
- filter: { whereBetween: { expiresAt: [1, nowMs - 1] } }
8843
- });
8844
- return deleted;
8401
+ replace(previous, next) {
8402
+ this.forget(previous);
8403
+ this.remember(next);
8845
8404
  }
8846
- async cleanupOrphans(validUserIds) {
8847
- const kind = this.kind;
8848
- const all = kind !== null ? (await (0, import_read_all_rows.readAllRows)(kind)).map((row) => rowToToken(row.id, row.data)) : (await this.store.query.query({
8849
- collection: TOKENS_COLLECTION,
8850
- filter: {}
8851
- })).map((e) => parseToken(e.data));
8852
- let removed = 0;
8853
- for (const record of all) if (record.userId === "system" || !validUserIds.has(record.userId)) {
8854
- if (kind !== null) await kind.delete(record.id);
8855
- else await this.store.delete.mutate({
8856
- collection: TOKENS_COLLECTION,
8857
- key: record.id
8405
+ async persist(session) {
8406
+ try {
8407
+ const sessionsKind = this.sessionsKind;
8408
+ if (sessionsKind !== null) {
8409
+ const parsed = localAuthRecordKinds["login-sessions"].schema.safeParse(sessionToBody$1(session));
8410
+ if (!parsed.success) throw new Error(parsed.error.message);
8411
+ await sessionsKind.put({
8412
+ id: session.id,
8413
+ at: session.createdAt,
8414
+ expiresAt: session.expiresAt,
8415
+ data: parsed.data
8416
+ });
8417
+ } else await this.store.update.mutate({
8418
+ collection: LOGIN_SESSIONS_COLLECTION,
8419
+ id: session.id,
8420
+ data: { ...session }
8858
8421
  });
8859
- removed++;
8422
+ } catch (error) {
8423
+ this.logger?.error("Login session write failed — the change holds until restart", { meta: {
8424
+ sessionId: session.id,
8425
+ error: error instanceof Error ? error.message : String(error)
8426
+ } });
8427
+ }
8428
+ }
8429
+ async persistCutoff(userId, notBefore) {
8430
+ try {
8431
+ const cutoffsKind = this.cutoffsKind;
8432
+ if (cutoffsKind !== null) {
8433
+ const parsed = localAuthRecordKinds["login-session-cutoffs"].schema.safeParse({ notBefore });
8434
+ if (!parsed.success) throw new Error(parsed.error.message);
8435
+ await cutoffsKind.put({
8436
+ id: userId,
8437
+ data: parsed.data
8438
+ });
8439
+ } else {
8440
+ await this.store.delete.mutate({
8441
+ collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
8442
+ key: userId
8443
+ });
8444
+ await this.store.insert.mutate({
8445
+ collection: LOGIN_SESSION_CUTOFFS_COLLECTION,
8446
+ record: {
8447
+ id: userId,
8448
+ data: {
8449
+ userId,
8450
+ notBefore
8451
+ }
8452
+ }
8453
+ });
8454
+ }
8455
+ } catch (error) {
8456
+ this.logger?.error("Login session cutoff write failed — it holds until restart", { meta: {
8457
+ userId,
8458
+ error: error instanceof Error ? error.message : String(error)
8459
+ } });
8860
8460
  }
8861
- return removed;
8862
8461
  }
8863
8462
  };
8864
8463
  //#endregion
@@ -8973,6 +8572,212 @@ var DeviceCaStore = class {
8973
8572
  return this.loaded;
8974
8573
  }
8975
8574
  };
8575
+ var UserPreferenceBodySchema = zod.z.object({
8576
+ userId: zod.z.string().min(1),
8577
+ namespace: require_dist.PreferenceNamespaceSchema,
8578
+ value: require_dist.PreferenceValueSchema,
8579
+ revision: zod.z.number().int().positive(),
8580
+ updatedAt: zod.z.number().int(),
8581
+ updatedByDevice: require_dist.PreferenceDeviceIdSchema.nullable()
8582
+ });
8583
+ var localAuthPreferencesRecordKinds = require_dist.defineRecordKinds({ "user-preferences": {
8584
+ schema: UserPreferenceBodySchema,
8585
+ class: "config",
8586
+ writeTrigger: "a signed-in user saves one of their account preference namespaces (preferences.put) or deletes it",
8587
+ device: "none",
8588
+ at: "none",
8589
+ indexes: [{
8590
+ name: "user",
8591
+ fields: ["userId"]
8592
+ }],
8593
+ retention: { none: "a row goes when its user deletes the namespace, or with the user (deleteUser wipes every row of theirs)" },
8594
+ maxDataBytes: require_dist.PREFERENCES_MAX_VALUE_BYTES + 4 * 1024
8595
+ } });
8596
+ /** Namespaces cannot hold `:`, so the id splits unambiguously. */
8597
+ function userPreferenceRecordId(userId, namespace) {
8598
+ return `${userId}:${namespace}`;
8599
+ }
8600
+ //#endregion
8601
+ //#region src/builtins/local-auth/user-preferences-store.ts
8602
+ /**
8603
+ * Per-user account preferences (D894): one row per `(userId, namespace)`.
8604
+ *
8605
+ * Every method takes the OWNER's id as its first argument and touches only
8606
+ * that user's rows; the hub router stamps it from the principal, and nothing
8607
+ * here accepts a second user id.
8608
+ *
8609
+ * The poll (`changedSince`) is answered from an in-memory mirror of each
8610
+ * user's `namespace → revision`, loaded once on first touch. This store is the
8611
+ * kind's only writer (one in-process instance in `local-auth`), so the mirror
8612
+ * cannot fall behind the rows; a poll that finds nothing changed reads no row.
8613
+ * Writes of one user are serialized, so a read-modify-write never interleaves.
8614
+ */
8615
+ /** A write the limits refuse; nothing was stored. */
8616
+ var PreferencesLimitError = class extends Error {
8617
+ reason;
8618
+ constructor(reason, message) {
8619
+ super(message);
8620
+ this.reason = reason;
8621
+ this.name = "PreferencesLimitError";
8622
+ }
8623
+ };
8624
+ /**
8625
+ * The limit refusal behind `error`, or `null`. By name and field, not
8626
+ * `instanceof`: the store and its caller may load different copies of this
8627
+ * module.
8628
+ */
8629
+ function preferencesLimitReasonOf(error) {
8630
+ if (!(error instanceof Error) || error.name !== "PreferencesLimitError") return null;
8631
+ const reason = Reflect.get(error, "reason");
8632
+ return reason === "value-too-large" || reason === "too-many-namespaces" ? reason : null;
8633
+ }
8634
+ /** The page size of a per-user scan; one page covers a user within the limit. */
8635
+ var USER_SCAN_PAGE = 40;
8636
+ /** The page size of the boot orphan sweep over every user's rows. */
8637
+ var ORPHAN_SCAN_PAGE = 500;
8638
+ function toRow(body) {
8639
+ return {
8640
+ namespace: body.namespace,
8641
+ value: body.value,
8642
+ revision: body.revision,
8643
+ updatedAt: body.updatedAt,
8644
+ updatedByDevice: body.updatedByDevice
8645
+ };
8646
+ }
8647
+ function byNamespace(a, b) {
8648
+ return a.namespace < b.namespace ? -1 : a.namespace > b.namespace ? 1 : 0;
8649
+ }
8650
+ var UserPreferencesStore = class {
8651
+ kind;
8652
+ now;
8653
+ /** `userId → namespace → revision`; absent until the user is first touched. */
8654
+ mirrors = /* @__PURE__ */ new Map();
8655
+ locks = /* @__PURE__ */ new Map();
8656
+ constructor(kind, options = {}) {
8657
+ this.kind = kind;
8658
+ this.now = options.now ?? Date.now;
8659
+ }
8660
+ /** The caller's rows; `namespaces` narrows them (unknown ones are simply absent). */
8661
+ async get(userId, namespaces) {
8662
+ if (namespaces === void 0) return (await this.scanUser(userId)).map((r) => toRow(r.data)).toSorted(byNamespace);
8663
+ return (await this.kind.getMany([...new Set(namespaces)].map((ns) => userPreferenceRecordId(userId, ns)))).filter((r) => r.data.userId === userId).map((r) => toRow(r.data)).toSorted(byNamespace);
8664
+ }
8665
+ async put(userId, input) {
8666
+ const bytes = Buffer.byteLength(JSON.stringify(input.value), "utf8");
8667
+ if (bytes > 262144) throw new PreferencesLimitError("value-too-large", `preferences: ${input.namespace} is ${bytes} bytes, the limit is ${require_dist.PREFERENCES_MAX_VALUE_BYTES}`);
8668
+ return this.serialized(userId, async () => {
8669
+ const mirror = await this.mirrorOf(userId);
8670
+ const id = userPreferenceRecordId(userId, input.namespace);
8671
+ const existing = await this.kind.get(id);
8672
+ const currentRevision = existing?.data.revision ?? 0;
8673
+ if (input.baseRevision !== void 0 && input.baseRevision !== currentRevision) return {
8674
+ status: "conflict",
8675
+ current: existing === null ? null : toRow(existing.data)
8676
+ };
8677
+ if (existing === null && mirror.size >= 32) throw new PreferencesLimitError("too-many-namespaces", `preferences: a user holds at most 32 namespaces`);
8678
+ const body = {
8679
+ userId,
8680
+ namespace: input.namespace,
8681
+ value: input.value,
8682
+ revision: currentRevision + 1,
8683
+ updatedAt: this.now(),
8684
+ updatedByDevice: input.deviceId ?? null
8685
+ };
8686
+ await this.kind.put({
8687
+ id,
8688
+ data: body
8689
+ }, { ifRev: existing?.rev ?? 0 });
8690
+ mirror.set(input.namespace, body.revision);
8691
+ return {
8692
+ status: "saved",
8693
+ row: toRow(body)
8694
+ };
8695
+ });
8696
+ }
8697
+ /** `false` when the namespace did not exist. */
8698
+ async delete(userId, namespace) {
8699
+ return this.serialized(userId, async () => {
8700
+ const mirror = await this.mirrorOf(userId);
8701
+ const deleted = await this.kind.delete(userPreferenceRecordId(userId, namespace));
8702
+ mirror.delete(namespace);
8703
+ return deleted;
8704
+ });
8705
+ }
8706
+ /**
8707
+ * What the client is missing: every row whose revision differs from the one
8708
+ * it holds (or that it does not list), and every listed namespace now gone.
8709
+ */
8710
+ async changedSince(userId, known) {
8711
+ const mirror = await this.mirrorOf(userId);
8712
+ const changed = [];
8713
+ for (const [namespace, revision] of mirror) if (known[namespace] !== revision) changed.push(namespace);
8714
+ const deleted = Object.keys(known).filter((namespace) => !mirror.has(namespace)).toSorted();
8715
+ return {
8716
+ rows: [...changed.length === 0 ? [] : await this.get(userId, changed)],
8717
+ deleted
8718
+ };
8719
+ }
8720
+ /** Every row of `userId` goes (user delete). Returns how many. */
8721
+ async wipeUser(userId) {
8722
+ return this.serialized(userId, async () => {
8723
+ const rows = await this.scanUser(userId);
8724
+ const removed = rows.length === 0 ? 0 : await this.kind.deleteMany(rows.map((r) => r.id));
8725
+ this.mirrors.delete(userId);
8726
+ return removed;
8727
+ });
8728
+ }
8729
+ /** Rows whose user is not in `liveUserIds` go (boot sweep). Returns how many. */
8730
+ async wipeOrphans(liveUserIds) {
8731
+ const orphans = /* @__PURE__ */ new Set();
8732
+ let cursor;
8733
+ do {
8734
+ const page = await this.kind.query({
8735
+ order: "id",
8736
+ limit: ORPHAN_SCAN_PAGE,
8737
+ ...cursor !== void 0 ? { cursor } : {}
8738
+ });
8739
+ for (const row of page.records) if (!liveUserIds.has(row.data.userId)) orphans.add(row.data.userId);
8740
+ cursor = page.nextCursor ?? void 0;
8741
+ } while (cursor !== void 0);
8742
+ let removed = 0;
8743
+ for (const userId of orphans) removed += await this.wipeUser(userId);
8744
+ return removed;
8745
+ }
8746
+ async mirrorOf(userId) {
8747
+ const cached = this.mirrors.get(userId);
8748
+ if (cached !== void 0) return cached;
8749
+ const rows = await this.scanUser(userId);
8750
+ const raced = this.mirrors.get(userId);
8751
+ if (raced !== void 0) return raced;
8752
+ const mirror = new Map(rows.map((r) => [r.data.namespace, r.data.revision]));
8753
+ this.mirrors.set(userId, mirror);
8754
+ return mirror;
8755
+ }
8756
+ async scanUser(userId) {
8757
+ const out = [];
8758
+ let cursor;
8759
+ do {
8760
+ const page = await this.kind.query({
8761
+ where: { userId },
8762
+ limit: USER_SCAN_PAGE,
8763
+ ...cursor !== void 0 ? { cursor } : {}
8764
+ });
8765
+ out.push(...page.records);
8766
+ cursor = page.nextCursor ?? void 0;
8767
+ } while (cursor !== void 0);
8768
+ return out;
8769
+ }
8770
+ /** Run `task` after every earlier write of the same user has settled. */
8771
+ serialized(userId, task) {
8772
+ const next = (this.locks.get(userId) ?? Promise.resolve()).then(task, task);
8773
+ const settled = next.catch(() => void 0);
8774
+ this.locks.set(userId, settled);
8775
+ settled.then(() => {
8776
+ if (this.locks.get(userId) === settled) this.locks.delete(userId);
8777
+ });
8778
+ return next;
8779
+ }
8780
+ };
8976
8781
  //#endregion
8977
8782
  //#region src/auth/user-management-internal.ts
8978
8783
  /** Process-global, so every bundled copy of this module agrees on the key. */
@@ -12476,17 +12281,16 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
12476
12281
  authManager = null;
12477
12282
  userManager = null;
12478
12283
  apiKeyManager = null;
12479
- scopedTokenManager = null;
12480
12284
  oauthSessionManager = null;
12481
12285
  reapTimer = null;
12482
12286
  totpManager = null;
12483
12287
  externalIdentities = null;
12484
12288
  signingKeyKeeper = null;
12485
12289
  loginSessions = null;
12486
- adminTokens = null;
12487
12290
  auditLog = null;
12488
12291
  deviceCredentials = null;
12489
12292
  deviceCa = null;
12293
+ preferences = null;
12490
12294
  constructor() {
12491
12295
  super({});
12492
12296
  }
@@ -12505,21 +12309,18 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
12505
12309
  if (store && records) {
12506
12310
  const useRecords = await require_phase6_declare_gate.resolvePhase6UseRecordsPathForHub(this.ctx, "local-auth", localAuthRecordKinds, LOCAL_AUTH_RECORDS_LEGACY_COLLECTIONS);
12507
12311
  const authStores = useRecords ? localAuthRecordStoresOf(records) : null;
12312
+ await records.declare(localAuthPreferencesRecordKinds);
12313
+ this.preferences = new UserPreferencesStore(records.kind(localAuthPreferencesRecordKinds["user-preferences"]));
12508
12314
  const legacyStoreAccess = { getStore: () => store };
12509
12315
  await declareAuthSchema(store, { ...useRecords ? { skipCollections: LOCAL_AUTH_RECORDS_LEGACY_COLLECTIONS } : {} });
12510
12316
  this.userManager = authStores === null ? new UserManager(legacyStoreAccess, this.authManager) : new UserManager({
12511
12317
  usersKind: authStores.users,
12512
12318
  legacyStore: store
12513
12319
  }, this.authManager);
12514
- this.adminTokens = authStores === null ? new AdminTokenManager(store) : new AdminTokenManager({ adminTokensKind: authStores.adminTokens });
12515
12320
  this.apiKeyManager = authStores === null ? new ApiKeyManager(legacyStoreAccess) : new ApiKeyManager({
12516
12321
  apiKeysKind: authStores.apiKeys,
12517
12322
  legacyStore: store
12518
12323
  });
12519
- this.scopedTokenManager = authStores === null ? new ScopedTokenManager(store) : new ScopedTokenManager({
12520
- scopedTokensKind: authStores.scopedTokens,
12521
- legacyStore: store
12522
- });
12523
12324
  this.oauthSessionManager = authStores === null ? new OauthSessionManager(store) : new OauthSessionManager({
12524
12325
  oauthSessionsKind: authStores.oauthSessions,
12525
12326
  legacyStore: store
@@ -12580,8 +12381,12 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
12580
12381
  });
12581
12382
  const liveUsers = await this.userManager.listAll();
12582
12383
  const liveIds = new Set(liveUsers.map((u) => u.id));
12583
- const removed = await this.scopedTokenManager.cleanupOrphans(liveIds);
12584
- if (removed > 0) this.ctx.logger.warn(`cleaned up ${removed} orphan scoped-token(s) on boot`);
12384
+ try {
12385
+ const orphanPrefs = await this.preferences.wipeOrphans(liveIds);
12386
+ if (orphanPrefs > 0) this.ctx.logger.warn(`cleaned up ${orphanPrefs} orphan account-preference row(s) on boot`);
12387
+ } catch (err) {
12388
+ this.ctx.logger.warn("account-preference orphan sweep failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
12389
+ }
12585
12390
  await this.reapAuthRows();
12586
12391
  this.reapTimer = setInterval(() => {
12587
12392
  this.reapAuthRows();
@@ -12695,29 +12500,6 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
12695
12500
  await this.apiKeyManager.revoke(input.id);
12696
12501
  return { success: true };
12697
12502
  },
12698
- createScopedToken: async (input) => {
12699
- if (!this.scopedTokenManager) throw new Error("Scoped token management not available");
12700
- const { token, record } = await this.scopedTokenManager.create(input.userId, input.name, input.scopes, input.expiresAt);
12701
- return {
12702
- token,
12703
- record
12704
- };
12705
- },
12706
- revokeScopedToken: async (input) => {
12707
- if (!this.scopedTokenManager) throw new Error("Scoped token management not available");
12708
- if (!await this.scopedTokenManager.revoke(input.id)) throw new Error(`Scoped token not found: ${input.id}`);
12709
- return { success: true };
12710
- },
12711
- validateScopedToken: async (input) => {
12712
- if (!this.scopedTokenManager || !this.userManager) return null;
12713
- const record = await this.scopedTokenManager.validate(input.token);
12714
- if (record === null) return null;
12715
- return await this.userManager.findById(record.userId) === null ? null : record;
12716
- },
12717
- listScopedTokens: async (input) => {
12718
- if (!this.scopedTokenManager) return [];
12719
- return this.scopedTokenManager.listForUser(input.userId);
12720
- },
12721
12503
  oauthIssueCode: async (input) => {
12722
12504
  return lazyOauthGrants().oauthIssueCode(input);
12723
12505
  },
@@ -12959,7 +12741,9 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
12959
12741
  if (this.deviceCredentials === null) throw require_dist.userError("device.credentials-unavailable", void 0, "Device credentials not available");
12960
12742
  return this.deviceCredentials;
12961
12743
  };
12744
+ const preferences = this.preferences;
12962
12745
  return {
12746
+ ...preferences !== null ? { preferences } : {},
12963
12747
  sessions: {
12964
12748
  create: (input) => sessions().create(input),
12965
12749
  refresh: (token, lifetimeMs) => sessions().refresh(token, lifetimeMs),
@@ -12984,22 +12768,6 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
12984
12768
  revoke: (id, by) => devices().revoke(id, by),
12985
12769
  list: (input) => devices().list(input)
12986
12770
  },
12987
- adminTokens: {
12988
- create: async (input) => {
12989
- if (this.adminTokens === null) throw new Error("Admin tokens not available");
12990
- return this.adminTokens.create(input);
12991
- },
12992
- validate: async (token) => {
12993
- if (this.adminTokens === null || this.userManager === null) return null;
12994
- const record = await this.adminTokens.validate(token);
12995
- if (record === null) return null;
12996
- const creator = await this.userManager.findById(record.createdBy);
12997
- return creator !== null && creator.isAdmin ? record : null;
12998
- },
12999
- list: async () => this.adminTokens === null ? [] : this.adminTokens.list(),
13000
- revoke: async (id) => this.adminTokens === null ? false : this.adminTokens.revoke(id)
13001
- },
13002
- scopedTokens: { revoke: async (id) => this.scopedTokenManager === null ? false : this.scopedTokenManager.revoke(id) },
13003
12771
  oauthSessions: { revoke: async (id) => {
13004
12772
  if (this.oauthSessionManager === null) return false;
13005
12773
  const session = await this.oauthSessionManager.getById(id);
@@ -13040,18 +12808,15 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
13040
12808
  async endAllAccess(userId) {
13041
12809
  const steps = [
13042
12810
  ["sessions", async () => this.loginSessions?.revokeAllForUser(userId, { reason: "user-deleted" })],
13043
- ["scoped tokens", async () => {
13044
- for (const token of await this.scopedTokenManager?.listForUser(userId) ?? []) await this.scopedTokenManager?.revoke(token.id);
13045
- }],
13046
12811
  ["oauth sessions", async () => {
13047
12812
  for (const session of await this.oauthSessionManager?.list() ?? []) if (session.userId === userId && session.revokedAt === null) await this.oauthSessionManager?.markRevoked(session.id);
13048
12813
  }],
13049
- ["admin tokens", async () => this.adminTokens?.revokeAllCreatedBy(userId)],
13050
12814
  ["device credentials", async () => {
13051
12815
  for (const id of await this.deviceCredentials?.revokeAllForUser(userId, "user-deleted") ?? []) await this.loginSessions?.revokeByCredential(id);
13052
12816
  }],
13053
12817
  ["totp", async () => this.totpManager?.disable(userId)],
13054
- ["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)]
12818
+ ["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)],
12819
+ ["account preferences", async () => this.preferences?.wipeUser(userId)]
13055
12820
  ];
13056
12821
  for (const [what, step] of steps) try {
13057
12822
  await step();
@@ -13063,25 +12828,22 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
13063
12828
  }
13064
12829
  }
13065
12830
  /**
13066
- * Drop auth rows that are dead but were never removed: scoped tokens past
13067
- * their expiry, and OAuth sessions revoked longer ago than
13068
- * {@link OAUTH_REVOKED_RETENTION_MS}.
12831
+ * Drop auth rows that are dead but were never removed: OAuth sessions
12832
+ * revoked longer ago than {@link OAUTH_REVOKED_RETENTION_MS}, dead login
12833
+ * sessions, old audit rows, dead device credentials. (Tokens are the hub's
12834
+ * `credential-tokens` rows, reaped there.)
13069
12835
  *
13070
- * Neither row could authorise anything — `validate` rejects an expired
13071
- * token, and every consumer treats a missing session exactly like a revoked
13072
- * one. So this is hygiene, and it is best-effort: a failed sweep is logged
12836
+ * No such row could authorise anything — every consumer treats a missing
12837
+ * session exactly like a revoked one. So this is hygiene, and it is best-effort: a failed sweep is logged
13073
12838
  * and the next tick tries again. It must never be able to fail boot.
13074
12839
  */
13075
12840
  async reapAuthRows() {
13076
12841
  try {
13077
- const tokens = await this.scopedTokenManager?.reapExpired() ?? 0;
13078
12842
  const sessions = await this.oauthSessionManager?.reapRevoked(Date.now() - OAUTH_REVOKED_RETENTION_MS) ?? 0;
13079
12843
  const logins = await this.loginSessions?.reap() ?? 0;
13080
- await this.adminTokens?.reap();
13081
12844
  await this.auditLog?.reap();
13082
12845
  await this.deviceCredentials?.reap();
13083
- if (tokens > 0 || sessions > 0 || logins > 0) this.ctx.logger.info("auth retention swept", { meta: {
13084
- expiredTokens: tokens,
12846
+ if (sessions > 0 || logins > 0) this.ctx.logger.info("auth retention swept", { meta: {
13085
12847
  revokedSessions: sessions,
13086
12848
  deadLoginSessions: logins
13087
12849
  } });
@@ -13099,20 +12861,15 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
13099
12861
  this.authManager = null;
13100
12862
  this.userManager = null;
13101
12863
  this.apiKeyManager = null;
13102
- this.scopedTokenManager = null;
13103
12864
  this.oauthSessionManager = null;
13104
12865
  this.externalIdentities = null;
13105
12866
  this.loginSessions = null;
13106
- this.adminTokens = null;
13107
12867
  this.auditLog = null;
13108
12868
  this.deviceCredentials = null;
13109
12869
  this.deviceCa = null;
13110
12870
  }
13111
12871
  };
13112
12872
  //#endregion
13113
- exports.ADMIN_TOKEN_MAX_LIFETIME_MS = ADMIN_TOKEN_MAX_LIFETIME_MS;
13114
- exports.ADMIN_TOKEN_PREFIX = ADMIN_TOKEN_PREFIX;
13115
- exports.AdminTokenManager = AdminTokenManager;
13116
12873
  exports.ApiKeyManager = ApiKeyManager;
13117
12874
  exports.AuthAuditLog = AuthAuditLog;
13118
12875
  exports.AuthManager = AuthManager;
@@ -13125,7 +12882,6 @@ exports.default = LocalAuthAddon;
13125
12882
  exports.LoginSessionManager = LoginSessionManager;
13126
12883
  exports.PREVIOUS_LEAF_GRACE_MS = PREVIOUS_LEAF_GRACE_MS;
13127
12884
  exports.REFRESH_TOKEN_PREFIX = REFRESH_TOKEN_PREFIX;
13128
- exports.ScopedTokenManager = ScopedTokenManager;
13129
12885
  exports.SettingsReadUnavailableError = SettingsReadUnavailableError;
13130
12886
  exports.USER_MANAGEMENT_INTERNAL = USER_MANAGEMENT_INTERNAL;
13131
12887
  exports.UserManager = UserManager;
@@ -13134,3 +12890,4 @@ exports.getUserManagementInternal = getUserManagementInternal;
13134
12890
  exports.isSettingsReadUnavailable = isSettingsReadUnavailable;
13135
12891
  exports.onSessionsRevoked = onSessionsRevoked;
13136
12892
  exports.parseTokenExpiry = parseTokenExpiry;
12893
+ exports.preferencesLimitReasonOf = preferencesLimitReasonOf;