agent-comms 9.0.11 → 9.0.13

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 (172) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +12 -6
  4. package/dist/account-cli.d.ts.map +1 -1
  5. package/dist/account-cli.js +4 -1
  6. package/dist/account-cli.js.map +1 -1
  7. package/dist/bridges/user/web/dist/bundle.js +1 -1
  8. package/dist/bridges/user/web/dist/index.html +1 -7
  9. package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
  10. package/dist/bridges/user/web/dist/sw.js +1 -1
  11. package/dist/bridges/user/web/hosted-dashboard.d.ts +10 -0
  12. package/dist/bridges/user/web/hosted-dashboard.d.ts.map +1 -0
  13. package/dist/bridges/user/web/hosted-dashboard.js +10 -0
  14. package/dist/bridges/user/web/hosted-dashboard.js.map +1 -0
  15. package/dist/bridges/user/web/web-access.d.ts +0 -2
  16. package/dist/bridges/user/web/web-access.d.ts.map +1 -1
  17. package/dist/bridges/user/web/web-access.js +1 -2
  18. package/dist/bridges/user/web/web-access.js.map +1 -1
  19. package/dist/cli-main.d.ts.map +1 -1
  20. package/dist/cli-main.js +9 -12
  21. package/dist/cli-main.js.map +1 -1
  22. package/dist/core/account-bundle.d.ts +1 -1
  23. package/dist/core/account-bundle.d.ts.map +1 -1
  24. package/dist/core/account-bundle.js +26 -19
  25. package/dist/core/account-bundle.js.map +1 -1
  26. package/dist/core/account-join.d.ts.map +1 -1
  27. package/dist/core/account-join.js +5 -2
  28. package/dist/core/account-join.js.map +1 -1
  29. package/dist/core/account-ledger-crypto.d.ts.map +1 -1
  30. package/dist/core/account-ledger-crypto.js +4 -1
  31. package/dist/core/account-ledger-crypto.js.map +1 -1
  32. package/dist/core/account-ledger-store.d.ts +3 -1
  33. package/dist/core/account-ledger-store.d.ts.map +1 -1
  34. package/dist/core/account-ledger-store.js +6 -3
  35. package/dist/core/account-ledger-store.js.map +1 -1
  36. package/dist/core/account-ledger.d.ts +51 -4
  37. package/dist/core/account-ledger.d.ts.map +1 -1
  38. package/dist/core/account-ledger.js +102 -15
  39. package/dist/core/account-ledger.js.map +1 -1
  40. package/dist/core/bridge-mesh.d.ts.map +1 -1
  41. package/dist/core/bridge-mesh.js +3 -0
  42. package/dist/core/bridge-mesh.js.map +1 -1
  43. package/dist/core/bridge.d.ts.map +1 -1
  44. package/dist/core/bridge.js +16 -4
  45. package/dist/core/bridge.js.map +1 -1
  46. package/dist/core/capability-ask.d.ts.map +1 -1
  47. package/dist/core/capability-ask.js +10 -2
  48. package/dist/core/capability-ask.js.map +1 -1
  49. package/dist/core/connection-code-tool.d.ts +1 -1
  50. package/dist/core/connection-code-tool.js +2 -2
  51. package/dist/core/connection-code-tool.js.map +1 -1
  52. package/dist/core/connection-code.d.ts +5 -5
  53. package/dist/core/connection-code.d.ts.map +1 -1
  54. package/dist/core/connection-code.js +22 -6
  55. package/dist/core/connection-code.js.map +1 -1
  56. package/dist/core/device-membership.d.ts +8 -3
  57. package/dist/core/device-membership.d.ts.map +1 -1
  58. package/dist/core/device-membership.js +4 -4
  59. package/dist/core/device-membership.js.map +1 -1
  60. package/dist/core/dm-grant-actions.d.ts.map +1 -1
  61. package/dist/core/dm-grant-actions.js +7 -1
  62. package/dist/core/dm-grant-actions.js.map +1 -1
  63. package/dist/core/error-reporting.d.ts.map +1 -1
  64. package/dist/core/error-reporting.js +5 -0
  65. package/dist/core/error-reporting.js.map +1 -1
  66. package/dist/core/gateway-trust.d.ts +1 -1
  67. package/dist/core/gateway-trust.d.ts.map +1 -1
  68. package/dist/core/gateway-trust.js +4 -4
  69. package/dist/core/gateway-trust.js.map +1 -1
  70. package/dist/core/group-proofs.d.ts.map +1 -1
  71. package/dist/core/group-proofs.js +2 -2
  72. package/dist/core/group-proofs.js.map +1 -1
  73. package/dist/core/identity-store.d.ts +13 -9
  74. package/dist/core/identity-store.d.ts.map +1 -1
  75. package/dist/core/identity-store.js +122 -147
  76. package/dist/core/identity-store.js.map +1 -1
  77. package/dist/core/index.d.ts +1 -0
  78. package/dist/core/index.d.ts.map +1 -1
  79. package/dist/core/index.js +1 -0
  80. package/dist/core/index.js.map +1 -1
  81. package/dist/core/iso-timestamp.d.ts +10 -0
  82. package/dist/core/iso-timestamp.d.ts.map +1 -0
  83. package/dist/core/iso-timestamp.js +19 -0
  84. package/dist/core/iso-timestamp.js.map +1 -0
  85. package/dist/core/issuer-identity-file.d.ts +15 -0
  86. package/dist/core/issuer-identity-file.d.ts.map +1 -1
  87. package/dist/core/issuer-identity-file.js +69 -20
  88. package/dist/core/issuer-identity-file.js.map +1 -1
  89. package/dist/core/machine-identity.d.ts +1 -1
  90. package/dist/core/machine-identity.d.ts.map +1 -1
  91. package/dist/core/machine-identity.js +14 -11
  92. package/dist/core/machine-identity.js.map +1 -1
  93. package/dist/core/mesh-store.d.ts +2 -2
  94. package/dist/core/mesh-store.d.ts.map +1 -1
  95. package/dist/core/mesh-store.js.map +1 -1
  96. package/dist/core/peer-lifecycle.d.ts.map +1 -1
  97. package/dist/core/peer-lifecycle.js +8 -2
  98. package/dist/core/peer-lifecycle.js.map +1 -1
  99. package/dist/core/pid-lock-file.d.ts +27 -4
  100. package/dist/core/pid-lock-file.d.ts.map +1 -1
  101. package/dist/core/pid-lock-file.js +120 -11
  102. package/dist/core/pid-lock-file.js.map +1 -1
  103. package/dist/core/process-start.d.ts +10 -0
  104. package/dist/core/process-start.d.ts.map +1 -0
  105. package/dist/core/process-start.js +43 -0
  106. package/dist/core/process-start.js.map +1 -0
  107. package/dist/core/room-lifecycle.d.ts +3 -2
  108. package/dist/core/room-lifecycle.d.ts.map +1 -1
  109. package/dist/core/room-lifecycle.js +9 -8
  110. package/dist/core/room-lifecycle.js.map +1 -1
  111. package/dist/core/room-protocol.d.ts.map +1 -1
  112. package/dist/core/room-protocol.js +25 -3
  113. package/dist/core/room-protocol.js.map +1 -1
  114. package/dist/core/stale-agent-checker.d.ts.map +1 -1
  115. package/dist/core/stale-agent-checker.js +3 -1
  116. package/dist/core/stale-agent-checker.js.map +1 -1
  117. package/dist/core/store.d.ts +3 -0
  118. package/dist/core/store.d.ts.map +1 -1
  119. package/dist/core/store.js +42 -20
  120. package/dist/core/store.js.map +1 -1
  121. package/dist/core/tool.d.ts +5 -1
  122. package/dist/core/tool.d.ts.map +1 -1
  123. package/dist/core/tool.js +7 -1
  124. package/dist/core/tool.js.map +1 -1
  125. package/dist/core/types.d.ts +1 -1
  126. package/dist/core/types.d.ts.map +1 -1
  127. package/dist/core/types.js +8 -3
  128. package/dist/core/types.js.map +1 -1
  129. package/dist/core/user-identity.d.ts +5 -5
  130. package/dist/core/user-identity.d.ts.map +1 -1
  131. package/dist/core/user-identity.js +59 -45
  132. package/dist/core/user-identity.js.map +1 -1
  133. package/package.json +1 -1
  134. package/src/account-cli.ts +4 -1
  135. package/src/bridges/user/web/dist/bundle.js +1 -1
  136. package/src/bridges/user/web/dist/index.html +1 -7
  137. package/src/bridges/user/web/dist/mesh-worker.js +4 -4
  138. package/src/bridges/user/web/dist/sw.js +1 -1
  139. package/src/bridges/user/web/hosted-dashboard.ts +11 -0
  140. package/src/bridges/user/web/web-access.ts +1 -3
  141. package/src/cli-main.ts +10 -12
  142. package/src/core/account-bundle.ts +36 -25
  143. package/src/core/account-join.ts +5 -2
  144. package/src/core/account-ledger-crypto.ts +4 -1
  145. package/src/core/account-ledger-store.ts +11 -3
  146. package/src/core/account-ledger.ts +142 -20
  147. package/src/core/bridge-mesh.ts +4 -0
  148. package/src/core/bridge.ts +24 -4
  149. package/src/core/capability-ask.ts +16 -1
  150. package/src/core/connection-code-tool.ts +2 -2
  151. package/src/core/connection-code.ts +34 -8
  152. package/src/core/device-membership.ts +12 -8
  153. package/src/core/dm-grant-actions.ts +7 -1
  154. package/src/core/error-reporting.ts +5 -0
  155. package/src/core/gateway-trust.ts +4 -4
  156. package/src/core/group-proofs.ts +5 -2
  157. package/src/core/identity-store.ts +182 -191
  158. package/src/core/index.ts +1 -0
  159. package/src/core/iso-timestamp.ts +21 -0
  160. package/src/core/issuer-identity-file.ts +87 -23
  161. package/src/core/machine-identity.ts +15 -12
  162. package/src/core/mesh-store.ts +2 -1
  163. package/src/core/peer-lifecycle.ts +9 -4
  164. package/src/core/pid-lock-file.ts +139 -11
  165. package/src/core/process-start.ts +45 -0
  166. package/src/core/room-lifecycle.ts +12 -9
  167. package/src/core/room-protocol.ts +23 -3
  168. package/src/core/stale-agent-checker.ts +3 -1
  169. package/src/core/store.ts +48 -23
  170. package/src/core/tool.ts +14 -1
  171. package/src/core/types.ts +9 -3
  172. package/src/core/user-identity.ts +65 -51
