agent-comms 3.15.0 → 3.16.0

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 (84) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +52 -1
  4. package/dist/core/bridge-mesh.d.ts +1 -1
  5. package/dist/core/bridge-mesh.js +1 -1
  6. package/dist/core/bridge.d.ts +10 -0
  7. package/dist/core/bridge.d.ts.map +1 -1
  8. package/dist/core/bridge.js +40 -1
  9. package/dist/core/bridge.js.map +1 -1
  10. package/dist/core/connection-code-tool.d.ts +18 -0
  11. package/dist/core/connection-code-tool.d.ts.map +1 -0
  12. package/dist/core/connection-code-tool.js +74 -0
  13. package/dist/core/connection-code-tool.js.map +1 -0
  14. package/dist/core/connection-code.d.ts +65 -0
  15. package/dist/core/connection-code.d.ts.map +1 -0
  16. package/dist/core/connection-code.js +180 -0
  17. package/dist/core/connection-code.js.map +1 -0
  18. package/dist/core/dm-send-delegation.d.ts +32 -0
  19. package/dist/core/dm-send-delegation.d.ts.map +1 -0
  20. package/dist/core/dm-send-delegation.js +35 -0
  21. package/dist/core/dm-send-delegation.js.map +1 -0
  22. package/dist/core/gateway-trust.d.ts +20 -5
  23. package/dist/core/gateway-trust.d.ts.map +1 -1
  24. package/dist/core/gateway-trust.js +37 -7
  25. package/dist/core/gateway-trust.js.map +1 -1
  26. package/dist/core/identity-store.d.ts +28 -4
  27. package/dist/core/identity-store.d.ts.map +1 -1
  28. package/dist/core/identity-store.js +82 -8
  29. package/dist/core/identity-store.js.map +1 -1
  30. package/dist/core/mesh-store.d.ts +13 -4
  31. package/dist/core/mesh-store.d.ts.map +1 -1
  32. package/dist/core/mesh-store.js +24 -5
  33. package/dist/core/mesh-store.js.map +1 -1
  34. package/dist/core/pgp-keyserver.d.ts +12 -0
  35. package/dist/core/pgp-keyserver.d.ts.map +1 -0
  36. package/dist/core/pgp-keyserver.js +22 -0
  37. package/dist/core/pgp-keyserver.js.map +1 -0
  38. package/dist/core/room-lifecycle.d.ts +3 -1
  39. package/dist/core/room-lifecycle.d.ts.map +1 -1
  40. package/dist/core/room-lifecycle.js +4 -3
  41. package/dist/core/room-lifecycle.js.map +1 -1
  42. package/dist/core/tool.d.ts +11 -2
  43. package/dist/core/tool.d.ts.map +1 -1
  44. package/dist/core/tool.js +12 -2
  45. package/dist/core/tool.js.map +1 -1
  46. package/dist/core/types.d.ts +41 -0
  47. package/dist/core/types.d.ts.map +1 -1
  48. package/dist/core/types.js +25 -0
  49. package/dist/core/types.js.map +1 -1
  50. package/dist/test/connection-code-ledger-persistence.test.d.ts +5 -0
  51. package/dist/test/connection-code-ledger-persistence.test.d.ts.map +1 -0
  52. package/dist/test/connection-code-ledger-persistence.test.js +84 -0
  53. package/dist/test/connection-code-ledger-persistence.test.js.map +1 -0
  54. package/dist/test/connection-code-tool.test.d.ts +2 -0
  55. package/dist/test/connection-code-tool.test.d.ts.map +1 -0
  56. package/dist/test/connection-code-tool.test.js +296 -0
  57. package/dist/test/connection-code-tool.test.js.map +1 -0
  58. package/dist/test/connection-code.test.d.ts +5 -0
  59. package/dist/test/connection-code.test.d.ts.map +1 -0
  60. package/dist/test/connection-code.test.js +236 -0
  61. package/dist/test/connection-code.test.js.map +1 -0
  62. package/dist/test/dm-send-capability-admission.integration.test.js +82 -1
  63. package/dist/test/dm-send-capability-admission.integration.test.js.map +1 -1
  64. package/dist/test/dm-send-delegation.test.d.ts +2 -0
  65. package/dist/test/dm-send-delegation.test.d.ts.map +1 -0
  66. package/dist/test/dm-send-delegation.test.js +146 -0
  67. package/dist/test/dm-send-delegation.test.js.map +1 -0
  68. package/dist/test/dm-send-principal-delegation.integration.test.d.ts +2 -0
  69. package/dist/test/dm-send-principal-delegation.integration.test.d.ts.map +1 -0
  70. package/dist/test/dm-send-principal-delegation.integration.test.js +89 -0
  71. package/dist/test/dm-send-principal-delegation.integration.test.js.map +1 -0
  72. package/dist/test/gateway-trust.test.js +92 -0
  73. package/dist/test/gateway-trust.test.js.map +1 -1
  74. package/dist/test/identity-store.test.js +42 -18
  75. package/dist/test/identity-store.test.js.map +1 -1
  76. package/dist/test/pgp-keyserver.test.d.ts +5 -0
  77. package/dist/test/pgp-keyserver.test.d.ts.map +1 -0
  78. package/dist/test/pgp-keyserver.test.js +24 -0
  79. package/dist/test/pgp-keyserver.test.js.map +1 -0
  80. package/dist/test/test-transport.d.ts +2 -1
  81. package/dist/test/test-transport.d.ts.map +1 -1
  82. package/dist/test/test-transport.js +5 -5
  83. package/dist/test/test-transport.js.map +1 -1
  84. package/package.json +2 -1
