@camstack/system 1.2.428 → 1.2.430

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 (101) hide show
  1. package/dist/agent-https-names-CpUSuP4w.js +120 -0
  2. package/dist/agent-https-names-uetr4OQl.mjs +91 -0
  3. package/dist/auth/auth-audit-log.d.ts +1 -1
  4. package/dist/auth/device-ca-store.d.ts +57 -0
  5. package/dist/auth/device-ca.d.ts +75 -0
  6. package/dist/auth/device-credential-manager.d.ts +154 -0
  7. package/dist/auth/login-session-manager.d.ts +14 -2
  8. package/dist/auth/user-management-internal.d.ts +10 -1
  9. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  10. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  11. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  12. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  13. package/dist/builtins/alerts/alerts.addon.js +1 -1
  14. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  15. package/dist/builtins/autotrack/index.js +1 -1
  16. package/dist/builtins/autotrack/index.mjs +1 -1
  17. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  18. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  19. package/dist/builtins/calendars/calendars.addon.js +1 -1
  20. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  21. package/dist/builtins/camera-grid/index.js +1 -1
  22. package/dist/builtins/camera-grid/index.mjs +1 -1
  23. package/dist/builtins/composer/composer.addon.js +2 -2
  24. package/dist/builtins/composer/composer.addon.mjs +2 -2
  25. package/dist/builtins/console-logging/index.js +1 -1
  26. package/dist/builtins/console-logging/index.mjs +1 -1
  27. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  28. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  29. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  30. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  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 +10 -6
  34. package/dist/builtins/keychain/index.mjs +8 -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/local-auth.addon.d.ts +6 -0
  38. package/dist/builtins/local-auth/local-auth.addon.js +955 -47
  39. package/dist/builtins/local-auth/local-auth.addon.mjs +946 -41
  40. package/dist/builtins/local-network/local-network.addon.js +2 -2
  41. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  42. package/dist/builtins/loki-logging/index.js +1 -1
  43. package/dist/builtins/loki-logging/index.mjs +1 -1
  44. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  45. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  46. package/dist/builtins/platform-probe/index.js +1 -1
  47. package/dist/builtins/platform-probe/index.mjs +1 -1
  48. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  49. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  50. package/dist/builtins/snapshot/index.js +1 -1
  51. package/dist/builtins/snapshot/index.mjs +1 -1
  52. package/dist/builtins/sqlite-storage/file-host-client.d.ts +11 -0
  53. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  54. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  55. package/dist/builtins/sqlite-storage/index.js +1 -1
  56. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  57. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +34 -4
  58. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +34 -4
  59. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  60. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  61. package/dist/builtins/system-config/system-config.addon.d.ts +18 -0
  62. package/dist/builtins/system-config/system-config.addon.js +834 -18
  63. package/dist/builtins/system-config/system-config.addon.mjs +819 -19
  64. package/dist/builtins/winston-logging/index.js +1 -1
  65. package/dist/builtins/winston-logging/index.mjs +1 -1
  66. package/dist/{composition-sources-BQNxXQZG.mjs → composition-sources-Cw_TXEq9.mjs} +1 -1
  67. package/dist/{composition-sources-DvCGZEUI.js → composition-sources-o5MPSlk6.js} +1 -1
  68. package/dist/{constants-CCSJGnjs.js → constants-BcNaVkvI.js} +1 -1
  69. package/dist/{constants-CR4If_lP.mjs → constants-CttE77VF.mjs} +1 -1
  70. package/dist/device-ca-B-BTxeCj.mjs +355 -0
  71. package/dist/device-ca-CKiqh2Iu.js +462 -0
  72. package/dist/{dist-DHSJV4Qc.mjs → dist-CXG6muCR.mjs} +1052 -35
  73. package/dist/{dist-BQG3QFg7.js → dist-DxJdci75.js} +1099 -34
  74. package/dist/file-host-worker.js +1 -1
  75. package/dist/file-host-worker.mjs +1 -1
  76. package/dist/index.d.ts +4 -1
  77. package/dist/index.js +59 -12
  78. package/dist/index.mjs +10 -9
  79. package/dist/kernel/index.d.ts +2 -2
  80. package/dist/kernel/moleculer/mesh-mtls/index.d.ts +4 -1
  81. package/dist/kernel/moleculer/mesh-mtls/mesh-certificate-registry.d.ts +91 -0
  82. package/dist/kernel/moleculer/mesh-mtls/mesh-enroll.d.ts +4 -0
  83. package/dist/kernel/moleculer/mesh-mtls/mesh-peer-transport.d.ts +23 -0
  84. package/dist/kernel/moleculer/mesh-mtls/mesh-renewal.d.ts +126 -0
  85. package/dist/kernel/moleculer/mesh-mtls/mesh-tls-gate.d.ts +40 -0
  86. package/dist/kernel/moleculer/mesh-mtls/tls-tcp-transporter.d.ts +15 -0
  87. package/dist/{restore-archive-Dccm8lkA.js → restore-archive-CQJNoqRK.js} +1 -1
  88. package/dist/{restore-archive-DH1Pxq_1.mjs → restore-archive-D_wBL1OO.mjs} +1 -1
  89. package/dist/{retired-settings-keys-CPz7zpza.mjs → retired-settings-keys-EmDe11cP.mjs} +1 -1
  90. package/dist/{retired-settings-keys-BfGaSeQI.js → retired-settings-keys-g1oifXxR.js} +1 -1
  91. package/dist/tls/agent-https-names.d.ts +27 -0
  92. package/dist/tls/agent-https.d.ts +111 -0
  93. package/dist/tls/cert-issue.d.ts +7 -0
  94. package/dist/tls/index.d.ts +3 -0
  95. package/dist/{tls-BgImdxCO.js → tls-C5IYg3V5.js} +406 -0
  96. package/dist/{tls-CaOLZ09W.mjs → tls-CpcGkeJV.mjs} +314 -4
  97. package/dist/{wal-maintenance-DItkuOvu.js → wal-maintenance-BW1LIHi0.js} +0 -0
  98. package/dist/{wal-maintenance-Drqhcjit.mjs → wal-maintenance-vhPZLYuA.mjs} +0 -0
  99. package/package.json +4 -1
  100. package/dist/jwt-signing-key-BZm055IP.js +0 -174
  101. package/dist/jwt-signing-key-DL-qk1SM.mjs +0 -127
@@ -3,10 +3,10 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BQG3QFg7.js");
6
+ const require_dist = require("../../dist-DxJdci75.js");
7
7
  const require_safe_buffer$1 = require("../../safe-buffer-D3aoa-7X.js");
8
8
  const require_ms$1 = require("../../ms-LB3giGoU.js");
9
- const require_jwt_signing_key = require("../../jwt-signing-key-BZm055IP.js");
9
+ const require_device_ca = require("../../device-ca-CKiqh2Iu.js");
10
10
  let node_crypto = require("node:crypto");
11
11
  node_crypto = require_chunk.__toESM(node_crypto);
12
12
  let crypto$1 = require("crypto");
@@ -16,7 +16,7 @@ node_path = require_chunk.__toESM(node_path);
16
16
  let node_fs = require("node:fs");
17
17
  node_fs = require_chunk.__toESM(node_fs);
18
18
  //#region src/auth/parse-record.ts
