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