@@ -45,6 +45,8 @@ interface PendingCapabilityAsk {
45
45
  scope: Readonly<CapabilityScope>;
46
46
  requesterDevice: DeviceId;
47
47
  decide: (decision: Readonly<CapabilityGrantDecision>) => Promise<void>;
48
+ /** Drops this ask from the pending set when wire-mesh-core's own window for it has passed. */
49
+ expiry: ReturnType<typeof setTimeout>;
48
50
  }
49
51
 
50
52
  export class CapabilityAskAdmission {
@@ -63,21 +65,32 @@ export class CapabilityAskAdmission {
63
65
  bearerDevice: options.bearerDevice,
64
66
  timeoutMs: options.timeoutMs,
65
67
  onRequest: (event: Readonly<CapabilityGrantRequestEvent>) => {
66
- this.handleCapabilityRequest(options.capability, event);
68
+ this.handleCapabilityRequest(
69
+ options.capability,
70
+ options.timeoutMs,
71
+ event,
72
+ );
67
73
  },
68
74
  });
69
75
  }
70
76
 
71
77
  private handleCapabilityRequest(
72
78
  capability: string,
79
+ timeoutMs: number,
73
80
  event: Readonly<CapabilityGrantRequestEvent>,
74
81
  ): void {
75
82
  const requestId = nanoid();
83
+ // wire-mesh-core answers the requester with a timeout once the window passes and ignores any later decision, but never tells this side, so an ask left in the pending set would stay listed and a later accept would report success for a request nobody is waiting on. Its own timer was started just before onRequest ran, so this one, for the same period, fires after it.
84
+ const expiry = setTimeout(() => {
85
+ this.pending.delete(requestId);
86
+ }, timeoutMs);
87
+ expiry.unref();
76
88
  this.pending.set(requestId, {
77
89
  capability,
78
90
  scope: event.scope,
79
91
  requesterDevice: event.requesterDevice,
80
92
  decide: event.decide,
93
+ expiry,
81
94
  });
82
95
 
83
96
  const deliveryEvent: DeliveryEvent = {
@@ -126,6 +139,7 @@ export class CapabilityAskAdmission {
126
139
  ): Promise<void> {
127
140
  const pending = this.requirePending(requestId);
128
141
  this.pending.delete(requestId);
142
+ clearTimeout(pending.expiry);
129
143
  await pending.decide({
130
144
  kind: "accept",
131
145
  capability: options.capability ?? pending.capability,
@@ -143,6 +157,7 @@ export class CapabilityAskAdmission {
143
157
  ): Promise<void> {
144
158
  const pending = this.requirePending(requestId);
145
159
  this.pending.delete(requestId);
160
+ clearTimeout(pending.expiry);
146
161
  await pending.decide({
147
162
  kind: "reject",
148
163
  ...(reason !== undefined ? { reason } : {}),
@@ -37,7 +37,7 @@ export async function handleGatewayGenerateConnectionCode(
37
37
  }
38
38
 
39
39
  /**
40
- * Validates a candidate connection code (assembled from action's own code/expiresAt/device/signature fields) and, on success, trusts the device-id it vouches for. When the candidate carries a signature but action supplies a fingerprint with no publicKey, fetches the signer's public key from a keyserver first via fetchPgpPublicKeyByFingerprintImpl -- injected rather than imported directly so a test can supply a fake resolver instead of making a real network call.
40
+ * Validates a candidate connection code (assembled from action's own code/expiresAt/device/signature fields) and, on success, trusts the device-id it vouches for. An unsigned code is a bearer hint, never authentication: whoever produced the text decided which device-id it names. Supplying a publicKey or fingerprint requires the code to be signed, and an unsigned one is then refused. When the candidate carries a signature but action supplies a fingerprint with no publicKey, fetches the signer's public key from a keyserver first via fetchPgpPublicKeyByFingerprintImpl, injected rather than imported directly so a test can supply a fake resolver instead of making a real network call.
41
41
  */
42
42
  export async function handleGatewayRedeemConnectionCode(
43
43
  store: Readonly<MeshOnlyFeatures>,
@@ -86,7 +86,7 @@ export async function handleGatewayRedeemConnectionCode(
86
86
  const fingerprintNote =
87
87
  result.fingerprint !== undefined
88
88
  ? ` (signature verified, signing key fingerprint ${result.fingerprint})`
89
- : " (no signature; freshness only)";
89
+ : " (unsigned: the code was fresh and unused, which says nothing about who issued it)";
90
90
  return `Trusted remote gateway device ${result.deviceId} via connection code${fingerprintNote}.`;
91
91
  });
92
92
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ConnectionCode generation and redemption (agent-comms#188): bootstraps GatewayTrust between two devices that have never established a mesh connection, since GatewayTrust's own deny-by-default, pin-the-key model otherwise requires a device-id to already be known and pasted in by hand (gateway_trust). One artifact, one generation flow, one redemption flow -- never two mechanisms bolted together.
3
3
  *
4
- * `code`/`expiresAt` are the always-checked half: freshness/liveness proof that whoever redeems this was on the other end of this exact exchange, recently. `signature` is the optional half: a detached PGP signature (armored) over `${code}:${expiresAt}:${deviceId}`, checked only when present. Redemption never requires a signature -- a device with no PGP identity still generates and redeems a bare code -- but when one is present, verifying it needs the signer's own public key, supplied by the caller either as a pasted armored block or (see pgp-keyserver.ts) fetched from a keyserver by fingerprint. Either way, a caller who also supplies the fingerprint they already independently trust gets it pinned: the verified key's own computed fingerprint must equal it, or redemption fails.
4
+ * `code`/`expiresAt` are the always-checked half: freshness/liveness proof that whoever redeems this was on the other end of this exact exchange, recently. `expiresAt` must be an ISO 8601 timestamp; a code whose expiry does not parse is refused as invalid. `signature` is the optional half: a detached PGP signature (armored) over `${code}:${expiresAt}:${deviceId}`. An unsigned code is a bearer hint and authenticates nothing about who issued it: redeeming one trusts whatever device-id it names, so a device with no PGP identity can still generate and redeem a bare code, but only where the redeemer has some other reason to believe it. A redeemer who supplies a public key or a fingerprint is asking for the issuer to be authenticated, so the code must then carry a signature that verifies against that key, and a code without one is refused. Verifying needs the signer's own public key, supplied by the caller either as a pasted armored block or (see pgp-keyserver.ts) fetched from a keyserver by fingerprint. A caller who also supplies the fingerprint they already independently trust gets it pinned: the verified key's own computed fingerprint must equal it, or redemption fails.
5
5
  *
6
6
  * PGP key material is never generated, stored, or managed by this module or by agent-comms generally -- signing and verification both take key material supplied per call by the caller, exactly as invoking `gpg --sign`/`gpg --verify` directly would. Building a persisted PGP-identity subsystem (key generation, passphrase storage, revocation) is a materially larger, security-sensitive scope the settled design (agent-comms#188) never asked for; a caller who wants a code signed brings whatever real-world PGP identity they already have (git-commit signing key, personal key on a keyserver, etc.) rather than trusting agent-comms to mint and hold a new one on their behalf.
7
7
  *
@@ -15,6 +15,7 @@ import {
15
15
  saveConnectionCodeLedger,
16
16
  } from "./identity-store.js";
17
17
  import type { IdentitySlot, StoredConnectionCode } from "./identity-store.js";
18
+ import { parseIsoTimestampMs } from "./iso-timestamp.js";
18
19
  import type { ConnectionCode } from "./types.js";
19
20
 
20
21
  /** Nonce length: longer than nanoid's own 21-character default, since this identifier doubles as the single-use token a redemption check keys off, not merely a display id. */
@@ -35,7 +36,9 @@ export const DEFAULT_CONNECTION_CODE_TTL_MS =
35
36
  export type ConnectionCodeInvalidReason =
36
37
  | "expired"
37
38
  | "already_redeemed"
39
+ | "invalid_expiry"
38
40
  | "signature_required"
41
+ | "signature_missing"
39
42
  | "signature_invalid"
40
43
  | "fingerprint_mismatch";
41
44
 
@@ -140,9 +143,9 @@ export interface GenerateConnectionCodeOptions {
140
143
  }
141
144
 
142
145
  export interface RedeemConnectionCodeOptions {
143
- /** The signer's own armored PGP public key, needed only when the candidate code carries a signature. */
146
+ /** The signer's own armored PGP public key. Supplying it asks for the code's issuer to be authenticated: a candidate without a signature is refused with "signature_missing", and one with a signature must verify against this key. */
144
147
  publicKeyArmored?: string;
145
- /** A PGP fingerprint the caller already has independent reason to trust. When supplied alongside a signed code, the verified signing key's own computed fingerprint must equal it (case- and whitespace-insensitive) or redemption fails with "fingerprint_mismatch". When omitted, a present signature is still verified against publicKeyArmored, but nothing pins it to a fingerprint the caller actually recognises. */
148
+ /** A PGP fingerprint the caller already has independent reason to trust. Supplying it asks for the code's issuer to be authenticated: a candidate without a signature is refused with "signature_missing", and the verified signing key's own computed fingerprint must equal it (case- and whitespace-insensitive) or redemption fails with "fingerprint_mismatch". When omitted, a present signature is still verified against publicKeyArmored, but nothing pins it to a fingerprint the caller actually recognises. */
146
149
  expectedFingerprint?: string;
147
150
  /** Injectable clock for tests; defaults to Date.now(). */
148
151
  now?: number;
@@ -155,8 +158,14 @@ export interface RedeemConnectionCodeResult {
155
158
  fingerprint?: string;
156
159
  }
157
160
 
161
+ /** Whether an expiry given as text has passed at `now`. An expiry that does not parse counts as passed: it can never be compared against the clock, so nothing may rely on it staying valid. */
162
+ function hasExpired(expiresAt: string, now: number): boolean {
163
+ const expiresAtMs = parseIsoTimestampMs(expiresAt);
164
+ return expiresAtMs === undefined || expiresAtMs <= now;
165
+ }
166
+
158
167
  /**
159
- * Generates and redeems ConnectionCode artifacts, enforcing the always-checked freshness/liveness half (expiry, single-use) and the optional PGP-signature half (checked only when a code carries one). Optionally persisted per bridge slot via identity-store.ts's connection-code ledger (see this file's own header comment for what survives a restart and why); constructed with no slot, stays in-memory only, mirroring GatewayTrust's own no-slot fallback.
168
+ * Generates and redeems ConnectionCode artifacts, enforcing the always-checked freshness/liveness half (expiry, single-use) and the PGP-signature half (verified when a code carries a signature, and required whenever the redeemer supplies a key or fingerprint to check it against). Optionally persisted per bridge slot via identity-store.ts's connection-code ledger (see this file's own header comment for what survives a restart and why); constructed with no slot, stays in-memory only, mirroring GatewayTrust's own no-slot fallback.
160
169
  */
161
170
  export class ConnectionCodeLedger {
162
171
  private readonly issued = new Map<string, StoredConnectionCode>();
@@ -179,10 +188,10 @@ export class ConnectionCodeLedger {
179
188
  /** Drops every issued or redeemed entry whose own expiresAt has already passed, so neither map accumulates forever across a long-running process or a slot that survives many restarts. */
180
189
  private prune(now: number): void {
181
190
  for (const [nonce, record] of this.issued) {
182
- if (Date.parse(record.expiresAt) <= now) this.issued.delete(nonce);
191
+ if (hasExpired(record.expiresAt, now)) this.issued.delete(nonce);
183
192
  }
184
193
  for (const [nonce, expiresAt] of this.redeemed) {
185
- if (Date.parse(expiresAt) <= now) this.redeemed.delete(nonce);
194
+ if (hasExpired(expiresAt, now)) this.redeemed.delete(nonce);
186
195
  }
187
196
  }
188
197
 
@@ -240,7 +249,13 @@ export class ConnectionCodeLedger {
240
249
  const now = options.now ?? Date.now();
241
250
  this.prune(now);
242
251
 
243
- if (Date.parse(candidate.expiresAt) <= now) {
252
+ if (parseIsoTimestampMs(candidate.expiresAt) === undefined) {
253
+ throw new ConnectionCodeError(
254
+ "invalid_expiry",
255
+ "Connection code expiry is not an ISO 8601 timestamp",
256
+ );
257
+ }
258
+ if (hasExpired(candidate.expiresAt, now)) {
244
259
  throw new ConnectionCodeError(
245
260
  "expired",
246
261
  `Connection code expired at ${candidate.expiresAt}`,
@@ -254,7 +269,18 @@ export class ConnectionCodeLedger {
254
269
  }
255
270
 
256
271
  let fingerprint: string | undefined;
257
- if (candidate.signature !== undefined) {
272
+ if (candidate.signature === undefined) {
273
+ // The caller supplied something to check the issuer against, and an unsigned code has no issuer to check: accepting it as "freshness only" would pass the pin on nothing.
274
+ if (
275
+ options.publicKeyArmored !== undefined ||
276
+ options.expectedFingerprint !== undefined
277
+ ) {
278
+ throw new ConnectionCodeError(
279
+ "signature_missing",
280
+ "A public key or fingerprint was supplied to check the issuer against, but this connection code is not signed",
281
+ );
282
+ }
283
+ } else {
258
284
  if (options.publicKeyArmored === undefined) {
259
285
  throw new ConnectionCodeError(
260
286
  "signature_required",
@@ -87,6 +87,12 @@ export async function admitDevice(
87
87
  return verdict;
88
88
  }
89
89
 
90
+ /** What removeDevice did: the revocation entries it minted, to broadcast, and how many ledger entries could not be read. When `unreadable` is above zero the removal is incomplete: a grant recorded only in an unreadable entry was not revoked, so a caller must not report the device as removed. */
91
+ export interface RemoveDeviceResult {
92
+ entries: RevocationEntry[];
93
+ unreadable: number;
94
+ }
95
+
90
96
  export interface RemoveDeviceOptions {
91
97
  /** The user principal's own identity -- only the principal that issued a device's membership may revoke it. */
92
98
  userIdentity: IdentityPort;
@@ -98,18 +104,16 @@ export interface RemoveDeviceOptions {
98
104
  }
99
105
 
100
106
  /**
101
- * Revokes every group:member grant for deviceId that the account's ledger holds as outstanding, whichever machine of the account minted it: mints a revocation-entry for each token-id, records it in the given RevocationView immediately, and appends it to the ledger, so every other machine of the account sees the grant as revoked and a later re-admission starts from nothing outstanding. Returns no entries for a device the account never admitted.
107
+ * Revokes every group:member grant for deviceId that the account's ledger holds as outstanding, whichever machine of the account minted it: mints a revocation-entry for each token-id, records it in the given RevocationView immediately, and appends it to the ledger, so every other machine of the account sees the grant as revoked and a later re-admission starts from nothing outstanding. Returns no entries for a device the account never admitted. A ledger entry that cannot be read does not stop the removal (account-ledger.ts): every grant the readable entries record is still revoked, and one recorded only in an unreadable entry is not, which the result says.
102
108
  *
103
- * Returns the minted entries so the caller can broadcast them to the mesh (this module has no MeshTransport dependency to do so itself, mirroring the module-level "no assumption about locality" boundary documented above): every other device that might hold one of this device's grants needs the entry to independently start refusing it.
109
+ * Returns the minted entries, and the number of unreadable ledger entries met, so the caller can broadcast them to the mesh (this module has no MeshTransport dependency to do so itself, mirroring the module-level "no assumption about locality" boundary documented above): every other device that might hold one of this device's grants needs the entry to independently start refusing it.
104
110
  */
105
111
  export async function removeDevice(
106
112
  options: Readonly<RemoveDeviceOptions>,
107
- ): Promise<RevocationEntry[]> {
113
+ ): Promise<RemoveDeviceResult> {
108
114
  const deviceHex = deviceIdToHex(options.deviceId);
109
- const outstanding = await options.accountLedger.outstandingGrants(
110
- "device",
111
- deviceHex,
112
- );
115
+ const { grants: outstanding, unreadable } =
116
+ await options.accountLedger.outstandingGrantsChecked("device", deviceHex);
113
117
  const entries: RevocationEntry[] = [];
114
118
  for (const { tokenId } of outstanding) {
115
119
  const entry = await mintRevocationEntry({
@@ -126,5 +130,5 @@ export async function removeDevice(
126
130
  );
127
131
  entries.push(entry);
128
132
  }
129
- return entries;
133
+ return { entries, unreadable };
130
134
  }
@@ -64,7 +64,13 @@ export async function dmRevoke(
64
64
  action: CommsAction & { action: "dm_revoke" },
65
65
  ): Promise<CommsResult> {
66
66
  if (!store.revokeAgentDmAccess) return grantsUnavailable();
67
- await store.revokeAgentDmAccess(action.target);
67
+ const { unreadable } = await store.revokeAgentDmAccess(action.target);
68
+ if (unreadable > 0) {
69
+ return {
70
+ content: `Revoked every DM grant for ${action.target} that the readable ledger entries record, but ${String(unreadable)} ledger entries cannot be read, so a grant recorded only in them is still valid. Repair or replace them from another machine's replica of the account, then revoke again.`,
71
+ isError: true,
72
+ };
73
+ }
68
74
  return {
69
75
  content: `Revoked ${action.target}'s DM grant.`,
70
76
  isError: false,
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { shortId } from "./display-name.js";
6
+ import { UnusableIdentityRecordError } from "./issuer-identity-file.js";
6
7
  import { errnoCode, PeerLinkError } from "./peer-link-error.js";
7
8
 
8
9
  /** The causes of a refused or dropped connection to a peer, which all mean the process behind that address has gone. */
@@ -52,6 +53,10 @@ export function describeMeshError(
52
53
  error: Readonly<Error>,
53
54
  ): DescribedMeshError | undefined {
54
55
  if (error instanceof PeerLinkError) return describePeerLinkError(error);
56
+ // Already says what happened and what to do. Keyed by file, since the cc-peer front tries to attach a session on every tick and each try meets the same file.
57
+ if (error instanceof UnusableIdentityRecordError) {
58
+ return { key: `identity-unusable:${error.file}`, message: error.message };
59
+ }
55
60
  const code = errnoCode(error);
56
61
  if (code !== undefined && NO_NETWORK_CODES.has(code)) {
57
62
  return {
@@ -89,7 +89,7 @@ export class GatewayTrust {
89
89
  this.load();
90
90
  }
91
91
 
92
- /** Replaces the in-memory sets with whatever the file currently holds, recording the file's stamp. A no-op without a location. Verified members are left alone: they are in memory only and are dropped on their own when their principal stops being trusted. */
92
+ /** Replaces the in-memory sets with whatever the file currently holds, recording the file's stamp. Identifiers are kept in lower case whatever case the file holds them in, so every lookup and removal, which lower-cases its argument, finds them. A no-op without a location. Verified members are left alone: they are in memory only and are dropped on their own when their principal stops being trusted. */
93
93
  private load(): void {
94
94
  if (this.location === undefined) return;
95
95
  this.checkedAt = this.clock.now();
@@ -99,13 +99,13 @@ export class GatewayTrust {
99
99
  this.trustedIssuers.principal.clear();
100
100
  this.trustedIssuers.machine.clear();
101
101
  for (const deviceHex of loaded.devices) {
102
- this.trusted.add(deviceHex);
102
+ this.trusted.add(deviceHex.toLowerCase());
103
103
  }
104
104
  for (const principalHex of loaded.principals) {
105
- this.trustedIssuers.principal.add(principalHex);
105
+ this.trustedIssuers.principal.add(principalHex.toLowerCase());
106
106
  }
107
107
  for (const machineHex of loaded.machines) {
108
- this.trustedIssuers.machine.add(machineHex);
108
+ this.trustedIssuers.machine.add(machineHex.toLowerCase());
109
109
  }
110
110
  }
111
111
 
@@ -166,13 +166,16 @@ export class GroupProofs {
166
166
 
167
167
  /** Sets (or, given undefined, clears) this machine's self display name and re-mints its claim now rather than on the next refresh. Throws if the store has no identity yet. */
168
168
  async saveMachineName(name: string | undefined): Promise<void> {
169
- saveMachineDisplayName(this.getIdentity().machineIdentityOptions, name);
169
+ await saveMachineDisplayName(
170
+ this.getIdentity().machineIdentityOptions,
171
+ name,
172
+ );
170
173
  return this.machineName.refresh();
171
174
  }
172
175
 
173
176
  /** Sets (or, given undefined, clears) this account's self display name and re-mints its claim now rather than on the next refresh. Throws if the store has no identity yet. */
174
177
  async saveUserName(name: string | undefined): Promise<void> {
175
- saveUserDisplayName(this.getIdentity().userIdentityOptions, name);
178
+ await saveUserDisplayName(this.getIdentity().userIdentityOptions, name);
176
179
  return this.principalName.refresh();
177
180
  }
178
181