19
- function parseRecord$1(kind, schema, data) {
19
+ function parseRecord$2(kind, schema, data) {
20
20
  try {
21
21
  return schema.parse(data);
22
22
  } catch (err) {
@@ -38,7 +38,7 @@ function parseRecord$1(kind, schema, data) {
38
38
  */
39
39
  var API_KEYS_COLLECTION$1 = "api_keys";
40
40
  function parseApiKey(data) {
41
- return parseRecord$1("api-key", require_dist.ApiKeyRecordSchema, data);
41
+ return parseRecord$2("api-key", require_dist.ApiKeyRecordSchema, data);
42
42
  }
43
43
  var ApiKeyManager = class {
44
44
  storageAccess;
@@ -160,7 +160,7 @@ function genSalt(rounds, seed_length, callback) {
160
160
  * @returns {!Promise} If `callback` has been omitted
161
161
  * @throws {Error} If `callback` is present but not a function
162
162
  */
163
- function hash$1(password, salt, callback, progressCallback) {
163
+ function hash$2(password, salt, callback, progressCallback) {
164
164
  function _async(callback) {
165
165
  if (typeof password === "string" && typeof salt === "number") genSalt(salt, function(err, salt) {
166
166
  _hash(password, salt, callback, progressCallback);
@@ -213,7 +213,7 @@ function compare(password, hashValue, callback, progressCallback) {
213
213
  nextTick(callback.bind(this, null, false));
214
214
  return;
215
215
  }
216
- hash$1(password, hashValue.substring(0, 29), function(err, comp) {
216
+ hash$2(password, hashValue.substring(0, 29), function(err, comp) {
217
217
  if (err) callback(err);
218
218
  else callback(null, safeStringCompare(comp, hashValue));
219
219
  }, progressCallback);
@@ -6028,7 +6028,7 @@ var AuthManager = class {
6028
6028
  * slot `local-auth` fills from the keychain (D797). Never config, never
6029
6029
  * env; read on every mint and verify, and it THROWS while unavailable.
6030
6030
  */
6031
- constructor(config, logger = noopLogger, signingKey = require_jwt_signing_key.processJwtSigningKey) {
6031
+ constructor(config, logger = noopLogger, signingKey = require_device_ca.processJwtSigningKey) {
6032
6032
  this.config = config;
6033
6033
  this.signingKey = signingKey;
6034
6034
  this.logger = logger;
@@ -6087,7 +6087,7 @@ var AuthManager = class {
6087
6087
  return import_jsonwebtoken.verify(token, this.jwtSecret);
6088
6088
  }
6089
6089
  async hashPassword(password) {
6090
- return hash$1(password, 10);
6090
+ return hash$2(password, 10);
6091
6091
  }
6092
6092
  async comparePassword(password, hash) {
6093
6093
  return compare(password, hash);
@@ -6282,7 +6282,7 @@ var AuthManager = class {
6282
6282
  var TOKENS_COLLECTION = "scoped_tokens";
6283
6283
  var TOKEN_PREFIX = "cst_";
6284
6284
  function parseToken(data) {
6285
- return parseRecord$1("scoped-token", require_dist.ScopedTokenSchema, data);
6285
+ return parseRecord$2("scoped-token", require_dist.ScopedTokenSchema, data);
6286
6286
  }
6287
6287
  /**
6288
6288
  * Manages scoped API tokens with restricted addon/route/capability access.
@@ -6565,6 +6565,10 @@ var LOGIN_SESSIONS_COLUMNS = [
6565
6565
  {
6566
6566
  name: "ip",
6567
6567
  type: "TEXT"
6568
+ },
6569
+ {
6570
+ name: "credentialId",
6571
+ type: "TEXT"
6568
6572
  }
6569
6573
  ];
6570
6574
  var LOGIN_SESSION_CUTOFFS_COLUMNS = [{
@@ -6582,7 +6586,8 @@ var METHODS = [
6582
6586
  "passkey",
6583
6587
  "sso",
6584
6588
  "handoff",
6585
- "exchange"
6589
+ "exchange",
6590
+ "device-certificate"
6586
6591
  ];
6587
6592
  var REFRESH_TOKEN_PREFIX = "csr_";
6588
6593
  /**
@@ -6598,20 +6603,20 @@ var MAX_CLIENT_LABEL = 200;
6598
6603
  function hashToken(token) {
6599
6604
  return node_crypto.createHash("sha256").update(token).digest("hex");
6600
6605
  }
6601
- function str(data, key) {
6606
+ function str$1(data, key) {
6602
6607
  const value = data[key];
6603
6608
  return typeof value === "string" && value.length > 0 ? value : null;
6604
6609
  }
6605
- function num(data, key) {
6610
+ function num$1(data, key) {
6606
6611
  const value = data[key];
6607
6612
  return typeof value === "number" && Number.isFinite(value) ? value : null;
6608
6613
  }
6609
6614
  function parseSession$1(data) {
6610
- const id = str(data, "id");
6611
- const userId = str(data, "userId");
6612
- const createdAt = num(data, "createdAt");
6613
- const lastUsedAt = num(data, "lastUsedAt");
6614
- const expiresAt = num(data, "expiresAt");
6615
+ const id = str$1(data, "id");
6616
+ const userId = str$1(data, "userId");
6617
+ const createdAt = num$1(data, "createdAt");
6618
+ const lastUsedAt = num$1(data, "lastUsedAt");
6619
+ const expiresAt = num$1(data, "expiresAt");
6615
6620
  const method = METHODS.find((m) => m === data["method"]);
6616
6621
  if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
6617
6622
  return {
@@ -6622,13 +6627,14 @@ function parseSession$1(data) {
6622
6627
  createdAt,
6623
6628
  lastUsedAt: lastUsedAt ?? createdAt,
6624
6629
  expiresAt,
6625
- revokedAt: num(data, "revokedAt"),
6626
- revokedReason: str(data, "revokedReason"),
6627
- refreshHash: str(data, "refreshHash"),
6628
- previousRefreshHash: str(data, "previousRefreshHash"),
6629
- rotatedAt: num(data, "rotatedAt"),
6630
- client: str(data, "client"),
6631
- ip: str(data, "ip")
6630
+ revokedAt: num$1(data, "revokedAt"),
6631
+ revokedReason: str$1(data, "revokedReason"),
6632
+ refreshHash: str$1(data, "refreshHash"),
6633
+ previousRefreshHash: str$1(data, "previousRefreshHash"),
6634
+ rotatedAt: num$1(data, "rotatedAt"),
6635
+ client: str$1(data, "client"),
6636
+ ip: str$1(data, "ip"),
6637
+ credentialId: str$1(data, "credentialId")
6632
6638
  };
6633
6639
  }
6634
6640
  function toLoginSessionSummary(record) {
@@ -6657,8 +6663,8 @@ var LoginSessionManager = class {
6657
6663
  }
6658
6664
  const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
6659
6665
  for (const row of cutoffs) {
6660
- const userId = str(row.data, "userId");
6661
- const notBefore = num(row.data, "notBefore");
6666
+ const userId = str$1(row.data, "userId");
6667
+ const notBefore = num$1(row.data, "notBefore");
6662
6668
  if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
6663
6669
  }
6664
6670
  }
@@ -6679,7 +6685,8 @@ var LoginSessionManager = class {
6679
6685
  previousRefreshHash: null,
6680
6686
  rotatedAt: null,
6681
6687
  client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
6682
- ip: input.ip ?? null
6688
+ ip: input.ip ?? null,
6689
+ credentialId: input.credentialId ?? null
6683
6690
  };
6684
6691
  await this.store.insert.mutate({
6685
6692
  collection: LOGIN_SESSIONS_COLLECTION,
@@ -6709,6 +6716,16 @@ var LoginSessionManager = class {
6709
6716
  legacyCutoff(userId) {
6710
6717
  return this.cutoffs.get(userId) ?? 0;
6711
6718
  }
6719
+ /**
6720
+ * The session a refresh token belongs to, WITHOUT rotating it - so the
6721
+ * refresh path can judge the session (its device credential) before it
6722
+ * spends the token. `null` when the token names no session.
6723
+ */
6724
+ peekByRefreshToken(refreshToken) {
6725
+ if (!refreshToken.startsWith("csr_")) return null;
6726
+ const sessionId = this.byRefreshHash.get(hashToken(refreshToken));
6727
+ return sessionId === void 0 ? null : this.sessions.get(sessionId) ?? null;
6728
+ }
6712
6729
  /** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
6713
6730
  async refresh(refreshToken, lifetimeMs) {
6714
6731
  if (!refreshToken.startsWith("csr_")) return {
@@ -6812,6 +6829,23 @@ var LoginSessionManager = class {
6812
6829
  for (const session of ended) await this.persist(session);
6813
6830
  return ended.length;
6814
6831
  }
6832
+ /** End every live session opened by device credential `credentialId`. Returns how many. */
6833
+ async revokeByCredential(credentialId, reason = "credential-revoked") {
6834
+ const at = this.now();
6835
+ const ended = [];
6836
+ for (const session of this.sessions.values()) {
6837
+ if (session.credentialId !== credentialId || session.revokedAt !== null) continue;
6838
+ const updated = {
6839
+ ...session,
6840
+ revokedAt: at,
6841
+ revokedReason: reason
6842
+ };
6843
+ this.replace(session, updated);
6844
+ ended.push(updated);
6845
+ }
6846
+ for (const session of ended) await this.persist(session);
6847
+ return ended.length;
6848
+ }
6815
6849
  /** `userId`'s live sessions, newest first. */
6816
6850
  listForUser(userId) {
6817
6851
  return this.listActive().filter((s) => s.userId === userId);
@@ -6965,7 +6999,7 @@ var ADMIN_TOKEN_PREFIX = "csa_";
6965
6999
  var ADMIN_TOKEN_MAX_LIFETIME_MS = 90 * 864e5;
6966
7000
  var DEAD_RETENTION_MS = 30 * 864e5;
6967
7001
  var TOUCH_EVERY_MS = 6e4;
6968
- function hash(token) {
7002
+ function hash$1(token) {
6969
7003
  return node_crypto.createHash("sha256").update(token).digest("hex");
6970
7004
  }
6971
7005
  function parse(data) {
@@ -7005,7 +7039,7 @@ var AdminTokenManager = class {
7005
7039
  id: node_crypto.randomUUID(),
7006
7040
  createdBy: input.createdBy,
7007
7041
  name,
7008
- tokenHash: hash(token),
7042
+ tokenHash: hash$1(token),
7009
7043
  tokenPrefix: token.slice(0, 12),
7010
7044
  createdAt: at,
7011
7045
  expiresAt: input.expiresAt,
@@ -7029,7 +7063,7 @@ var AdminTokenManager = class {
7029
7063
  if (!token.startsWith("csa_")) return null;
7030
7064
  const first = (await this.store.query.query({
7031
7065
  collection: ADMIN_TOKENS_COLLECTION,
7032
- filter: { where: { tokenHash: hash(token) } }
7066
+ filter: { where: { tokenHash: hash$1(token) } }
7033
7067
  }))[0];
7034
7068
  const stored = first === void 0 ? null : parse(first.data);
7035
7069
  if (stored === null || stored.revokedAt !== null) return null;
@@ -7090,6 +7124,678 @@ var AdminTokenManager = class {
7090
7124
  }
7091
7125
  };
7092
7126
  //#endregion
7127
+ //#region src/auth/device-ca-store.ts
7128
+ function hasCertificate(value) {
7129
+ return typeof value === "string" && value.includes("BEGIN CERTIFICATE");
7130
+ }
7131
+ var DeviceCaStore = class {
7132
+ deps;
7133
+ loaded = null;
7134
+ creating = null;
7135
+ lastReported = null;
7136
+ constructor(deps) {
7137
+ this.deps = deps;
7138
+ }
7139
+ /** The CA when one exists (completing an interrupted creation). Never creates a NEW one. */
7140
+ async lookup() {
7141
+ if (this.loaded !== null) return {
7142
+ state: "ready",
7143
+ ca: this.loaded
7144
+ };
7145
+ const secrets = this.deps.secrets;
7146
+ if (secrets === null) return {
7147
+ state: "unavailable",
7148
+ reason: "no keychain door in this process"
7149
+ };
7150
+ let entry;
7151
+ try {
7152
+ entry = (await secrets.resolve([require_device_ca.DEVICE_CA_KEY_REF]))[require_device_ca.DEVICE_CA_KEY_REF];
7153
+ } catch (err) {
7154
+ return {
7155
+ state: "unavailable",
7156
+ reason: err instanceof Error ? err.message : String(err)
7157
+ };
7158
+ }
7159
+ if (entry === void 0) return {
7160
+ state: "unavailable",
7161
+ reason: "no-answer"
7162
+ };
7163
+ if (entry.kind === "unavailable") return {
7164
+ state: "unavailable",
7165
+ reason: entry.reason
7166
+ };
7167
+ if (entry.kind === "forbidden") return {
7168
+ state: "unavailable",
7169
+ reason: "forbidden"
7170
+ };
7171
+ let section;
7172
+ try {
7173
+ section = await this.deps.readSection(require_device_ca.DEVICE_CA_SETTINGS_SECTION);
7174
+ } catch (err) {
7175
+ return {
7176
+ state: "unavailable",
7177
+ reason: `settings unreadable: ${err instanceof Error ? err.message : String(err)}`
7178
+ };
7179
+ }
7180
+ const certPem = section[require_device_ca.DEVICE_CA_FIELD];
7181
+ if (entry.kind === "missing") {
7182
+ if (!hasCertificate(certPem)) return { state: "absent" };
7183
+ return this.report("the device CA key is missing from the keychain but cluster.deviceCa is present - not creating a new CA (it would orphan every enrolled device); restore the key (secret://system/global/deviceCaKey) or remove cluster.deviceCa knowingly");
7184
+ }
7185
+ try {
7186
+ if (!hasCertificate(certPem)) {
7187
+ const rebuilt = await require_device_ca.rebuildDeviceCaCertificate(entry.value);
7188
+ await this.deps.writeSection(require_device_ca.DEVICE_CA_SETTINGS_SECTION, { [require_device_ca.DEVICE_CA_FIELD]: rebuilt.certPem });
7189
+ this.deps.logger?.warn("Device CA certificate rebuilt from the stored key", { meta: { reason: "cluster.deviceCa was missing next to the key" } });
7190
+ this.loaded = rebuilt;
7191
+ return {
7192
+ state: "ready",
7193
+ ca: rebuilt
7194
+ };
7195
+ }
7196
+ this.loaded = await require_device_ca.loadDeviceCa(certPem, entry.value);
7197
+ return {
7198
+ state: "ready",
7199
+ ca: this.loaded
7200
+ };
7201
+ } catch (err) {
7202
+ return {
7203
+ state: "unavailable",
7204
+ reason: err instanceof Error ? err.message : String(err)
7205
+ };
7206
+ }
7207
+ }
7208
+ /** The CA, created now if neither half exists. Throws on any inconsistent or unreadable state. */
7209
+ async ensure() {
7210
+ const found = await this.lookup();
7211
+ if (found.state === "ready") return found.ca;
7212
+ if (found.state === "unavailable") throw new Error(`device CA unavailable: ${found.reason}`);
7213
+ if (this.creating === null) this.creating = this.create().finally(() => {
7214
+ this.creating = null;
7215
+ });
7216
+ return this.creating;
7217
+ }
7218
+ report(reason) {
7219
+ if (this.lastReported !== reason) {
7220
+ this.lastReported = reason;
7221
+ this.deps.logger?.error(`Device CA is inconsistent: ${reason}`);
7222
+ }
7223
+ return {
7224
+ state: "unavailable",
7225
+ reason
7226
+ };
7227
+ }
7228
+ async create() {
7229
+ const secrets = this.deps.secrets;
7230
+ if (secrets === null) throw new Error("device CA unavailable: no keychain door");
7231
+ const generated = await require_device_ca.generateDeviceCa();
7232
+ await secrets.put(require_device_ca.DEVICE_CA_KEY_REF, generated.keyPem);
7233
+ await this.deps.writeSection(require_device_ca.DEVICE_CA_SETTINGS_SECTION, { [require_device_ca.DEVICE_CA_FIELD]: generated.certPem });
7234
+ this.loaded = await require_device_ca.loadDeviceCa(generated.certPem, generated.keyPem);
7235
+ return this.loaded;
7236
+ }
7237
+ };
7238
+ //#endregion
7239
+ //#region src/auth/device-credential-manager.ts
7240
+ /**
7241
+ * Device credentials (D855): the registry of enrolled devices, the one-shot
7242
+ * enrollment codes, the login nonces and the certificate checks.
7243
+ *
7244
+ * Hub-owned and persisted in `device_credentials` (the settings store);
7245
+ * agents persist nothing. Enrollment codes and nonces live in memory only —
7246
+ * both die in minutes and a hub restart simply asks the user to start again.
7247
+ *
7248
+ * Every refusal is logged with its reason and the credential/user it concerns
7249
+ * (`tags: { credentialId | userId }`) — never the code, the nonce or a
7250
+ * signature.
7251
+ */
7252
+ /**
7253
+ * @durable class=config owner=local-auth
7254
+ * write="an enrollment completes (QR or self) inserts a row; a renewal rewrites
7255
+ * fingerprint/expiry; every accepted certificate login rewrites lastUsedAt/From;
7256
+ * an admin revokes or renames"
7257
+ * retention="a revoked or long-expired row is kept 90 days for the audit question
7258
+ * 'which device was that', then the daily reap deletes it; an active row is
7259
+ * never deleted"
7260
+ */
7261
+ var DEVICE_CREDENTIALS_COLLECTION = "device_credentials";
7262
+ var DEVICE_CREDENTIALS_COLUMNS = [
7263
+ {
7264
+ name: "id",
7265
+ type: "TEXT",
7266
+ primaryKey: true,
7267
+ notNull: true
7268
+ },
7269
+ {
7270
+ name: "userId",
7271
+ type: "TEXT",
7272
+ notNull: true
7273
+ },
7274
+ {
7275
+ name: "deviceName",
7276
+ type: "TEXT",
7277
+ notNull: true
7278
+ },
7279
+ {
7280
+ name: "platform",
7281
+ type: "TEXT",
7282
+ notNull: true
7283
+ },
7284
+ {
7285
+ name: "enrolledVia",
7286
+ type: "TEXT",
7287
+ notNull: true
7288
+ },
7289
+ {
7290
+ name: "fingerprint",
7291
+ type: "TEXT",
7292
+ notNull: true
7293
+ },
7294
+ {
7295
+ name: "serial",
7296
+ type: "TEXT",
7297
+ notNull: true
7298
+ },
7299
+ {
7300
+ name: "previousFingerprint",
7301
+ type: "TEXT"
7302
+ },
7303
+ {
7304
+ name: "renewedAt",
7305
+ type: "INTEGER"
7306
+ },
7307
+ {
7308
+ name: "publicKey",
7309
+ type: "TEXT",
7310
+ notNull: true
7311
+ },
7312
+ {
7313
+ name: "issuedAt",
7314
+ type: "INTEGER",
7315
+ notNull: true
7316
+ },
7317
+ {
7318
+ name: "expiresAt",
7319
+ type: "INTEGER",
7320
+ notNull: true
7321
+ },
7322
+ {
7323
+ name: "lastUsedAt",
7324
+ type: "INTEGER"
7325
+ },
7326
+ {
7327
+ name: "lastUsedFrom",
7328
+ type: "TEXT"
7329
+ },
7330
+ {
7331
+ name: "revokedAt",
7332
+ type: "INTEGER"
7333
+ },
7334
+ {
7335
+ name: "revokedBy",
7336
+ type: "TEXT"
7337
+ }
7338
+ ];
7339
+ var MAX_PENDING = 2e3;
7340
+ /**
7341
+ * Outstanding login nonces are bounded PER CLIENT (a partition = the client
7342
+ * address): one flooder evicts only its own oldest nonces, never another
7343
+ * client's. {@link MAX_NONCES_TOTAL} is a memory ceiling that only a flood from
7344
+ * hundreds of distinct addresses can reach.
7345
+ */
7346
+ var MAX_NONCES_PER_PARTITION = 50;
7347
+ var MAX_NONCES_TOTAL = 2e4;
7348
+ /**
7349
+ * After a renewal the PREVIOUS leaf still logs in for this long, so a device
7350
+ * that crashed between the hub's answer and its own write is not locked out;
7351
+ * after it, the old leaf is `superseded` (not definitive: the app holds the
7352
+ * newest certificate, so seeing it means the device lost the new one).
7353
+ */
7354
+ var PREVIOUS_LEAF_GRACE_MS = 24 * 36e5;
7355
+ var REAP_RETENTION_MS = 90 * 864e5;
7356
+ function hash(value) {
7357
+ return node_crypto.createHash("sha256").update(value).digest("hex");
7358
+ }
7359
+ function str(data, key) {
7360
+ const v = data[key];
7361
+ return typeof v === "string" && v.length > 0 ? v : null;
7362
+ }
7363
+ function num(data, key) {
7364
+ const v = data[key];
7365
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
7366
+ }
7367
+ var PLATFORMS = [
7368
+ "ios",
7369
+ "android",
7370
+ "web",
7371
+ "other"
7372
+ ];
7373
+ function parseRecord$1(data) {
7374
+ const id = str(data, "id");
7375
+ const userId = str(data, "userId");
7376
+ const deviceName = str(data, "deviceName");
7377
+ const fingerprint = str(data, "fingerprint");
7378
+ const serial = str(data, "serial");
7379
+ const publicKey = str(data, "publicKey");
7380
+ const issuedAt = num(data, "issuedAt");
7381
+ const expiresAt = num(data, "expiresAt");
7382
+ if (id === null || userId === null || deviceName === null || fingerprint === null || serial === null || publicKey === null || issuedAt === null || expiresAt === null) return null;
7383
+ return {
7384
+ id,
7385
+ userId,
7386
+ deviceName,
7387
+ platform: PLATFORMS.find((p) => p === data["platform"]) ?? "other",
7388
+ enrolledVia: data["enrolledVia"] === "self" ? "self" : "qr",
7389
+ fingerprint,
7390
+ serial,
7391
+ previousFingerprint: str(data, "previousFingerprint"),
7392
+ renewedAt: num(data, "renewedAt"),
7393
+ publicKey,
7394
+ issuedAt,
7395
+ expiresAt,
7396
+ lastUsedAt: num(data, "lastUsedAt"),
7397
+ lastUsedFrom: str(data, "lastUsedFrom"),
7398
+ revokedAt: num(data, "revokedAt"),
7399
+ revokedBy: str(data, "revokedBy")
7400
+ };
7401
+ }
7402
+ var DeviceCredentialManager = class {
7403
+ store;
7404
+ deps;
7405
+ records = /* @__PURE__ */ new Map();
7406
+ pendingEnrollments = /* @__PURE__ */ new Map();
7407
+ usedCodes = /* @__PURE__ */ new Map();
7408
+ nonces = /* @__PURE__ */ new Map();
7409
+ now;
7410
+ constructor(store, deps) {
7411
+ this.store = store;
7412
+ this.deps = deps;
7413
+ this.now = deps.now ?? Date.now;
7414
+ }
7415
+ async load() {
7416
+ const rows = await this.store.query.query({ collection: DEVICE_CREDENTIALS_COLLECTION });
7417
+ for (const row of rows) {
7418
+ const record = parseRecord$1(row.data);
7419
+ if (record !== null) this.records.set(record.id, record);
7420
+ }
7421
+ }
7422
+ /** A one-shot code for `userId` (admin path). Returned once; only its hash is kept. */
7423
+ startEnrollment(input) {
7424
+ this.prune();
7425
+ if (this.pendingEnrollments.size >= MAX_PENDING) throw new Error("Too many pending device enrollments — try again shortly");
7426
+ const code = node_crypto.randomBytes(24).toString("base64url");
7427
+ const expiresAt = this.now() + require_dist.DEVICE_ENROLLMENT_TTL_MS;
7428
+ this.pendingEnrollments.set(hash(code), {
7429
+ userId: input.userId,
7430
+ deviceName: input.deviceName,
7431
+ createdBy: input.createdBy,
7432
+ expiresAt
7433
+ });
7434
+ return {
7435
+ code,
7436
+ expiresAt
7437
+ };
7438
+ }
7439
+ /** Redeem a code with the device's public key (public procedure). */
7440
+ async completeEnrollment(input) {
7441
+ const key = hash(input.code);
7442
+ const pending = this.pendingEnrollments.get(key);
7443
+ if (pending === void 0) {
7444
+ const wasUsed = this.usedCodes.has(key);
7445
+ return this.refuse("enroll", "unknown-or-used-code", { detail: wasUsed ? "code already redeemed" : "code unknown" });
7446
+ }
7447
+ this.pendingEnrollments.delete(key);
7448
+ this.usedCodes.set(key, pending.expiresAt);
7449
+ if (this.now() > pending.expiresAt) return this.refuse("enroll", "expired-code", { userId: pending.userId });
7450
+ return this.issueNew({
7451
+ userId: pending.userId,
7452
+ deviceName: input.deviceName ?? pending.deviceName,
7453
+ platform: input.platform,
7454
+ publicKeySpki: input.publicKeySpki,
7455
+ enrolledVia: "qr"
7456
+ });
7457
+ }
7458
+ /** A signed-in user's own device (the caller has already proved a step-up). */
7459
+ async enrollSelf(input) {
7460
+ if (!await this.deps.selfEnrollmentAllowed()) return this.refuse("enroll-self", "self-enrollment-disabled", { userId: input.userId });
7461
+ return this.issueNew({
7462
+ ...input,
7463
+ enrolledVia: "self"
7464
+ });
7465
+ }
7466
+ async issueNew(input) {
7467
+ const user = await this.deps.findUser(input.userId);
7468
+ if (user === null) return this.refuse("enroll", "user-gone", { userId: input.userId });
7469
+ const spki = Buffer.from(input.publicKeySpki, "base64url");
7470
+ try {
7471
+ await require_device_ca.importDevicePublicKey(spki);
7472
+ } catch {
7473
+ return this.refuse("enroll", "bad-public-key", { userId: input.userId });
7474
+ }
7475
+ let ca;
7476
+ try {
7477
+ ca = await this.deps.ca.ensure();
7478
+ } catch (error) {
7479
+ this.deps.logger?.error("Device enrollment refused: the device CA is unavailable", {
7480
+ tags: { userId: input.userId },
7481
+ meta: { error: error instanceof Error ? error.message : String(error) }
7482
+ });
7483
+ return {
7484
+ ok: false,
7485
+ reason: "ca-unavailable"
7486
+ };
7487
+ }
7488
+ const id = node_crypto.randomUUID();
7489
+ const leaf = await require_device_ca.issueDeviceLeaf(ca, {
7490
+ credentialId: id,
7491
+ userId: input.userId,
7492
+ publicKeySpki: spki,
7493
+ now: new Date(this.now())
7494
+ });
7495
+ const record = {
7496
+ id,
7497
+ userId: input.userId,
7498
+ deviceName: input.deviceName,
7499
+ platform: input.platform,
7500
+ enrolledVia: input.enrolledVia,
7501
+ fingerprint: leaf.fingerprint,
7502
+ serial: leaf.serialNumber,
7503
+ previousFingerprint: null,
7504
+ renewedAt: null,
7505
+ publicKey: input.publicKeySpki,
7506
+ issuedAt: leaf.notBefore.getTime(),
7507
+ expiresAt: leaf.notAfter.getTime(),
7508
+ lastUsedAt: null,
7509
+ lastUsedFrom: null,
7510
+ revokedAt: null,
7511
+ revokedBy: null
7512
+ };
7513
+ await this.store.insert.mutate({
7514
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7515
+ record: {
7516
+ id,
7517
+ data: { ...record }
7518
+ }
7519
+ });
7520
+ this.records.set(id, record);
7521
+ this.deps.logger?.info("Device credential enrolled", {
7522
+ tags: {
7523
+ credentialId: id,
7524
+ userId: input.userId
7525
+ },
7526
+ meta: {
7527
+ via: input.enrolledVia,
7528
+ platform: input.platform
7529
+ }
7530
+ });
7531
+ return this.issued(record, leaf, ca, user.username);
7532
+ }
7533
+ issued(record, leaf, ca, username) {
7534
+ return {
7535
+ ok: true,
7536
+ credentialId: record.id,
7537
+ userId: record.userId,
7538
+ username,
7539
+ certPem: leaf.certPem,
7540
+ chainPem: ca.certPem,
7541
+ notAfter: leaf.notAfter.getTime()
7542
+ };
7543
+ }
7544
+ /** A single-use nonce bound to `origin`. */
7545
+ issueNonce(origin, partition = "unknown") {
7546
+ this.prune();
7547
+ const own = [...this.nonces.entries()].filter(([, v]) => v.partition === partition);
7548
+ for (const [key] of own.slice(0, Math.max(0, own.length - MAX_NONCES_PER_PARTITION + 1))) this.nonces.delete(key);
7549
+ if (this.nonces.size >= MAX_NONCES_TOTAL) {
7550
+ const oldest = this.nonces.keys().next();
7551
+ if (oldest.done !== true) this.nonces.delete(oldest.value);
7552
+ }
7553
+ const nonce = node_crypto.randomBytes(32).toString("base64url");
7554
+ const expiresAt = this.now() + require_dist.DEVICE_NONCE_TTL_MS;
7555
+ this.nonces.set(nonce, {
7556
+ origin,
7557
+ expiresAt,
7558
+ partition
7559
+ });
7560
+ return {
7561
+ nonce,
7562
+ expiresAt
7563
+ };
7564
+ }
7565
+ /**
7566
+ * Check a presented certificate + signature. Consumes the nonce on the first
7567
+ * attempt. On success the credential's lastUsed is stamped.
7568
+ */
7569
+ async authenticate(input) {
7570
+ const bound = this.nonces.get(input.nonce);
7571
+ if (bound === void 0) return this.refuse(input.purpose, "unknown-nonce", {});
7572
+ this.nonces.delete(input.nonce);
7573
+ if (this.now() > bound.expiresAt) return this.refuse(input.purpose, "expired-nonce", {});
7574
+ const caState = await this.deps.ca.lookup();
7575
+ if (caState.state !== "ready") return this.refuse(input.purpose, "no-device-ca", {});
7576
+ const verdict = await require_device_ca.verifyDeviceLeaf(caState.ca.certPem, input.certPem, new Date(this.now()));
7577
+ if (!verdict.ok) return this.refuse(input.purpose, verdict.reason, {});
7578
+ const message = require_dist.buildDeviceSignatureMessage({
7579
+ purpose: input.purpose,
7580
+ origin: bound.origin,
7581
+ nonce: input.nonce
7582
+ });
7583
+ if (!require_device_ca.verifyDeviceSignature(verdict.publicKeySpki, message, input.signature)) return this.refuse(input.purpose, "bad-signature", { credentialId: verdict.credentialId });
7584
+ const record = this.records.get(verdict.credentialId);
7585
+ if (record === void 0) return this.refuse(input.purpose, "unknown-credential", { credentialId: verdict.credentialId });
7586
+ const tags = {
7587
+ credentialId: record.id,
7588
+ userId: record.userId
7589
+ };
7590
+ if (record.revokedAt !== null) return this.refuse(input.purpose, "revoked", tags);
7591
+ if (verdict.userId !== null && verdict.userId !== record.userId) return this.refuse(input.purpose, "user-mismatch", tags);
7592
+ if (!verdict.publicKeySpki.equals(Buffer.from(record.publicKey, "base64url"))) return this.refuse(input.purpose, "key-mismatch", tags);
7593
+ if (verdict.fingerprint !== record.fingerprint) {
7594
+ if (!(record.previousFingerprint !== null && verdict.fingerprint === record.previousFingerprint && record.renewedAt !== null && this.now() - record.renewedAt <= 864e5)) return this.refuse(input.purpose, "superseded", tags);
7595
+ }
7596
+ const used = {
7597
+ ...record,
7598
+ lastUsedAt: this.now(),
7599
+ lastUsedFrom: input.from ?? record.lastUsedFrom
7600
+ };
7601
+ await this.save(used);
7602
+ return {
7603
+ ok: true,
7604
+ credential: used
7605
+ };
7606
+ }
7607
+ /** A new leaf for the SAME public key, after {@link authenticate} accepted a `renew` proof. */
7608
+ async renew(credential) {
7609
+ const current = this.records.get(credential.id);
7610
+ if (current === void 0) return this.refuse("renew", "unknown-credential", {});
7611
+ if (current.revokedAt !== null) return this.refuse("renew", "revoked", {
7612
+ credentialId: current.id,
7613
+ userId: current.userId
7614
+ });
7615
+ let ca;
7616
+ try {
7617
+ ca = await this.deps.ca.ensure();
7618
+ } catch {
7619
+ return {
7620
+ ok: false,
7621
+ reason: "ca-unavailable"
7622
+ };
7623
+ }
7624
+ const leaf = await require_device_ca.issueDeviceLeaf(ca, {
7625
+ credentialId: current.id,
7626
+ userId: current.userId,
7627
+ publicKeySpki: Buffer.from(current.publicKey, "base64url"),
7628
+ now: new Date(this.now())
7629
+ });
7630
+ const updated = {
7631
+ ...current,
7632
+ fingerprint: leaf.fingerprint,
7633
+ serial: leaf.serialNumber,
7634
+ previousFingerprint: current.fingerprint,
7635
+ renewedAt: this.now(),
7636
+ issuedAt: leaf.notBefore.getTime(),
7637
+ expiresAt: leaf.notAfter.getTime()
7638
+ };
7639
+ await this.save(updated);
7640
+ this.deps.logger?.info("Device certificate renewed", { tags: {
7641
+ credentialId: updated.id,
7642
+ userId: updated.userId
7643
+ } });
7644
+ return {
7645
+ ok: true,
7646
+ credentialId: updated.id,
7647
+ userId: updated.userId,
7648
+ certPem: leaf.certPem,
7649
+ chainPem: ca.certPem,
7650
+ notAfter: leaf.notAfter.getTime()
7651
+ };
7652
+ }
7653
+ /** Synchronous: the hub asks on every refresh of a credential-bound session. */
7654
+ check(credentialId) {
7655
+ const record = this.records.get(credentialId);
7656
+ if (record === void 0) return "unknown";
7657
+ if (record.revokedAt !== null) return "revoked";
7658
+ return record.expiresAt <= this.now() ? "expired" : "active";
7659
+ }
7660
+ get(credentialId) {
7661
+ return this.records.get(credentialId);
7662
+ }
7663
+ list(input) {
7664
+ return [...this.records.values()].filter((r) => input.userId === void 0 || r.userId === input.userId).filter((r) => input.includeRevoked || r.revokedAt === null).toSorted((a, b) => b.issuedAt - a.issuedAt);
7665
+ }
7666
+ /**
7667
+ * Delete a REVOKED row (admin clean-up). `live` refuses a credential that is
7668
+ * not revoked — a live credential is revoked first, so a delete can never be
7669
+ * the way a device silently stops working.
7670
+ */
7671
+ async delete(credentialId) {
7672
+ const record = this.records.get(credentialId);
7673
+ if (record === void 0) return "unknown";
7674
+ if (record.revokedAt === null) {
7675
+ this.deps.logger?.warn("Device credential delete refused: still live", { tags: {
7676
+ credentialId,
7677
+ userId: record.userId
7678
+ } });
7679
+ return "live";
7680
+ }
7681
+ this.records.delete(credentialId);
7682
+ await this.store.delete.mutate({
7683
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7684
+ key: credentialId
7685
+ });
7686
+ this.deps.logger?.info("Device credential row deleted", { tags: {
7687
+ credentialId,
7688
+ userId: record.userId
7689
+ } });
7690
+ return "deleted";
7691
+ }
7692
+ /** Revoke; `false` when unknown or already revoked. `revokedBy`: see `formatDeviceRevoker`. */
7693
+ async revoke(credentialId, revokedBy) {
7694
+ const record = this.records.get(credentialId);
7695
+ if (record === void 0 || record.revokedAt !== null) return false;
7696
+ await this.save({
7697
+ ...record,
7698
+ revokedAt: this.now(),
7699
+ revokedBy
7700
+ });
7701
+ this.deps.logger?.warn("Device credential revoked", {
7702
+ tags: {
7703
+ credentialId,
7704
+ userId: record.userId
7705
+ },
7706
+ meta: { revokedBy }
7707
+ });
7708
+ return true;
7709
+ }
7710
+ async rename(credentialId, deviceName) {
7711
+ const record = this.records.get(credentialId);
7712
+ if (record === void 0) return false;
7713
+ await this.save({
7714
+ ...record,
7715
+ deviceName
7716
+ });
7717
+ return true;
7718
+ }
7719
+ /** Revoke every credential of a user (account deleted). Returns their ids. */
7720
+ async revokeAllForUser(userId, revokedBy) {
7721
+ const ids = [...this.records.values()].filter((r) => r.userId === userId && r.revokedAt === null).map((r) => r.id);
7722
+ for (const id of ids) await this.revoke(id, revokedBy);
7723
+ return ids;
7724
+ }
7725
+ toSummary(record, username) {
7726
+ return {
7727
+ id: record.id,
7728
+ userId: record.userId,
7729
+ username,
7730
+ deviceName: record.deviceName,
7731
+ platform: record.platform,
7732
+ enrolledVia: record.enrolledVia,
7733
+ fingerprint: record.fingerprint,
7734
+ issuedAt: record.issuedAt,
7735
+ expiresAt: record.expiresAt,
7736
+ lastUsedAt: record.lastUsedAt,
7737
+ lastUsedFrom: record.lastUsedFrom,
7738
+ revokedAt: record.revokedAt,
7739
+ revokedBy: record.revokedBy,
7740
+ status: record.revokedAt !== null ? "revoked" : record.expiresAt <= this.now() ? "expired" : "active"
7741
+ };
7742
+ }
7743
+ /** Delete rows revoked or expired more than 90 days ago. */
7744
+ async reap() {
7745
+ const cutoff = this.now() - REAP_RETENTION_MS;
7746
+ const dead = [...this.records.values()].filter((r) => (r.revokedAt ?? r.expiresAt) < cutoff);
7747
+ for (const r of dead) {
7748
+ this.records.delete(r.id);
7749
+ await this.store.delete.mutate({
7750
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7751
+ key: r.id
7752
+ });
7753
+ }
7754
+ return dead.length;
7755
+ }
7756
+ async save(record) {
7757
+ this.records.set(record.id, record);
7758
+ try {
7759
+ await this.store.update.mutate({
7760
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7761
+ id: record.id,
7762
+ data: { ...record }
7763
+ });
7764
+ } catch (error) {
7765
+ this.deps.logger?.error("Device credential write failed — the change holds until restart", {
7766
+ tags: {
7767
+ credentialId: record.id,
7768
+ userId: record.userId
7769
+ },
7770
+ meta: { error: error instanceof Error ? error.message : String(error) }
7771
+ });
7772
+ }
7773
+ }
7774
+ refuse(leg, reason, context) {
7775
+ this.deps.logger?.warn(`Device credential ${leg} refused: ${reason}`, {
7776
+ tags: {
7777
+ ...context.credentialId !== void 0 ? { credentialId: context.credentialId } : {},
7778
+ ...context.userId !== void 0 ? { userId: context.userId } : {}
7779
+ },
7780
+ meta: {
7781
+ leg,
7782
+ reason,
7783
+ ...context.detail !== void 0 ? { detail: context.detail } : {}
7784
+ }
7785
+ });
7786
+ return {
7787
+ ok: false,
7788
+ reason
7789
+ };
7790
+ }
7791
+ prune() {
7792
+ const at = this.now();
7793
+ for (const [k, v] of this.pendingEnrollments) if (at > v.expiresAt) this.pendingEnrollments.delete(k);
7794
+ for (const [k, exp] of this.usedCodes) if (at > exp + 6e5) this.usedCodes.delete(k);
7795
+ for (const [k, v] of this.nonces) if (at > v.expiresAt) this.nonces.delete(k);
7796
+ }
7797
+ };
7798
+ //#endregion
7093
7799
  //#region src/auth/auth-audit-log.ts
7094
7800
  /**
7095
7801
  * The authentication audit trail: what an operator must be able to answer
@@ -7144,7 +7850,11 @@ var AUTH_AUDIT_ACTIONS = [
7144
7850
  "admin-token-revoked",
7145
7851
  "credentials-revoked",
7146
7852
  "agent-addon-update",
7147
- "agent-addon-update-refused"
7853
+ "agent-addon-update-refused",
7854
+ "device-credential-enrolled",
7855
+ "device-credential-revoked",
7856
+ "device-credential-login",
7857
+ "device-credential-login-refused"
7148
7858
  ];
7149
7859
  var RETENTION_MS = 90 * 864e5;
7150
7860
  var MAX_LIST = 500;
@@ -7270,7 +7980,7 @@ var USERS_COLLECTION$1 = "users";
7270
7980
  var FIRST_BOOT_ADMIN_USERNAME = "admin";
7271
7981
  var FIRST_BOOT_ADMIN_PASSWORD = "changeme";
7272
7982
  function parseUser(data) {
7273
- return parseRecord$1("user", require_dist.UserRecordSchema, data);
7983
+ return parseRecord$2("user", require_dist.UserRecordSchema, data);
7274
7984
  }
7275
7985
  var UserManager = class {
7276
7986
  storageAccess;
@@ -7441,7 +8151,7 @@ var JwtSigningKeyKeeper = class {
7441
8151
  return;
7442
8152
  }
7443
8153
  this.unsubscribe = secrets.onChange((ref) => {
7444
- if (ref === require_jwt_signing_key.JWT_SIGNING_KEY_REF) this.load(secrets);
8154
+ if (ref === require_device_ca.JWT_SIGNING_KEY_REF) this.load(secrets);
7445
8155
  });
7446
8156
  await this.load(secrets);
7447
8157
  }
@@ -7454,23 +8164,23 @@ var JwtSigningKeyKeeper = class {
7454
8164
  }
7455
8165
  async load(secrets) {
7456
8166
  if (this.stopped) return;
7457
- const next = await require_jwt_signing_key.loadJwtSigningKey({ secrets });
8167
+ const next = await require_device_ca.loadJwtSigningKey({ secrets });
7458
8168
  if (this.stopped) return;
7459
8169
  this.apply(next);
7460
- if (next.state === "unavailable" && require_jwt_signing_key.getProcessJwtSigningKeyState().state !== "ready") this.scheduleRetry(secrets);
8170
+ if (next.state === "unavailable" && require_device_ca.getProcessJwtSigningKeyState().state !== "ready") this.scheduleRetry(secrets);
7461
8171
  }
7462
8172
  apply(next) {
7463
- const previous = require_jwt_signing_key.getProcessJwtSigningKeyState();
8173
+ const previous = require_device_ca.getProcessJwtSigningKeyState();
7464
8174
  if (next.state === "ready") {
7465
8175
  if (previous.state !== "ready") this.deps.logger.info("Session signing key loaded from the keychain");
7466
- require_jwt_signing_key.setProcessJwtSigningKey(next);
8176
+ require_device_ca.setProcessJwtSigningKey(next);
7467
8177
  return;
7468
8178
  }
7469
8179
  if (previous.state === "ready") return;
7470
- require_jwt_signing_key.setProcessJwtSigningKey(next);
8180
+ require_device_ca.setProcessJwtSigningKey(next);
7471
8181
  this.deps.logger.error("Sessions are UNAVAILABLE: the session signing key cannot be read from the keychain. No one can sign in and no session token verifies until the keychain unlocks (System → Secrets; or fix keychain.keyFile in config.yaml and restart). No fallback key is used.", { meta: {
7472
8182
  reason: next.reason,
7473
- ref: require_jwt_signing_key.JWT_SIGNING_KEY_REF
8183
+ ref: require_device_ca.JWT_SIGNING_KEY_REF
7474
8184
  } });
7475
8185
  }
7476
8186
  scheduleRetry(secrets) {
@@ -7864,6 +8574,14 @@ async function declareAuthSchema(store) {
7864
8574
  columns: ["createdBy"]
7865
8575
  }]
7866
8576
  });
8577
+ await store.declareCollection.mutate({
8578
+ collection: DEVICE_CREDENTIALS_COLLECTION,
8579
+ columns: DEVICE_CREDENTIALS_COLUMNS,
8580
+ indexes: [{
8581
+ name: "idx_device_credentials_user_id",
8582
+ columns: ["userId"]
8583
+ }]
8584
+ });
7867
8585
  await store.declareCollection.mutate({
7868
8586
  collection: AUTH_AUDIT_COLLECTION,
7869
8587
  columns: AUTH_AUDIT_COLUMNS,
@@ -10761,6 +11479,8 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10761
11479
  loginSessions = null;
10762
11480
  adminTokens = null;
10763
11481
  auditLog = null;
11482
+ deviceCredentials = null;
11483
+ deviceCa = null;
10764
11484
  constructor() {
10765
11485
  super({});
10766
11486
  }
@@ -10790,6 +11510,25 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10790
11510
  await this.loginSessions.load();
10791
11511
  this.adminTokens = new AdminTokenManager(store);
10792
11512
  this.auditLog = new AuthAuditLog(store, this.ctx.logger);
11513
+ const deviceUsers = this.userManager;
11514
+ this.deviceCa = new DeviceCaStore({
11515
+ secrets: forOwner === null ? null : forOwner("system"),
11516
+ readSection: async (section) => await this.ctx.settings?.getSection(section) ?? {},
11517
+ writeSection: async (section, patch) => {
11518
+ await this.ctx.settings?.setSection(section, patch);
11519
+ },
11520
+ logger: this.ctx.logger
11521
+ });
11522
+ this.deviceCredentials = new DeviceCredentialManager(store, {
11523
+ ca: this.deviceCa,
11524
+ findUser: async (userId) => {
11525
+ const user = await deviceUsers.findById(userId);
11526
+ return user === null ? null : { username: user.username };
11527
+ },
11528
+ selfEnrollmentAllowed: async () => (await this.readDeviceCertificateSettings()).selfEnrollment === "allowed",
11529
+ logger: this.ctx.logger
11530
+ });
11531
+ await this.deviceCredentials.load();
10793
11532
  try {
10794
11533
  await reconcileAdminAccountAtBoot({
10795
11534
  users: this.userManager,
@@ -10889,6 +11628,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
10889
11628
  if (!this.userManager) throw new Error("User management not available");
10890
11629
  await this.userManager.resetPassword(input.id, input.newPassword);
10891
11630
  await this.loginSessions?.revokeAllForUser(input.id, { reason: "password-reset" });
11631
+ await this.revokeDeviceCredentialsForReset(input.id);
10892
11632
  return { success: true };
10893
11633
  },
10894
11634
  setUserScopes: async (input) => {
@@ -11013,13 +11753,154 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11013
11753
  };
11014
11754
  this.ctx.logger.info("registered auth-provider + user-management capabilities");
11015
11755
  attachUserManagementInternal(userMgmt, this.buildInternal());
11016
- return [{
11017
- capability: require_dist.authProviderCapability,
11018
- provider: authProvider
11019
- }, {
11020
- capability: require_dist.userManagementCapability,
11021
- provider: userMgmt
11022
- }];
11756
+ return [
11757
+ {
11758
+ capability: require_dist.authProviderCapability,
11759
+ provider: authProvider
11760
+ },
11761
+ {
11762
+ capability: require_dist.userManagementCapability,
11763
+ provider: userMgmt
11764
+ },
11765
+ {
11766
+ capability: require_dist.deviceCredentialsCapability,
11767
+ provider: this.buildDeviceCredentialsProvider()
11768
+ }
11769
+ ];
11770
+ }
11771
+ async readDeviceCertificateSettings() {
11772
+ return { selfEnrollment: (await this.ctx.settings?.getSection("deviceCertificates") ?? {})["selfEnrollment"] === "disabled" ? "disabled" : "allowed" };
11773
+ }
11774
+ async revokeDeviceCredentialsForReset(userId, by = "password-reset") {
11775
+ const ids = await this.deviceCredentials?.revokeAllForUser(userId, by) ?? [];
11776
+ for (const id of ids) {
11777
+ const ended = await this.loginSessions?.revokeByCredential(id) ?? 0;
11778
+ await this.auditLog?.record({
11779
+ action: "device-credential-revoked",
11780
+ userId,
11781
+ detail: {
11782
+ credentialId: id,
11783
+ by,
11784
+ sessionsEnded: ended
11785
+ }
11786
+ });
11787
+ }
11788
+ }
11789
+ /** The admin cap (`device-credentials`): list, enroll-by-QR, revoke, rename, settings. */
11790
+ buildDeviceCredentialsProvider() {
11791
+ const manager = () => {
11792
+ if (this.deviceCredentials === null) throw new Error("Device credentials not available");
11793
+ return this.deviceCredentials;
11794
+ };
11795
+ const hubOrigins = async () => {
11796
+ try {
11797
+ const book = await this.ctx.api?.localNetwork?.getAddresses?.query({});
11798
+ if (!book) return [];
11799
+ const ordered = [book.roles.publicEndpoint.resolved, ...book.roles.appConnections.resolved];
11800
+ return [...new Set(ordered.filter((o) => typeof o === "string"))].filter((o) => !/^https?:\/\/(127\.|localhost|\[::1\])/i.test(o));
11801
+ } catch (err) {
11802
+ this.ctx.logger.warn("Enrollment link: the hub address could not be resolved", { meta: { error: err instanceof Error ? err.message : String(err) } });
11803
+ return [];
11804
+ }
11805
+ };
11806
+ return {
11807
+ listDeviceCredentials: async (input) => {
11808
+ const users = new Map((await this.userManager?.listAll() ?? []).map((u) => [u.id, u.username]));
11809
+ return manager().list({
11810
+ ...input.userId !== void 0 ? { userId: input.userId } : {},
11811
+ includeRevoked: input.includeRevoked
11812
+ }).map((r) => manager().toSummary(r, users.get(r.userId) ?? null));
11813
+ },
11814
+ startEnrollment: async (input) => {
11815
+ if (await this.userManager?.findById(input.userId) == null) throw new Error(`User not found: ${input.userId}`);
11816
+ const started = manager().startEnrollment({
11817
+ userId: input.userId,
11818
+ deviceName: input.deviceName,
11819
+ createdBy: input.caller.userId
11820
+ });
11821
+ const origins = await hubOrigins();
11822
+ const hub = origins[0];
11823
+ await this.auditLog?.record({
11824
+ action: "device-credential-enrolled",
11825
+ userId: input.userId,
11826
+ detail: {
11827
+ stage: "code-issued",
11828
+ by: input.caller.userId
11829
+ }
11830
+ });
11831
+ return {
11832
+ code: started.code,
11833
+ expiresAt: started.expiresAt,
11834
+ enrollUrl: require_dist.buildDeviceEnrollmentLink({
11835
+ ...hub !== void 0 ? { hub } : {},
11836
+ code: started.code
11837
+ }),
11838
+ hubOrigin: hub ?? null,
11839
+ hubOrigins: [...origins]
11840
+ };
11841
+ },
11842
+ revokeDeviceCredential: async (input) => {
11843
+ const record = manager().get(input.id);
11844
+ if (record === void 0) throw new Error(`Device credential not found: ${input.id}`);
11845
+ const revoked = await manager().revoke(input.id, require_dist.formatDeviceRevoker({
11846
+ kind: "admin",
11847
+ userId: input.caller.userId
11848
+ }));
11849
+ const sessionsEnded = await this.loginSessions?.revokeByCredential(input.id) ?? 0;
11850
+ if (revoked) await this.auditLog?.record({
11851
+ action: "device-credential-revoked",
11852
+ userId: record.userId,
11853
+ detail: {
11854
+ credentialId: input.id,
11855
+ by: input.caller.userId,
11856
+ sessionsEnded
11857
+ }
11858
+ });
11859
+ return {
11860
+ revoked,
11861
+ sessionsEnded,
11862
+ status: "revoked"
11863
+ };
11864
+ },
11865
+ deleteDeviceCredential: async (input) => {
11866
+ const record = manager().get(input.id);
11867
+ const result = await manager().delete(input.id);
11868
+ if (result === "deleted" && record !== void 0) await this.auditLog?.record({
11869
+ action: "device-credential-revoked",
11870
+ userId: record.userId,
11871
+ detail: {
11872
+ credentialId: input.id,
11873
+ stage: "row-deleted",
11874
+ by: input.caller.userId
11875
+ }
11876
+ });
11877
+ return { result };
11878
+ },
11879
+ renameDeviceCredential: async (input) => ({ renamed: await manager().rename(input.id, input.deviceName) }),
11880
+ getDeviceCaStatus: async () => {
11881
+ if (this.deviceCa === null) return {
11882
+ state: "unavailable",
11883
+ reason: "not initialised"
11884
+ };
11885
+ const found = await this.deviceCa.lookup();
11886
+ if (found.state !== "ready") return found.state === "absent" ? { state: "absent" } : {
11887
+ state: "unavailable",
11888
+ reason: found.reason
11889
+ };
11890
+ const x509 = new node_crypto.X509Certificate(found.ca.certPem);
11891
+ return {
11892
+ state: "ready",
11893
+ subject: x509.subject,
11894
+ fingerprintSha256: x509.fingerprint256,
11895
+ validTo: x509.validTo
11896
+ };
11897
+ },
11898
+ getDeviceCertificateSettings: async () => this.readDeviceCertificateSettings(),
11899
+ setDeviceCertificateSettings: async (input) => {
11900
+ await this.ctx.settings?.setSection("deviceCertificates", { selfEnrollment: input.selfEnrollment });
11901
+ return this.readDeviceCertificateSettings();
11902
+ }
11903
+ };
11023
11904
  }
11024
11905
  /**
11025
11906
  * The hub-internal surface (`user-management-internal.ts`): operations no
@@ -11030,6 +11911,10 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11030
11911
  if (this.loginSessions === null) throw new Error("Login sessions not available");
11031
11912
  return this.loginSessions;
11032
11913
  };
11914
+ const devices = () => {
11915
+ if (this.deviceCredentials === null) throw new Error("Device credentials not available");
11916
+ return this.deviceCredentials;
11917
+ };
11033
11918
  return {
11034
11919
  sessions: {
11035
11920
  create: (input) => sessions().create(input),
@@ -11040,7 +11925,20 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11040
11925
  revoke: (id, reason) => sessions().revoke(id, reason),
11041
11926
  revokeAllForUser: (userId, options) => sessions().revokeAllForUser(userId, options),
11042
11927
  listForUser: (userId) => sessions().listForUser(userId),
11043
- listActive: () => sessions().listActive()
11928
+ listActive: () => sessions().listActive(),
11929
+ revokeByCredential: (credentialId, reason) => sessions().revokeByCredential(credentialId, reason),
11930
+ peekByRefreshToken: (token) => sessions().peekByRefreshToken(token)
11931
+ },
11932
+ deviceCredentials: {
11933
+ completeEnrollment: (input) => devices().completeEnrollment(input),
11934
+ enrollSelf: (input) => devices().enrollSelf(input),
11935
+ issueNonce: (origin) => devices().issueNonce(origin),
11936
+ authenticate: (input) => devices().authenticate(input),
11937
+ renew: (credential) => devices().renew(credential),
11938
+ check: (id) => devices().check(id),
11939
+ get: (id) => devices().get(id),
11940
+ revoke: (id, by) => devices().revoke(id, by),
11941
+ list: (input) => devices().list(input)
11044
11942
  },
11045
11943
  adminTokens: {
11046
11944
  create: async (input) => {
@@ -11078,6 +11976,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11078
11976
  ...input.keepSessionId !== void 0 ? { exceptSessionId: input.keepSessionId } : {},
11079
11977
  reason: "password-changed"
11080
11978
  });
11979
+ await this.revokeDeviceCredentialsForReset(input.userId, "password-changed");
11081
11980
  },
11082
11981
  linkExternalIdentity: async (input) => {
11083
11982
  if (!this.externalIdentities || !this.userManager) throw new Error("External sign-in linking not available");
@@ -11104,6 +12003,9 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11104
12003
  for (const session of await this.oauthSessionManager?.list() ?? []) if (session.userId === userId && session.revokedAt === null) await this.oauthSessionManager?.markRevoked(session.id);
11105
12004
  }],
11106
12005
  ["admin tokens", async () => this.adminTokens?.revokeAllCreatedBy(userId)],
12006
+ ["device credentials", async () => {
12007
+ for (const id of await this.deviceCredentials?.revokeAllForUser(userId, "user-deleted") ?? []) await this.loginSessions?.revokeByCredential(id);
12008
+ }],
11107
12009
  ["totp", async () => this.totpManager?.disable(userId)],
11108
12010
  ["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)]
11109
12011
  ];
@@ -11133,6 +12035,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11133
12035
  const logins = await this.loginSessions?.reap() ?? 0;
11134
12036
  await this.adminTokens?.reap();
11135
12037
  await this.auditLog?.reap();
12038
+ await this.deviceCredentials?.reap();
11136
12039
  if (tokens > 0 || sessions > 0 || logins > 0) this.ctx.logger.info("auth retention swept", { meta: {
11137
12040
  expiredTokens: tokens,
11138
12041
  revokedSessions: sessions,
@@ -11158,6 +12061,8 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11158
12061
  this.loginSessions = null;
11159
12062
  this.adminTokens = null;
11160
12063
  this.auditLog = null;
12064
+ this.deviceCredentials = null;
12065
+ this.deviceCa = null;
11161
12066
  }
11162
12067
  };
11163
12068
  //#endregion
@@ -11167,11 +12072,14 @@ exports.AdminTokenManager = AdminTokenManager;
11167
12072
  exports.ApiKeyManager = ApiKeyManager;
11168
12073
  exports.AuthAuditLog = AuthAuditLog;
11169
12074
  exports.AuthManager = AuthManager;
12075
+ exports.DeviceCaStore = DeviceCaStore;
12076
+ exports.DeviceCredentialManager = DeviceCredentialManager;
11170
12077
  exports.FIRST_BOOT_ADMIN_PASSWORD = FIRST_BOOT_ADMIN_PASSWORD;
11171
12078
  exports.FIRST_BOOT_ADMIN_USERNAME = FIRST_BOOT_ADMIN_USERNAME;
11172
12079
  exports.LocalAuthAddon = LocalAuthAddon;
11173
12080
  exports.default = LocalAuthAddon;
11174
12081
  exports.LoginSessionManager = LoginSessionManager;
12082
+ exports.PREVIOUS_LEAF_GRACE_MS = PREVIOUS_LEAF_GRACE_MS;
11175
12083
  exports.REFRESH_TOKEN_PREFIX = REFRESH_TOKEN_PREFIX;
11176
12084
  exports.ScopedTokenManager = ScopedTokenManager;
11177
12085
  exports.SettingsReadUnavailableError = SettingsReadUnavailableError;