@@ -0,0 +1,180 @@
1
+ /**
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
+ *
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.
5
+ *
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
+ *
8
+ * Persistence (ConnectionCodeLedger, backed by identity-store.ts's connection-code ledger) is two independent halves, not a request/response pair: the generating device's own issued-code record survives a restart between generation and hand-off (identity-store.ts's own doc comment on why), while the redeeming device's own redeemed-nonce record survives a restart within the code's own short validity window, enforcing genuine single-use locally rather than merely advisory "please don't reuse this" convention. Redemption never consults the issuer's own ledger -- there is no network round-trip back to whoever generated the code, since the whole point of this bootstrap is working before any connection between the two devices exists.
9
+ */
10
+ import * as openpgp from "openpgp";
11
+ import { nanoid } from "./nanoid.js";
12
+ import { loadConnectionCodeLedger, saveConnectionCodeLedger, } from "./identity-store.js";
13
+ /** 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. */
14
+ const CONNECTION_CODE_NONCE_LENGTH = 32;
15
+ /** Minutes a generated code stays valid absent an explicit ttlMs override. */
16
+ const DEFAULT_CONNECTION_CODE_TTL_MINUTES = 15;
17
+ /** Seconds per minute, used to convert DEFAULT_CONNECTION_CODE_TTL_MINUTES to milliseconds. */
18
+ const SECONDS_PER_MINUTE = 60;
19
+ /** Milliseconds per second, used to convert DEFAULT_CONNECTION_CODE_TTL_MINUTES to milliseconds. */
20
+ const MS_PER_SECOND = 1000;
21
+ /** Default validity window for a generated code absent an explicit ttlMs: long enough for a human to relay it out of band (Slack, a phone call), short enough that "freshness" actually means something. */
22
+ export const DEFAULT_CONNECTION_CODE_TTL_MS = DEFAULT_CONNECTION_CODE_TTL_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
23
+ /** Thrown by ConnectionCodeLedger.redeem for every rejection reason above -- callers branch on `reason` rather than parsing `message`. */
24
+ export class ConnectionCodeError extends Error {
25
+ reason;
26
+ constructor(reason, message) {
27
+ super(message);
28
+ this.reason = reason;
29
+ this.name = "ConnectionCodeError";
30
+ }
31
+ }
32
+ /** The exact canonical message a connection code's signature is computed over -- shared by generation and redemption so both sides agree on what was actually signed. Field order and the `:` separator are fixed by this function alone; `code` and `deviceId` are both hex/base64url alphabets that never contain `:`, so the three fields can't be confused with each other under concatenation. */
33
+ function connectionCodeMessage(candidate) {
34
+ return `${candidate.code}:${candidate.expiresAt}:${candidate.deviceId}`;
35
+ }
36
+ /** Signs `message` with `privateKeyArmored`, decrypting it with `passphrase` first if it is passphrase-protected. Returns the detached, armored PGP signature. */
37
+ async function signConnectionCodeMessage(message, privateKeyArmored, passphrase) {
38
+ let privateKey = await openpgp.readPrivateKey({
39
+ armoredKey: privateKeyArmored,
40
+ });
41
+ if (!privateKey.isDecrypted()) {
42
+ privateKey = await openpgp.decryptKey(passphrase === undefined ? { privateKey } : { privateKey, passphrase });
43
+ }
44
+ const unsignedMessage = await openpgp.createMessage({ text: message });
45
+ // openpgp.d.ts's own sign() overloads return a conditional type keyed off T extends WebStream<Data>/NodeWebStream<Data>, which ESLint's own type-aware checking (typescript-eslint's projectService) can't resolve concretely here even though tsc itself has no trouble with it -- widening to `unknown` and narrowing with a runtime typeof check is the honest fix (Type Safety: "unknown with type narrowing"), not a cast papering over a real mismatch tsc would otherwise catch.
46
+ const signResult = await openpgp.sign({
47
+ message: unsignedMessage,
48
+ signingKeys: privateKey,
49
+ detached: true,
50
+ });
51
+ if (typeof signResult !== "string") {
52
+ throw new TypeError("openpgp.sign returned a stream rather than an armored string -- unexpected for a non-streaming Message input");
53
+ }
54
+ return signResult;
55
+ }
56
+ /** Verifies `signatureArmored` (a detached PGP signature) over `message` against `publicKeyArmored`. Never throws for an ordinary verification failure (wrong key, tampered message) -- returns `{ valid: false }` instead, reserving thrown errors for malformed input (unparsable armored text). */
57
+ async function verifyConnectionCodeSignature(message, signatureArmored, publicKeyArmored) {
58
+ const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
59
+ const fingerprint = publicKey.getFingerprint();
60
+ const pgpMessage = await openpgp.createMessage({ text: message });
61
+ const signature = await openpgp.readSignature({
62
+ armoredSignature: signatureArmored,
63
+ });
64
+ const verificationResult = await openpgp.verify({
65
+ message: pgpMessage,
66
+ signature,
67
+ verificationKeys: publicKey,
68
+ });
69
+ const [sigResult] = verificationResult.signatures;
70
+ if (sigResult === undefined)
71
+ return { valid: false, fingerprint };
72
+ try {
73
+ await sigResult.verified;
74
+ return { valid: true, fingerprint };
75
+ }
76
+ catch {
77
+ return { valid: false, fingerprint };
78
+ }
79
+ }
80
+ /** Normalises a PGP fingerprint for comparison: openpgp.js's own getFingerprint() returns lowercase hex with no separators, but a human pasting one in from a keyserver page or business card may add spaces, colons, or uppercase. */
81
+ function normalizeFingerprint(fingerprint) {
82
+ return fingerprint.replace(/[\s:]+/g, "").toLowerCase();
83
+ }
84
+ /**
85
+ * 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.
86
+ */
87
+ export class ConnectionCodeLedger {
88
+ issued = new Map();
89
+ redeemed = new Map();
90
+ slot;
91
+ constructor(slot) {
92
+ this.slot = slot;
93
+ if (slot !== undefined) {
94
+ const ledger = loadConnectionCodeLedger(slot);
95
+ for (const [nonce, record] of Object.entries(ledger.issued)) {
96
+ this.issued.set(nonce, record);
97
+ }
98
+ for (const [nonce, expiresAt] of Object.entries(ledger.redeemed)) {
99
+ this.redeemed.set(nonce, expiresAt);
100
+ }
101
+ }
102
+ }
103
+ /** 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. */
104
+ prune(now) {
105
+ for (const [nonce, record] of this.issued) {
106
+ if (Date.parse(record.expiresAt) <= now)
107
+ this.issued.delete(nonce);
108
+ }
109
+ for (const [nonce, expiresAt] of this.redeemed) {
110
+ if (Date.parse(expiresAt) <= now)
111
+ this.redeemed.delete(nonce);
112
+ }
113
+ }
114
+ persist() {
115
+ if (this.slot !== undefined) {
116
+ saveConnectionCodeLedger(this.slot, {
117
+ issued: Object.fromEntries(this.issued),
118
+ redeemed: Object.fromEntries(this.redeemed),
119
+ });
120
+ }
121
+ }
122
+ /**
123
+ * Generates a fresh ConnectionCode vouching for deviceId, optionally signed. The nonce is single-use in the sense that redeem() below will refuse to redeem it twice; nothing prevents the caller from generating any number of independent codes for the same deviceId.
124
+ */
125
+ async generate(deviceId, options = {}) {
126
+ const now = options.now ?? Date.now();
127
+ this.prune(now);
128
+ const code = nanoid(CONNECTION_CODE_NONCE_LENGTH);
129
+ const expiresAt = new Date(now + (options.ttlMs ?? DEFAULT_CONNECTION_CODE_TTL_MS)).toISOString();
130
+ const signature = options.privateKeyArmored === undefined
131
+ ? undefined
132
+ : await signConnectionCodeMessage(connectionCodeMessage({ code, expiresAt, deviceId }), options.privateKeyArmored, options.passphrase);
133
+ const connectionCode = signature === undefined
134
+ ? { code, expiresAt, deviceId }
135
+ : { code, expiresAt, deviceId, signature };
136
+ const stored = signature === undefined
137
+ ? { expiresAt, deviceId }
138
+ : { expiresAt, deviceId, signature };
139
+ this.issued.set(code, stored);
140
+ this.persist();
141
+ return connectionCode;
142
+ }
143
+ /**
144
+ * Validates a candidate ConnectionCode and returns the device-id it vouches for. Always checks expiry and single-use; when the candidate carries a signature, also verifies it (requiring options.publicKeyArmored) and, when options.expectedFingerprint is given, pins the verified signing key to it. Throws ConnectionCodeError for every rejection; the caller is responsible for calling GatewayTrust.add(result.deviceId) on success -- this ledger has no dependency on GatewayTrust itself.
145
+ */
146
+ async redeem(candidate, options = {}) {
147
+ const now = options.now ?? Date.now();
148
+ this.prune(now);
149
+ if (Date.parse(candidate.expiresAt) <= now) {
150
+ throw new ConnectionCodeError("expired", `Connection code expired at ${candidate.expiresAt}`);
151
+ }
152
+ if (this.redeemed.has(candidate.code)) {
153
+ throw new ConnectionCodeError("already_redeemed", "Connection code has already been redeemed");
154
+ }
155
+ let fingerprint;
156
+ if (candidate.signature !== undefined) {
157
+ if (options.publicKeyArmored === undefined) {
158
+ throw new ConnectionCodeError("signature_required", "Connection code carries a signature but no public key was supplied to verify it against");
159
+ }
160
+ const message = connectionCodeMessage(candidate);
161
+ const verification = await verifyConnectionCodeSignature(message, candidate.signature, options.publicKeyArmored);
162
+ if (!verification.valid) {
163
+ throw new ConnectionCodeError("signature_invalid", "Connection code signature does not verify against the supplied public key");
164
+ }
165
+ fingerprint = verification.fingerprint;
166
+ if (options.expectedFingerprint !== undefined &&
167
+ (fingerprint === undefined ||
168
+ normalizeFingerprint(fingerprint) !==
169
+ normalizeFingerprint(options.expectedFingerprint))) {
170
+ throw new ConnectionCodeError("fingerprint_mismatch", `Signing key fingerprint ${fingerprint ?? "(unknown)"} does not match the expected fingerprint ${options.expectedFingerprint}`);
171
+ }
172
+ }
173
+ this.redeemed.set(candidate.code, candidate.expiresAt);
174
+ this.persist();
175
+ return fingerprint === undefined
176
+ ? { deviceId: candidate.deviceId }
177
+ : { deviceId: candidate.deviceId, fingerprint };
178
+ }
179
+ }
180
+ //# sourceMappingURL=connection-code.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection-code.js","sourceRoot":"","sources":["../../src/core/connection-code.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Sub-delegation of a received dm:send grant to one of the recipient principal's own devices (agent-comms#187): once a remote user principal has been admitted into another user's DM-communication scope (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0 specifically to allow this), that principal mints a CHILD dm:send token -- parent = the grant it received, bearer = its own device -- so a specific device can present durable admission on the principal's behalf, without the admitting side ever needing to know that device's own device-id in advance. Mirrors device-membership.ts's admitDevice in shape (a plain mint-and-record function, no MeshStore-equivalent orchestrator), but delegates an EXISTING grant via `parent` rather than self-issuing a fresh root-level one -- the first real use of mintCapabilityToken's parent-narrowing machinery in this codebase, every grant minted before this having been an independent root-level token.
3
+ *
4
+ * Deliberately does not touch the delegated device's own identity-store.ts slot, mirroring device-membership.ts's own "no assumption about locality" stance: getting the minted token onto that device (this process, or a different one) is left entirely to the caller.
5
+ */
6
+ import { type MintVerdict } from "wire-mesh-core/domain/tokens";
7
+ import type { IdentityPort } from "wire-mesh-core/ports/identity";
8
+ import type { Clock } from "wire-mesh-core/ports/clock";
9
+ import type { CapabilityToken, DeviceId } from "wire-mesh-core/generated/protocol";
10
+ import { type UserIdentityOptions } from "./user-identity.js";
11
+ export interface DelegateDmSendToDeviceOptions {
12
+ /** The principal doing the sub-delegating -- must be the same identity the parent grant's own bearer names, or mintCapabilityToken's own parent-bearer-matches-issuer narrowing check refuses the mint outright (`parent_bearer_mismatch`). */
13
+ userIdentity: IdentityPort;
14
+ /** Directory override for tests, forwarded to user-identity.ts's own issued-grant storage -- must resolve to the same user-identity.json userIdentity's own key material lives in. */
15
+ userIdentityOptions?: UserIdentityOptions;
16
+ clock: Clock;
17
+ tokenId: Uint8Array<ArrayBuffer>;
18
+ /** The grant this principal itself was admitted with (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0) -- every one of tokens.cddl's own narrowing obligations is checked against it at mint time. */
19
+ parent: CapabilityToken;
20
+ /** The device being delegated to -- one of this principal's own devices. */
21
+ deviceId: DeviceId;
22
+ /** The remote user principal that originally admitted this identity -- the scope this delegated token must keep naming, unchanged from parent's own scope (tokens.cddl's own narrowing requires an identical "user" scope path down the whole chain). */
23
+ remoteUserPrincipalDeviceId: DeviceId;
24
+ expires: number;
25
+ /** How many further hops the delegated token itself permits below it -- defaults to LEAF_NOT_DELEGABLE; only worth raising for a hierarchy deeper than "principal delegates directly to a device" genuinely needs. */
26
+ delegationsRemaining?: number;
27
+ }
28
+ /**
29
+ * Mints deviceId's own dm:send delegation from a received grant: bearer = deviceId, parent = the grant this principal itself was admitted with, scope unchanged (still the REMOTE admitting principal's own "user" scope, never this principal's own device-id) -- mintCapabilityToken's own narrowing refuses this outright if parent's own delegationsRemaining was 0 (nothing left to sub-delegate) or if userIdentity does not match parent's own bearer. On success, records the token-id under THIS principal's own issued-grant store, keyed by deviceId's hex, so a later revocation can find it -- reuses user-identity.ts's saveIssuedDmGrant/loadIssuedDmGrant/deleteIssuedDmGrant exactly as admitAgentForDm's own root-level self-grants do, since both are simply "grants this identity has issued," keyed by bearer, regardless of whether the grant is root-level or itself a delegation.
30
+ */
31
+ export declare function delegateDmSendToDevice(options: Readonly<DelegateDmSendToDeviceOptions>): Promise<MintVerdict>;
32
+ //# sourceMappingURL=dm-send-delegation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dm-send-delegation.d.ts","sourceRoot":"","sources":["../../src/core/dm-send-delegation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAClE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,KAAK,EACV,eAAe,EACf,QAAQ,EACT,MAAM,mCAAmC,CAAC;AAK3C,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,oBAAoB,CAAC;AAK5B,MAAM,WAAW,6BAA6B;IAC5C,+OAA+O;IAC/O,YAAY,EAAE,YAAY,CAAC;IAC3B,sLAAsL;IACtL,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;IACjC,iOAAiO;IACjO,MAAM,EAAE,eAAe,CAAC;IACxB,4EAA4E;IAC5E,QAAQ,EAAE,QAAQ,CAAC;IACnB,yPAAyP;IACzP,2BAA2B,EAAE,QAAQ,CAAC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,sNAAsN;IACtN,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;GAEG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,QAAQ,CAAC,6BAA6B,CAAC,GAC/C,OAAO,CAAC,WAAW,CAAC,CAuBtB"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Sub-delegation of a received dm:send grant to one of the recipient principal's own devices (agent-comms#187): once a remote user principal has been admitted into another user's DM-communication scope (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0 specifically to allow this), that principal mints a CHILD dm:send token -- parent = the grant it received, bearer = its own device -- so a specific device can present durable admission on the principal's behalf, without the admitting side ever needing to know that device's own device-id in advance. Mirrors device-membership.ts's admitDevice in shape (a plain mint-and-record function, no MeshStore-equivalent orchestrator), but delegates an EXISTING grant via `parent` rather than self-issuing a fresh root-level one -- the first real use of mintCapabilityToken's parent-narrowing machinery in this codebase, every grant minted before this having been an independent root-level token.
3
+ *
4
+ * Deliberately does not touch the delegated device's own identity-store.ts slot, mirroring device-membership.ts's own "no assumption about locality" stance: getting the minted token onto that device (this process, or a different one) is left entirely to the caller.
5
+ */
6
+ import { mintCapabilityToken, } from "wire-mesh-core/domain/tokens";
7
+ import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
8
+ import { DM_SEND_CAPABILITY, DM_SEND_SCOPE_KIND, } from "./dm-token-verification.js";
9
+ import { saveIssuedDmGrant, } from "./user-identity.js";
10
+ /** delegationsRemaining a delegated device's own token permits below it, when the caller doesn't ask for more -- 0, matching every other non-delegable grant this codebase already mints by default (device-membership.ts's own NOT_DELEGABLE): an ordinary device is a leaf of the chain, not a further delegator. */
11
+ const LEAF_NOT_DELEGABLE = 0;
12
+ /**
13
+ * Mints deviceId's own dm:send delegation from a received grant: bearer = deviceId, parent = the grant this principal itself was admitted with, scope unchanged (still the REMOTE admitting principal's own "user" scope, never this principal's own device-id) -- mintCapabilityToken's own narrowing refuses this outright if parent's own delegationsRemaining was 0 (nothing left to sub-delegate) or if userIdentity does not match parent's own bearer. On success, records the token-id under THIS principal's own issued-grant store, keyed by deviceId's hex, so a later revocation can find it -- reuses user-identity.ts's saveIssuedDmGrant/loadIssuedDmGrant/deleteIssuedDmGrant exactly as admitAgentForDm's own root-level self-grants do, since both are simply "grants this identity has issued," keyed by bearer, regardless of whether the grant is root-level or itself a delegation.
14
+ */
15
+ export async function delegateDmSendToDevice(options) {
16
+ const verdict = await mintCapabilityToken({
17
+ identity: options.userIdentity,
18
+ clock: options.clock,
19
+ tokenId: options.tokenId,
20
+ bearer: options.deviceId,
21
+ capability: DM_SEND_CAPABILITY,
22
+ scope: {
23
+ kind: DM_SEND_SCOPE_KIND,
24
+ path: deviceIdToHex(options.remoteUserPrincipalDeviceId),
25
+ },
26
+ expires: options.expires,
27
+ delegationsRemaining: options.delegationsRemaining ?? LEAF_NOT_DELEGABLE,
28
+ parent: options.parent,
29
+ });
30
+ if (!verdict.ok)
31
+ return verdict;
32
+ saveIssuedDmGrant(options.userIdentityOptions, deviceIdToHex(options.deviceId), options.tokenId);
33
+ return verdict;
34
+ }
35
+ //# sourceMappingURL=dm-send-delegation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dm-send-delegation.js","sourceRoot":"","sources":["../../src/core/dm-send-delegation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,mBAAmB,GAEpB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAOhE,OAAO,EACL,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,iBAAiB,GAElB,MAAM,oBAAoB,CAAC;AAE5B,uTAAuT;AACvT,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAoB7B;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAC1C,OAAgD;IAEhD,MAAM,OAAO,GAAG,MAAM,mBAAmB,CAAC;QACxC,QAAQ,EAAE,OAAO,CAAC,YAAY;QAC9B,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,MAAM,EAAE,OAAO,CAAC,QAAQ;QACxB,UAAU,EAAE,kBAAkB;QAC9B,KAAK,EAAE;YACL,IAAI,EAAE,kBAAkB;YACxB,IAAI,EAAE,aAAa,CAAC,OAAO,CAAC,2BAA2B,CAAC;SACzD;QACD,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,oBAAoB,EAAE,OAAO,CAAC,oBAAoB,IAAI,kBAAkB;QACxE,MAAM,EAAE,OAAO,CAAC,MAAM;KACvB,CAAC,CAAC;IACH,IAAI,CAAC,OAAO,CAAC,EAAE;QAAE,OAAO,OAAO,CAAC;IAEhC,iBAAiB,CACf,OAAO,CAAC,mBAAmB,EAC3B,aAAa,CAAC,OAAO,CAAC,QAAQ,CAAC,EAC/B,OAAO,CAAC,OAAO,CAChB,CAAC;IACF,OAAO,OAAO,CAAC;AACjB,CAAC"}
@@ -4,27 +4,42 @@
4
4
  * Persisted per bridge slot when constructed with one (agent-comms#186), mirroring identity-store.ts's own per-slot room-token/issued-grant persistence: the trusted set is loaded from that slot's own sibling JSON file (identity-store.ts's loadGatewayTrust) on construction, and written back in full (saveGatewayTrust) after every add/remove, so trust survives a gateway restart instead of needing to be re-established every run. The in-memory Set below remains the live source of truth for isTrusted/hasAny at all times; persistence is purely load-on-construct and save-on-mutate. Constructed with no slot, this class keeps the original v1 FederationManager.trustedFingerprints precedent (retired with federation.ts, commit 4232b08): in-memory only, never touching disk. Every pre-#186 construction site (most tests, and any caller with no bridge identity slot to hand) falls into this no-slot case unchanged.
5
5
  *
6
6
  * Keyed by individual device-id, not by "one entry per remote machine": wire-mesh-core's relay-hub protocol (relay-hub.ts, gossip-frame, relay-data-frame) carries no field identifying which remote gateway connection a given directory entry or relayed request actually originated from -- only the entry/request's own device-id, which may be an ordinary local peer forwarded on a remote machine's behalf rather than that machine's own coordinator. Gating per individual device-id is therefore the finest-grained, and only wire-protocol-honest, trust boundary actually implementable without a wire-mesh-core protocol change (deliberately out of scope here, matching agent-comms#156's own "gating the hub itself is out of scope" framing) -- confirmed as the intended granularity by hub-session.ts's own pre-existing isStateMutatingMessage doc comment, which already named this exact gap as "agent-comms#156's own future deliverable" of "per-peer" admission control. An operator who wants every local peer on a remote machine reachable trusts each of that machine's device-ids individually, not just its coordinator's.
7
+ *
8
+ * Principal-keyed trust (agent-comms#187): alongside the bare-device allowlist above, an operator may also trust a user-principal device-id (user-identity.ts) directly -- the root a remote peer's own dm:send-style delegation chain can terminate at, verified the same way device-membership-verification.ts already verifies a device's own group:member chain (`verifyCapabilityToken`'s chain-walk and its `rootIssuer` output). This is purely a second, parallel allowlist: `isTrusted`/`add`/`remove`/`list` keep checking only the bare-device set, unchanged, for a peer with no principal at all -- `isTrustedFor` is the additive entrypoint a caller who has already chain-verified a token uses to decide trust from that verified bearer and chain-root pair, accepting either a directly trusted device or a bearer rooted at a trusted principal. Persisted alongside the bare-device set (agent-comms#186's own persistence, extended here per that issue's own "agent-comms#187 covers what gets stored" framing): loadGatewayTrust/saveGatewayTrust now carry both sets.
7
9
  */
8
10
  import type { IdentitySlot } from "./identity-store.js";
9
- /** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick<GatewayTrust, "isTrusted" | "hasAny">` inline at every field/parameter that takes one. */
10
- export type GatewayTrustReader = Pick<GatewayTrust, "isTrusted" | "hasAny">;
11
+ /** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick<GatewayTrust, "isTrusted" | "hasAny" | "isTrustedPrincipal" | "isTrustedFor">` inline at every field/parameter that takes one. */
12
+ export type GatewayTrustReader = Pick<GatewayTrust, "isTrusted" | "hasAny" | "isTrustedPrincipal" | "isTrustedFor">;
11
13
  export declare class GatewayTrust {
12
14
  private readonly trusted;
15
+ private readonly trustedPrincipals;
13
16
  private readonly slot;
14
- /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever device-ids were trusted before the last restart into the initial in-memory set. */
17
+ /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever devices and principals were trusted before the last restart into the initial in-memory sets. */
15
18
  constructor(slot?: Readonly<IdentitySlot>);
16
19
  /** Marks a remote device-id (hex, case-insensitive) as trusted: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. Idempotent. Persists the updated set when this instance was constructed with a slot. */
17
20
  add(deviceHex: string): void;
18
21
  /** Withdraws a previously trusted device-id (hex, case-insensitive). A no-op if it was never trusted. Mirrors FederationManager.removeTrustedFingerprint's own precedent: already-merged directory entries and in-flight requests are unaffected -- this governs future traffic only. Persists the updated set when this instance was constructed with a slot. */
19
22
  remove(deviceHex: string): void;
20
- /** Writes the complete current trusted set back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */
23
+ /** Writes the complete current trusted device and principal sets back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */
21
24
  private persist;
22
25
  /** Every currently trusted device-id, lowercase hex, in insertion order. */
23
26
  list(): string[];
24
27
  /** Whether the given device-id (hex, case-insensitive) is currently trusted. */
25
28
  isTrusted(deviceHex: string): boolean;
29
+ /** Marks a remote user-principal device-id (hex, case-insensitive; user-identity.ts) as trusted (agent-comms#187): a peer presenting a token whose delegation chain roots at this principal is trusted via `isTrustedFor` below, without that peer's own bare device-id ever needing individual trust. Idempotent, and entirely independent of the bare-device allowlist `add` manages. Persists the updated set when this instance was constructed with a slot. */
30
+ addPrincipal(deviceHex: string): void;
31
+ /** Withdraws a previously trusted principal (hex, case-insensitive). A no-op if it was never trusted. Governs future chain checks only, mirroring `remove`'s own already-merged-traffic-is-unaffected posture. Persists the updated set when this instance was constructed with a slot. */
32
+ removePrincipal(deviceHex: string): void;
33
+ /** Every currently trusted principal device-id, lowercase hex, in insertion order. */
34
+ listPrincipals(): string[];
35
+ /** Whether the given device-id (hex, case-insensitive) is currently trusted as a user principal. */
36
+ isTrustedPrincipal(deviceHex: string): boolean;
37
+ /**
38
+ * Whether a peer is trusted, given a token's own bearer and chain-root device-ids (hex, case-insensitive) a caller has already chain-verified via `verifyCapabilityToken` (the same `rootIssuer` output device-membership-verification.ts's own chain check already relies on). Passes when the bearer itself is directly trusted (the existing bare-device path, unmodified for a peer with no principal at all), OR when the chain's root is a trusted principal -- so a device sub-delegated from an admitted principal (mirroring agent-comms#161's own "principal admits its own devices" pattern) is trusted without ever being individually allowlisted. This class performs no cryptographic verification itself; the caller supplies already-verified device-ids, keeping GatewayTrust the same plain allowlist it always was.
39
+ */
40
+ isTrustedFor(bearerHex: string, rootIssuerHex: string): boolean;
26
41
  /**
27
- * Whether at least one remote device is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted() check above is what keeps this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters.
42
+ * Whether at least one remote device or principal is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device or principal. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted()/isTrustedFor() checks above are what keep this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters.
28
43
  */
29
44
  hasAny(): boolean;
30
45
  }
@@ -1 +1 @@
1
- {"version":3,"file":"gateway-trust.d.ts","sourceRoot":"","sources":["../../src/core/gateway-trust.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,mVAAmV;AACnV,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,YAAY,EAAE,WAAW,GAAG,QAAQ,CAAC,CAAC;AAE5E,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqC;IAE1D,sTAAsT;gBAC1S,IAAI,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC;IASzC,mRAAmR;IACnR,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAK5B,kWAAkW;IAClW,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAK/B,+JAA+J;IAC/J,OAAO,CAAC,OAAO;IAIf,4EAA4E;IAC5E,IAAI,IAAI,MAAM,EAAE;IAIhB,gFAAgF;IAChF,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIrC;;OAEG;IACH,MAAM,IAAI,OAAO;CAGlB"}
1
+ {"version":3,"file":"gateway-trust.d.ts","sourceRoot":"","sources":["../../src/core/gateway-trust.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,2XAA2X;AAC3X,MAAM,MAAM,kBAAkB,GAAG,IAAI,CACnC,YAAY,EACZ,WAAW,GAAG,QAAQ,GAAG,oBAAoB,GAAG,cAAc,CAC/D,CAAC;AAEF,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqB;IACvD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqC;IAE1D,mUAAmU;gBACvT,IAAI,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC;IAazC,mRAAmR;IACnR,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAK5B,kWAAkW;IAClW,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAK/B,qLAAqL;IACrL,OAAO,CAAC,OAAO;IAMf,4EAA4E;IAC5E,IAAI,IAAI,MAAM,EAAE;IAIhB,gFAAgF;IAChF,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIrC,ocAAoc;IACpc,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAKrC,2RAA2R;IAC3R,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAKxC,sFAAsF;IACtF,cAAc,IAAI,MAAM,EAAE;IAI1B,oGAAoG;IACpG,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI9C;;OAEG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO;IAI/D;;OAEG;IACH,MAAM,IAAI,OAAO;CAGlB"}
@@ -1,14 +1,19 @@
1
1
  import { loadGatewayTrust, saveGatewayTrust } from "./identity-store.js";
2
2
  export class GatewayTrust {
3
3
  trusted = new Set();
4
+ trustedPrincipals = new Set();
4
5
  slot;
5
- /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever device-ids were trusted before the last restart into the initial in-memory set. */
6
+ /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever devices and principals were trusted before the last restart into the initial in-memory sets. */
6
7
  constructor(slot) {
7
8
  this.slot = slot;
8
9
  if (slot !== undefined) {
9
- for (const deviceHex of loadGatewayTrust(slot)) {
10
+ const loaded = loadGatewayTrust(slot);
11
+ for (const deviceHex of loaded.devices) {
10
12
  this.trusted.add(deviceHex);
11
13
  }
14
+ for (const principalHex of loaded.principals) {
15
+ this.trustedPrincipals.add(principalHex);
16
+ }
12
17
  }
13
18
  }
14
19
  /** Marks a remote device-id (hex, case-insensitive) as trusted: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. Idempotent. Persists the updated set when this instance was constructed with a slot. */
@@ -21,10 +26,11 @@ export class GatewayTrust {
21
26
  this.trusted.delete(deviceHex.toLowerCase());
22
27
  this.persist();
23
28
  }
24
- /** Writes the complete current trusted set back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */
29
+ /** Writes the complete current trusted device and principal sets back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */
25
30
  persist() {
26
- if (this.slot !== undefined)
27
- saveGatewayTrust(this.slot, this.list());
31
+ if (this.slot !== undefined) {
32
+ saveGatewayTrust(this.slot, this.list(), this.listPrincipals());
33
+ }
28
34
  }
29
35
  /** Every currently trusted device-id, lowercase hex, in insertion order. */
30
36
  list() {
@@ -34,11 +40,35 @@ export class GatewayTrust {
34
40
  isTrusted(deviceHex) {
35
41
  return this.trusted.has(deviceHex.toLowerCase());
36
42
  }
43
+ /** Marks a remote user-principal device-id (hex, case-insensitive; user-identity.ts) as trusted (agent-comms#187): a peer presenting a token whose delegation chain roots at this principal is trusted via `isTrustedFor` below, without that peer's own bare device-id ever needing individual trust. Idempotent, and entirely independent of the bare-device allowlist `add` manages. Persists the updated set when this instance was constructed with a slot. */
44
+ addPrincipal(deviceHex) {
45
+ this.trustedPrincipals.add(deviceHex.toLowerCase());
46
+ this.persist();
47
+ }
48
+ /** Withdraws a previously trusted principal (hex, case-insensitive). A no-op if it was never trusted. Governs future chain checks only, mirroring `remove`'s own already-merged-traffic-is-unaffected posture. Persists the updated set when this instance was constructed with a slot. */
49
+ removePrincipal(deviceHex) {
50
+ this.trustedPrincipals.delete(deviceHex.toLowerCase());
51
+ this.persist();
52
+ }
53
+ /** Every currently trusted principal device-id, lowercase hex, in insertion order. */
54
+ listPrincipals() {
55
+ return [...this.trustedPrincipals];
56
+ }
57
+ /** Whether the given device-id (hex, case-insensitive) is currently trusted as a user principal. */
58
+ isTrustedPrincipal(deviceHex) {
59
+ return this.trustedPrincipals.has(deviceHex.toLowerCase());
60
+ }
61
+ /**
62
+ * Whether a peer is trusted, given a token's own bearer and chain-root device-ids (hex, case-insensitive) a caller has already chain-verified via `verifyCapabilityToken` (the same `rootIssuer` output device-membership-verification.ts's own chain check already relies on). Passes when the bearer itself is directly trusted (the existing bare-device path, unmodified for a peer with no principal at all), OR when the chain's root is a trusted principal -- so a device sub-delegated from an admitted principal (mirroring agent-comms#161's own "principal admits its own devices" pattern) is trusted without ever being individually allowlisted. This class performs no cryptographic verification itself; the caller supplies already-verified device-ids, keeping GatewayTrust the same plain allowlist it always was.
63
+ */
64
+ isTrustedFor(bearerHex, rootIssuerHex) {
65
+ return this.isTrusted(bearerHex) || this.isTrustedPrincipal(rootIssuerHex);
66
+ }
37
67
  /**
38
- * Whether at least one remote device is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted() check above is what keeps this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters.
68
+ * Whether at least one remote device or principal is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device or principal. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted()/isTrustedFor() checks above are what keep this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters.
39
69
  */
40
70
  hasAny() {
41
- return this.trusted.size > 0;
71
+ return this.trusted.size > 0 || this.trustedPrincipals.size > 0;
42
72
  }
43
73
  }
44
74
  //# sourceMappingURL=gateway-trust.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"gateway-trust.js","sourceRoot":"","sources":["../../src/core/gateway-trust.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAKzE,MAAM,OAAO,YAAY;IACN,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAC5B,IAAI,CAAqC;IAE1D,sTAAsT;IACtT,YAAY,IAA6B;QACvC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACvB,KAAK,MAAM,SAAS,IAAI,gBAAgB,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC/C,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAC9B,CAAC;QACH,CAAC;IACH,CAAC;IAED,mRAAmR;IACnR,GAAG,CAAC,SAAiB;QACnB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAED,kWAAkW;IAClW,MAAM,CAAC,SAAiB;QACtB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAED,+JAA+J;IACvJ,OAAO;QACb,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS;YAAE,gBAAgB,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IACxE,CAAC;IAED,4EAA4E;IAC5E,IAAI;QACF,OAAO,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;IAED,gFAAgF;IAChF,SAAS,CAAC,SAAiB;QACzB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;IACnD,CAAC;IAED;;OAEG;IACH,MAAM;QACJ,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,CAAC;IAC/B,CAAC;CACF"}
1
+ {"version":3,"file":"gateway-trust.js","sourceRoot":"","sources":["../../src/core/gateway-trust.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAQzE,MAAM,OAAO,YAAY;IACN,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAC5B,iBAAiB,GAAG,IAAI,GAAG,EAAU,CAAC;IACtC,IAAI,CAAqC;IAE1D,mUAAmU;IACnU,YAAY,IAA6B;QACvC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACvB,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;YACtC,KAAK,MAAM,SAAS,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;gBACvC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAC9B,CAAC;YACD,KAAK,MAAM,YAAY,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;gBAC7C,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;YAC3C,CAAC;QACH,CAAC;IACH,CAAC;IAED,mRAAmR;IACnR,GAAG,CAAC,SAAiB;QACnB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAED,kWAAkW;IAClW,MAAM,CAAC,SAAiB;QACtB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAED,qLAAqL;IAC7K,OAAO;QACb,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5B,gBAAgB,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC;QAClE,CAAC;IACH,CAAC;IAED,4EAA4E;IAC5E,IAAI;QACF,OAAO,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;IAED,gFAAgF;IAChF,SAAS,CAAC,SAAiB;QACzB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;IACnD,CAAC;IAED,ocAAoc;IACpc,YAAY,CAAC,SAAiB;QAC5B,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QACpD,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAED,2RAA2R;IAC3R,eAAe,CAAC,SAAiB;QAC/B,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QACvD,IAAI,CAAC,OAAO,EAAE,CAAC;IACjB,CAAC;IAED,sFAAsF;IACtF,cAAc;QACZ,OAAO,CAAC,GAAG,IAAI,CAAC,iBAAiB,CAAC,CAAC;IACrC,CAAC;IAED,oGAAoG;IACpG,kBAAkB,CAAC,SAAiB;QAClC,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;IAC7D,CAAC;IAED;;OAEG;IACH,YAAY,CAAC,SAAiB,EAAE,aAAqB;QACnD,OAAO,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,kBAAkB,CAAC,aAAa,CAAC,CAAC;IAC7E,CAAC;IAED;;OAEG;IACH,MAAM;QACJ,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,IAAI,IAAI,CAAC,iBAAiB,CAAC,IAAI,GAAG,CAAC,CAAC;IAClE,CAAC;CACF"}
@@ -62,12 +62,36 @@ export declare function loadIssuedRoomGrant(slot: Readonly<IdentitySlot>, roomPa
62
62
  export declare function saveIssuedRoomGrant(slot: Readonly<IdentitySlot>, roomPath: string, memberDeviceHex: string, tokenId: Uint8Array): void;
63
63
  /** Removes the recorded token-id for one (roomPath, member) grant, if any -- called once a kick has revoked it, so a later re-join mints and records a genuinely fresh one rather than leaving a stale entry alongside it. A no-op if none was recorded. */
64
64
  export declare function deleteIssuedRoomGrant(slot: Readonly<IdentitySlot>, roomPath: string, memberDeviceHex: string): void;
65
+ /** loadGatewayTrust's own return shape: every remote device-id and every remote user-principal device-id (agent-comms#187) this slot's gateway currently trusts, each lowercase hex, in insertion order. */
66
+ export interface LoadedGatewayTrust {
67
+ devices: string[];
68
+ principals: string[];
69
+ }
70
+ /**
71
+ * The trusted devices and principals (agent-comms#187) a slot's gateway had persisted before this call, both empty if the slot has never saved a trusted set or its gateway trust file is missing or unparseable. Reads a pre-#187 file (a bare JSON array, agent-comms#186's own original format) as devices-only with no principals -- every gateway-trust file written before principal-keyed trust existed named only bare devices, so there is nothing to migrate, just an older, narrower shape to keep reading correctly.
72
+ */
73
+ export declare function loadGatewayTrust(slot: Readonly<IdentitySlot>): LoadedGatewayTrust;
74
+ /**
75
+ * Persists a slot's complete trusted-gateway device-id and principal-id sets (agent-comms#187 extends agent-comms#186's own original device-only persistence, per that issue's own "agent-comms#187 covers what gets stored" framing), surviving a restart the same way the identity they gate alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list()/listPrincipals() after every add/remove/addPrincipal/removePrincipal, so there is no per-entry partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
76
+ */
77
+ export declare function saveGatewayTrust(slot: Readonly<IdentitySlot>, devices: readonly string[], principals: readonly string[]): void;
78
+ /** A generated connection code's own persisted record (agent-comms#188): everything ConnectionCode carries except its own nonce, which is this record's key instead of a repeated field. */
79
+ export interface StoredConnectionCode {
80
+ expiresAt: string;
81
+ deviceId: string;
82
+ signature?: string;
83
+ }
84
+ /** A slot's own connection-code ledger (agent-comms#188): every code this slot has generated (still outstanding, so the artifact survives a restart between generation and hand-off) and every nonce this slot has redeemed (so a restart can't reopen a single-use code to a second redemption within its own short validity window). Two independent halves of one bootstrap flow, not a request/response pair -- a generated code's own record here is never consulted by whichever remote slot later redeems it, since redemption validates the code's four self-contained fields directly rather than calling back to its issuer. */
85
+ export interface ConnectionCodeLedgerData {
86
+ issued: Record<string, StoredConnectionCode>;
87
+ redeemed: Record<string, string>;
88
+ }
65
89
  /**
66
- * Every remote device-id this slot's gateway currently trusts, lowercase hex, in insertion order. Empty if the slot has never saved a trusted set, or its gateway trust file is missing or unparseable.
90
+ * A slot's currently persisted connection-code ledger: every code it has issued, keyed by nonce, and every nonce it has redeemed, mapped to that code's own expiresAt (kept only so a stale entry can be pruned once its expiry has passed, the same reason issued entries carry their own expiresAt). Both halves default to empty if the slot has never saved a ledger, or its ledger file is missing or unparseable.
67
91
  */
68
- export declare function loadGatewayTrust(slot: Readonly<IdentitySlot>): string[];
92
+ export declare function loadConnectionCodeLedger(slot: Readonly<IdentitySlot>): ConnectionCodeLedgerData;
69
93
  /**
70
- * Persists a slot's complete trusted-gateway device-id set, surviving a restart the same way the identity it gates alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list() after every add/remove, so there is no per-device partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
94
+ * Persists a slot's complete connection-code ledger, surviving a restart the same way the identity it bootstraps trust for does. Overwrites whatever was saved before in full: ConnectionCodeLedger always calls this with its own current issued/redeemed maps after every generate/redeem, so there is no per-code partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
71
95
  */
72
- export declare function saveGatewayTrust(slot: Readonly<IdentitySlot>, trusted: readonly string[]): void;
96
+ export declare function saveConnectionCodeLedger(slot: Readonly<IdentitySlot>, ledger: Readonly<ConnectionCodeLedgerData>): void;
73
97
  //# sourceMappingURL=identity-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"identity-store.d.ts","sourceRoot":"","sources":["../../src/core/identity-store.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAQzE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AA2ID,4UAA4U;AAC5U,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,MAAM,CAIhE;AAuCD;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAC3B,YAAY,CAgBd;AAkDD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAC3B,MAAM,GAAG,SAAS,CAKpB;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAC3B,YAAY,CAId;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,IAAI,CAKtE;AAuBD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CASjC;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,eAAe,GACrB,IAAI,CAaN;AAED,sGAAsG;AACtG,wBAAgB,eAAe,CAC7B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,QAAQ,EAAE,MAAM,GACf,IAAI,CAUN;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CASjC;AAED;;GAEG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAaN;AAED,6JAA6J;AAC7J,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,SAAS,EAAE,MAAM,GAChB,IAAI,CAUN;AAMD;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,UAAU,CAAC,WAAW,CAAC,GAAG,SAAS,CAQrC;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,UAAU,GAClB,IAAI,CAcN;AAED,4PAA4P;AAC5P,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,IAAI,CASN;AASD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,MAAM,EAAE,CASvE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,IAAI,CAQN"}
1
+ {"version":3,"file":"identity-store.d.ts","sourceRoot":"","sources":["../../src/core/identity-store.ts"],"names":[],"mappings":"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"}
@@ -376,8 +376,11 @@ function gatewayTrustFilePath(slot) {
376
376
  const base = `gateway-trust-${slot.harness}--${slugifyCwd(slot.cwd)}`;
377
377
  return path.join(dir, `${base}.json`);
378
378
  }
379
+ function isStringArray(value) {
380
+ return (Array.isArray(value) && value.every((entry) => typeof entry === "string"));
381
+ }
379
382
  /**
380
- * Every remote device-id this slot's gateway currently trusts, lowercase hex, in insertion order. Empty if the slot has never saved a trusted set, or its gateway trust file is missing or unparseable.
383
+ * The trusted devices and principals (agent-comms#187) a slot's gateway had persisted before this call, both empty if the slot has never saved a trusted set or its gateway trust file is missing or unparseable. Reads a pre-#187 file (a bare JSON array, agent-comms#186's own original format) as devices-only with no principals -- every gateway-trust file written before principal-keyed trust existed named only bare devices, so there is nothing to migrate, just an older, narrower shape to keep reading correctly.
381
384
  */
382
385
  export function loadGatewayTrust(slot) {
383
386
  let parsed;
@@ -385,18 +388,89 @@ export function loadGatewayTrust(slot) {
385
388
  parsed = JSON.parse(fs.readFileSync(gatewayTrustFilePath(slot), "utf-8"));
386
389
  }
387
390
  catch {
388
- return [];
391
+ return { devices: [], principals: [] };
392
+ }
393
+ if (isStringArray(parsed))
394
+ return { devices: parsed, principals: [] };
395
+ if (typeof parsed !== "object" || parsed === null) {
396
+ return { devices: [], principals: [] };
397
+ }
398
+ const devices = "devices" in parsed && isStringArray(parsed.devices) ? parsed.devices : [];
399
+ const principals = "principals" in parsed && isStringArray(parsed.principals)
400
+ ? parsed.principals
401
+ : [];
402
+ return { devices, principals };
403
+ }
404
+ /**
405
+ * Persists a slot's complete trusted-gateway device-id and principal-id sets (agent-comms#187 extends agent-comms#186's own original device-only persistence, per that issue's own "agent-comms#187 covers what gets stored" framing), surviving a restart the same way the identity they gate alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list()/listPrincipals() after every add/remove/addPrincipal/removePrincipal, so there is no per-entry partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
406
+ */
407
+ export function saveGatewayTrust(slot, devices, principals) {
408
+ const { dir } = slotPaths(slot);
409
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
410
+ const stored = {
411
+ devices: [...devices],
412
+ principals: [...principals],
413
+ };
414
+ fs.writeFileSync(gatewayTrustFilePath(slot), `${JSON.stringify(stored, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
415
+ }
416
+ /** A slot's own connection-code ledger lives in its own sibling JSON file, mirroring gatewayTrustFilePath's own reasoning: the ledger has no dependency on this slot's own key material, so ConnectionCodeLedger can be constructed against a slot before or independently of that slot's identity ever being loaded. */
417
+ function connectionCodesFilePath(slot) {
418
+ const { dir } = slotPaths(slot);
419
+ const base = `connection-codes-${slot.harness}--${slugifyCwd(slot.cwd)}`;
420
+ return path.join(dir, `${base}.json`);
421
+ }
422
+ function isStoredConnectionCode(value) {
423
+ if (typeof value !== "object" || value === null)
424
+ return false;
425
+ if (!("expiresAt" in value) || !("deviceId" in value))
426
+ return false;
427
+ if (typeof value.expiresAt !== "string" || typeof value.deviceId !== "string")
428
+ return false;
429
+ if ("signature" in value && typeof value.signature !== "string")
430
+ return false;
431
+ return true;
432
+ }
433
+ /**
434
+ * A slot's currently persisted connection-code ledger: every code it has issued, keyed by nonce, and every nonce it has redeemed, mapped to that code's own expiresAt (kept only so a stale entry can be pruned once its expiry has passed, the same reason issued entries carry their own expiresAt). Both halves default to empty if the slot has never saved a ledger, or its ledger file is missing or unparseable.
435
+ */
436
+ export function loadConnectionCodeLedger(slot) {
437
+ let parsed;
438
+ try {
439
+ parsed = JSON.parse(fs.readFileSync(connectionCodesFilePath(slot), "utf-8"));
440
+ }
441
+ catch {
442
+ return { issued: {}, redeemed: {} };
443
+ }
444
+ if (typeof parsed !== "object" || parsed === null)
445
+ return { issued: {}, redeemed: {} };
446
+ const issuedRaw = "issued" in parsed &&
447
+ typeof parsed.issued === "object" &&
448
+ parsed.issued !== null
449
+ ? parsed.issued
450
+ : {};
451
+ const redeemedRaw = "redeemed" in parsed &&
452
+ typeof parsed.redeemed === "object" &&
453
+ parsed.redeemed !== null
454
+ ? parsed.redeemed
455
+ : {};
456
+ const issued = {};
457
+ for (const [nonce, record] of Object.entries(issuedRaw)) {
458
+ if (isStoredConnectionCode(record))
459
+ issued[nonce] = record;
460
+ }
461
+ const redeemed = {};
462
+ for (const [nonce, expiresAt] of Object.entries(redeemedRaw)) {
463
+ if (typeof expiresAt === "string")
464
+ redeemed[nonce] = expiresAt;
389
465
  }
390
- if (!Array.isArray(parsed))
391
- return [];
392
- return parsed.filter((entry) => typeof entry === "string");
466
+ return { issued, redeemed };
393
467
  }
394
468
  /**
395
- * Persists a slot's complete trusted-gateway device-id set, surviving a restart the same way the identity it gates alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list() after every add/remove, so there is no per-device partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
469
+ * Persists a slot's complete connection-code ledger, surviving a restart the same way the identity it bootstraps trust for does. Overwrites whatever was saved before in full: ConnectionCodeLedger always calls this with its own current issued/redeemed maps after every generate/redeem, so there is no per-code partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
396
470
  */
397
- export function saveGatewayTrust(slot, trusted) {
471
+ export function saveConnectionCodeLedger(slot, ledger) {
398
472
  const { dir } = slotPaths(slot);
399
473
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
400
- fs.writeFileSync(gatewayTrustFilePath(slot), `${JSON.stringify(trusted, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
474
+ fs.writeFileSync(connectionCodesFilePath(slot), `${JSON.stringify(ledger, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
401
475
  }
402
476
  //# sourceMappingURL=identity-store.js.map