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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -6
- package/dist/account-cli.d.ts.map +1 -1
- package/dist/account-cli.js +4 -1
- package/dist/account-cli.js.map +1 -1
- package/dist/bridges/user/web/dist/bundle.js +1 -1
- package/dist/bridges/user/web/dist/index.html +1 -7
- package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
- package/dist/bridges/user/web/dist/sw.js +1 -1
- package/dist/bridges/user/web/hosted-dashboard.d.ts +10 -0
- package/dist/bridges/user/web/hosted-dashboard.d.ts.map +1 -0
- package/dist/bridges/user/web/hosted-dashboard.js +10 -0
- package/dist/bridges/user/web/hosted-dashboard.js.map +1 -0
- package/dist/bridges/user/web/web-access.d.ts +0 -2
- package/dist/bridges/user/web/web-access.d.ts.map +1 -1
- package/dist/bridges/user/web/web-access.js +1 -2
- package/dist/bridges/user/web/web-access.js.map +1 -1
- package/dist/cli-main.d.ts.map +1 -1
- package/dist/cli-main.js +9 -12
- package/dist/cli-main.js.map +1 -1
- package/dist/core/account-bundle.d.ts +1 -1
- package/dist/core/account-bundle.d.ts.map +1 -1
- package/dist/core/account-bundle.js +26 -19
- package/dist/core/account-bundle.js.map +1 -1
- package/dist/core/account-join.d.ts.map +1 -1
- package/dist/core/account-join.js +5 -2
- package/dist/core/account-join.js.map +1 -1
- package/dist/core/account-ledger-crypto.d.ts.map +1 -1
- package/dist/core/account-ledger-crypto.js +4 -1
- package/dist/core/account-ledger-crypto.js.map +1 -1
- package/dist/core/account-ledger-store.d.ts +3 -1
- package/dist/core/account-ledger-store.d.ts.map +1 -1
- package/dist/core/account-ledger-store.js +6 -3
- package/dist/core/account-ledger-store.js.map +1 -1
- package/dist/core/account-ledger.d.ts +51 -4
- package/dist/core/account-ledger.d.ts.map +1 -1
- package/dist/core/account-ledger.js +102 -15
- package/dist/core/account-ledger.js.map +1 -1
- package/dist/core/bridge-mesh.d.ts.map +1 -1
- package/dist/core/bridge-mesh.js +3 -0
- package/dist/core/bridge-mesh.js.map +1 -1
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +16 -4
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/capability-ask.d.ts.map +1 -1
- package/dist/core/capability-ask.js +10 -2
- package/dist/core/capability-ask.js.map +1 -1
- package/dist/core/connection-code-tool.d.ts +1 -1
- package/dist/core/connection-code-tool.js +2 -2
- package/dist/core/connection-code-tool.js.map +1 -1
- package/dist/core/connection-code.d.ts +5 -5
- package/dist/core/connection-code.d.ts.map +1 -1
- package/dist/core/connection-code.js +22 -6
- package/dist/core/connection-code.js.map +1 -1
- package/dist/core/device-membership.d.ts +8 -3
- package/dist/core/device-membership.d.ts.map +1 -1
- package/dist/core/device-membership.js +4 -4
- package/dist/core/device-membership.js.map +1 -1
- package/dist/core/dm-grant-actions.d.ts.map +1 -1
- package/dist/core/dm-grant-actions.js +7 -1
- package/dist/core/dm-grant-actions.js.map +1 -1
- package/dist/core/error-reporting.d.ts.map +1 -1
- package/dist/core/error-reporting.js +5 -0
- package/dist/core/error-reporting.js.map +1 -1
- package/dist/core/gateway-trust.d.ts +1 -1
- package/dist/core/gateway-trust.d.ts.map +1 -1
- package/dist/core/gateway-trust.js +4 -4
- package/dist/core/gateway-trust.js.map +1 -1
- package/dist/core/group-proofs.d.ts.map +1 -1
- package/dist/core/group-proofs.js +2 -2
- package/dist/core/group-proofs.js.map +1 -1
- package/dist/core/identity-store.d.ts +13 -9
- package/dist/core/identity-store.d.ts.map +1 -1
- package/dist/core/identity-store.js +122 -147
- package/dist/core/identity-store.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/iso-timestamp.d.ts +10 -0
- package/dist/core/iso-timestamp.d.ts.map +1 -0
- package/dist/core/iso-timestamp.js +19 -0
- package/dist/core/iso-timestamp.js.map +1 -0
- package/dist/core/issuer-identity-file.d.ts +15 -0
- package/dist/core/issuer-identity-file.d.ts.map +1 -1
- package/dist/core/issuer-identity-file.js +69 -20
- package/dist/core/issuer-identity-file.js.map +1 -1
- package/dist/core/machine-identity.d.ts +1 -1
- package/dist/core/machine-identity.d.ts.map +1 -1
- package/dist/core/machine-identity.js +14 -11
- package/dist/core/machine-identity.js.map +1 -1
- package/dist/core/mesh-store.d.ts +2 -2
- package/dist/core/mesh-store.d.ts.map +1 -1
- package/dist/core/mesh-store.js.map +1 -1
- package/dist/core/peer-lifecycle.d.ts.map +1 -1
- package/dist/core/peer-lifecycle.js +8 -2
- package/dist/core/peer-lifecycle.js.map +1 -1
- package/dist/core/pid-lock-file.d.ts +27 -4
- package/dist/core/pid-lock-file.d.ts.map +1 -1
- package/dist/core/pid-lock-file.js +120 -11
- package/dist/core/pid-lock-file.js.map +1 -1
- package/dist/core/process-start.d.ts +10 -0
- package/dist/core/process-start.d.ts.map +1 -0
- package/dist/core/process-start.js +43 -0
- package/dist/core/process-start.js.map +1 -0
- package/dist/core/room-lifecycle.d.ts +3 -2
- package/dist/core/room-lifecycle.d.ts.map +1 -1
- package/dist/core/room-lifecycle.js +9 -8
- package/dist/core/room-lifecycle.js.map +1 -1
- package/dist/core/room-protocol.d.ts.map +1 -1
- package/dist/core/room-protocol.js +25 -3
- package/dist/core/room-protocol.js.map +1 -1
- package/dist/core/stale-agent-checker.d.ts.map +1 -1
- package/dist/core/stale-agent-checker.js +3 -1
- package/dist/core/stale-agent-checker.js.map +1 -1
- package/dist/core/store.d.ts +3 -0
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +42 -20
- package/dist/core/store.js.map +1 -1
- package/dist/core/tool.d.ts +5 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +7 -1
- package/dist/core/tool.js.map +1 -1
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +8 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/user-identity.d.ts +5 -5
- package/dist/core/user-identity.d.ts.map +1 -1
- package/dist/core/user-identity.js +59 -45
- package/dist/core/user-identity.js.map +1 -1
- package/package.json +1 -1
- package/src/account-cli.ts +4 -1
- package/src/bridges/user/web/dist/bundle.js +1 -1
- package/src/bridges/user/web/dist/index.html +1 -7
- package/src/bridges/user/web/dist/mesh-worker.js +4 -4
- package/src/bridges/user/web/dist/sw.js +1 -1
- package/src/bridges/user/web/hosted-dashboard.ts +11 -0
- package/src/bridges/user/web/web-access.ts +1 -3
- package/src/cli-main.ts +10 -12
- package/src/core/account-bundle.ts +36 -25
- package/src/core/account-join.ts +5 -2
- package/src/core/account-ledger-crypto.ts +4 -1
- package/src/core/account-ledger-store.ts +11 -3
- package/src/core/account-ledger.ts +142 -20
- package/src/core/bridge-mesh.ts +4 -0
- package/src/core/bridge.ts +24 -4
- package/src/core/capability-ask.ts +16 -1
- package/src/core/connection-code-tool.ts +2 -2
- package/src/core/connection-code.ts +34 -8
- package/src/core/device-membership.ts +12 -8
- package/src/core/dm-grant-actions.ts +7 -1
- package/src/core/error-reporting.ts +5 -0
- package/src/core/gateway-trust.ts +4 -4
- package/src/core/group-proofs.ts +5 -2
- package/src/core/identity-store.ts +182 -191
- package/src/core/index.ts +1 -0
- package/src/core/iso-timestamp.ts +21 -0
- package/src/core/issuer-identity-file.ts +87 -23
- package/src/core/machine-identity.ts +15 -12
- package/src/core/mesh-store.ts +2 -1
- package/src/core/peer-lifecycle.ts +9 -4
- package/src/core/pid-lock-file.ts +139 -11
- package/src/core/process-start.ts +45 -0
- package/src/core/room-lifecycle.ts +12 -9
- package/src/core/room-protocol.ts +23 -3
- package/src/core/stale-agent-checker.ts +3 -1
- package/src/core/store.ts +48 -23
- package/src/core/tool.ts +14 -1
- package/src/core/types.ts +9 -3
- package/src/core/user-identity.ts +65 -51
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { createFileExclusive, writeFileAtomic } from "./atomic-file.js";
|
|
12
|
+
import { withWriteLock } from "./pid-lock-file.js";
|
|
13
|
+
import { CommsError } from "./store.js";
|
|
12
14
|
import {
|
|
13
15
|
generateIdentity,
|
|
14
16
|
certifyKeyPair,
|
|
@@ -55,13 +57,43 @@ function isErrnoException(value: unknown): value is NodeJS.ErrnoException {
|
|
|
55
57
|
return value instanceof Error && "code" in value;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
/**
|
|
60
|
+
/** The `code` of the error thrown when an identity file exists but cannot be used. */
|
|
61
|
+
export const IDENTITY_RECORD_UNUSABLE = "IDENTITY_RECORD_UNUSABLE";
|
|
62
|
+
|
|
63
|
+
/** Thrown when an identity file exists but does not hold a record this build can use. The file is left exactly as it was: replacing it would mint a new key, which changes the identity's id for every peer that knows it and discards whatever the record held beside the key. */
|
|
64
|
+
export class UnusableIdentityRecordError extends CommsError {
|
|
65
|
+
constructor(
|
|
66
|
+
/** The file that could not be used. */
|
|
67
|
+
public readonly file: string,
|
|
68
|
+
/** Why it could not be used, as a short clause. */
|
|
69
|
+
public readonly reason: string,
|
|
70
|
+
) {
|
|
71
|
+
super(
|
|
72
|
+
`${file} exists but does not hold a usable identity record (${reason}). It has been left as it was. Restore it from a backup, or move it aside to start a new identity, which changes this identity's id for every peer that knows it.`,
|
|
73
|
+
IDENTITY_RECORD_UNUSABLE,
|
|
74
|
+
);
|
|
75
|
+
// No cause is attached and the reason is fixed text: the underlying parse and crypto errors can quote part of the file, which holds a private key, and an error is printed whole by anything that logs it.
|
|
76
|
+
this.name = "UnusableIdentityRecordError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The errno code of a failed read, which is all of the failure that is safe to repeat: its message quotes the path and nothing of the content, but the code is what a person can act on. */
|
|
81
|
+
function readFailureCode(err: unknown): string {
|
|
82
|
+
return isErrnoException(err) && typeof err.code === "string"
|
|
83
|
+
? err.code
|
|
84
|
+
: "unknown error";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reads the file's raw contents, or undefined if it does not exist yet. A file that exists but cannot be read (permissions, a directory in its place) is refused as unusable, never treated as absent. */
|
|
59
88
|
function readRawFile(file: string): string | undefined {
|
|
60
89
|
try {
|
|
61
90
|
return fs.readFileSync(file, "utf-8");
|
|
62
91
|
} catch (err) {
|
|
63
92
|
if (isErrnoException(err) && err.code === "ENOENT") return undefined;
|
|
64
|
-
throw
|
|
93
|
+
throw new UnusableIdentityRecordError(
|
|
94
|
+
file,
|
|
95
|
+
`it cannot be read (${readFailureCode(err)})`,
|
|
96
|
+
);
|
|
65
97
|
}
|
|
66
98
|
}
|
|
67
99
|
|
|
@@ -82,21 +114,18 @@ function parseIssuerRecord<T>(
|
|
|
82
114
|
let parsed: unknown;
|
|
83
115
|
try {
|
|
84
116
|
parsed = JSON.parse(raw);
|
|
85
|
-
} catch
|
|
86
|
-
throw
|
|
117
|
+
} catch {
|
|
118
|
+
throw new UnusableIdentityRecordError(file, "it is not valid JSON");
|
|
119
|
+
}
|
|
120
|
+
if (!guard(parsed)) {
|
|
121
|
+
throw new UnusableIdentityRecordError(
|
|
122
|
+
file,
|
|
123
|
+
"a field is missing or is not in a form this version accepts",
|
|
124
|
+
);
|
|
87
125
|
}
|
|
88
|
-
if (!guard(parsed)) throw unusableRecord(file);
|
|
89
126
|
return parsed;
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
/** The error for an identity file that exists but holds no usable record. Moving it aside is the operator's decision, since the next load then mints a new key under a new id. */
|
|
93
|
-
function unusableRecord(file: string, cause?: unknown): Error {
|
|
94
|
-
return new Error(
|
|
95
|
-
`${file} exists but does not hold a usable identity record; move it aside to mint a new key, which changes this identity's id for every peer that trusts it`,
|
|
96
|
-
{ cause },
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
129
|
function serializeRecord(stored: Readonly<object>): string {
|
|
101
130
|
return `${JSON.stringify(stored, null, 2)}\n`;
|
|
102
131
|
}
|
|
@@ -132,19 +161,54 @@ export function issuerKeyRecord(
|
|
|
132
161
|
};
|
|
133
162
|
}
|
|
134
163
|
|
|
135
|
-
/**
|
|
136
|
-
function
|
|
164
|
+
/** The identity a stored record describes, or UnusableIdentityRecordError when its key or certificate cannot be read. */
|
|
165
|
+
function identityOfRecord(
|
|
137
166
|
file: string,
|
|
138
167
|
stored: Readonly<StoredIssuerKey>,
|
|
139
168
|
): PeerIdentity {
|
|
169
|
+
try {
|
|
170
|
+
return issuerPeerIdentity(stored);
|
|
171
|
+
} catch {
|
|
172
|
+
throw new UnusableIdentityRecordError(
|
|
173
|
+
file,
|
|
174
|
+
"its key or certificate cannot be read",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether a stored record is within the renewal margin of its certificate's expiry, or has an expiry that does not parse. */
|
|
180
|
+
function needsRenewal(stored: Readonly<StoredIssuerKey>): boolean {
|
|
140
181
|
const expiresAt = Date.parse(stored.expiresAt);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (!needsRenewal) return issuerPeerIdentity(stored);
|
|
182
|
+
return Number.isNaN(expiresAt) || Date.now() > expiresAt - RENEWAL_MARGIN_MS;
|
|
183
|
+
}
|
|
144
184
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
185
|
+
/** Renews a stored identity nearing certificate expiry by re-certifying its existing key pair, so its device-id is unchanged (see identity.ts's certifyKeyPair for why a fresh key pair would be wrong). Every other field in the record is kept. Returns the identity unchanged when it is not yet near expiry. The record is read again under the file's write lock before it is rewritten, so a field another process wrote since `stored` was read is kept, and a renewal another process has already done is not repeated. A file that has gone since `stored` was read fails the renewal and is not written back: the copy read before the lock was taken is never used in its place, since it may be exactly what an operator has just moved aside. */
|
|
186
|
+
function renewIfNeeded(
|
|
187
|
+
file: string,
|
|
188
|
+
stored: Readonly<StoredIssuerKey>,
|
|
189
|
+
guard: (value: unknown) => value is StoredIssuerKey,
|
|
190
|
+
): PeerIdentity {
|
|
191
|
+
if (!needsRenewal(stored)) return identityOfRecord(file, stored);
|
|
192
|
+
return withWriteLock(file, () => {
|
|
193
|
+
const current = readIssuerRecord(file, guard);
|
|
194
|
+
if (current === undefined) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${file} was removed while its certificate was being renewed; nothing was written`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (!needsRenewal(current)) return identityOfRecord(file, current);
|
|
200
|
+
let renewed: PeerIdentity;
|
|
201
|
+
try {
|
|
202
|
+
renewed = certifyKeyPair(current.privateKey);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new UnusableIdentityRecordError(
|
|
205
|
+
file,
|
|
206
|
+
"its key cannot be certified again",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
writeIssuerRecord(file, { ...current, ...issuerKeyRecord(renewed) });
|
|
210
|
+
return renewed;
|
|
211
|
+
});
|
|
148
212
|
}
|
|
149
213
|
|
|
150
214
|
/** Generates a fresh identity and persists it at file, which does not exist yet. The create is exclusive, so when a concurrent caller creates the file first this caller keeps the winner's key and returns that instead of overwriting it, judging the winner's record with the owning module's guard. */
|
|
@@ -166,7 +230,7 @@ function createIssuerIdentity(
|
|
|
166
230
|
if (winner === undefined) {
|
|
167
231
|
throw new Error(`${file} was created and removed again while loading it`);
|
|
168
232
|
}
|
|
169
|
-
return renewIfNeeded(file, winner);
|
|
233
|
+
return renewIfNeeded(file, winner, guard);
|
|
170
234
|
}
|
|
171
235
|
|
|
172
236
|
/**
|
|
@@ -183,5 +247,5 @@ export function loadOrCreateIssuerIdentity(
|
|
|
183
247
|
|
|
184
248
|
const stored = readIssuerRecord(file, guard);
|
|
185
249
|
if (stored === undefined) return createIssuerIdentity(file, guard);
|
|
186
|
-
return renewIfNeeded(file, stored);
|
|
250
|
+
return renewIfNeeded(file, stored, guard);
|
|
187
251
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { resolveDataDir } from "./data-directory.js";
|
|
9
|
+
import { withWriteLockAsync } from "./pid-lock-file.js";
|
|
9
10
|
import type { PeerIdentity } from "./identity.js";
|
|
10
11
|
import {
|
|
11
12
|
isStoredIssuerKey,
|
|
@@ -59,19 +60,21 @@ export function loadMachineDisplayName(
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** Sets (or, given undefined, clears) the name this machine asserts for itself, keeping the key material and every other field. Throws if the machine identity has not been created yet. */
|
|
62
|
-
export function saveMachineDisplayName(
|
|
63
|
+
export async function saveMachineDisplayName(
|
|
63
64
|
options: Readonly<MachineIdentityOptions> | undefined,
|
|
64
65
|
displayName: string | undefined,
|
|
65
|
-
): void {
|
|
66
|
+
): Promise<void> {
|
|
66
67
|
const file = machineIdentityFile(options);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
await withWriteLockAsync(file, () => {
|
|
69
|
+
const stored = readIssuerRecord(file, isStoredMachineIdentity);
|
|
70
|
+
if (stored === undefined) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`no machine identity persisted yet; call loadOrCreateMachineIdentity first (${file})`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const next: StoredMachineIdentity = { ...stored };
|
|
76
|
+
if (displayName === undefined) delete next.displayName;
|
|
77
|
+
else next.displayName = displayName;
|
|
78
|
+
writeIssuerRecord(file, next);
|
|
79
|
+
});
|
|
77
80
|
}
|
package/src/core/mesh-store.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type { DeviceId } from "wire-mesh-core/generated/protocol";
|
|
|
40
40
|
import {
|
|
41
41
|
followLedgerRevocations,
|
|
42
42
|
type AccountLedgerReplica,
|
|
43
|
+
type LedgerGaps,
|
|
43
44
|
} from "./account-ledger.js";
|
|
44
45
|
import { StaleAgentChecker } from "./stale-agent-checker.js";
|
|
45
46
|
import { PeerLifecycle } from "./peer-lifecycle.js";
|
|
@@ -711,7 +712,7 @@ export class MeshStore implements CommsStore {
|
|
|
711
712
|
}
|
|
712
713
|
|
|
713
714
|
/** Revokes bearerId's own dm:send grant for real (agent-comms#162), the DM-scope counterpart to kickFromRoom. A no-op if bearerId was never admitted. Deliberately outside the CommsStore interface, like requestDmAccess above. Concrete-only -- reached directly by tests. */
|
|
714
|
-
async revokeAgentDmAccess(bearerId: string): Promise<
|
|
715
|
+
async revokeAgentDmAccess(bearerId: string): Promise<LedgerGaps> {
|
|
715
716
|
return this.roomLifecycle.revokeAgentDmAccess(bearerId);
|
|
716
717
|
}
|
|
717
718
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { normaliseWireState } from "./wire-protocol.js";
|
|
8
|
+
import { parseIsoTimestampMs } from "./iso-timestamp.js";
|
|
8
9
|
import type {
|
|
9
10
|
MeshMessage,
|
|
10
11
|
PeerInfo,
|
|
@@ -361,10 +362,14 @@ export class PeerLifecycle {
|
|
|
361
362
|
);
|
|
362
363
|
if (remainingPeers.length === 0) return;
|
|
363
364
|
|
|
364
|
-
// filter() above already returned a fresh array, so sorting it in place is safe.
|
|
365
|
-
|
|
366
|
-
(
|
|
367
|
-
)
|
|
365
|
+
// filter() above already returned a fresh array, so sorting it in place is safe. A start time that does not parse sorts last: an unreadable claim never makes a peer the longest-running one.
|
|
366
|
+
const startedAtMs = (peer: Readonly<PeerInfo>): number =>
|
|
367
|
+
parseIsoTimestampMs(peer.startedAt) ?? Number.POSITIVE_INFINITY;
|
|
368
|
+
remainingPeers.sort((a, b) => {
|
|
369
|
+
const earlier = startedAtMs(a);
|
|
370
|
+
const later = startedAtMs(b);
|
|
371
|
+
return earlier === later ? 0 : earlier < later ? -1 : 1;
|
|
372
|
+
});
|
|
368
373
|
const [successor, ...handoffList] = remainingPeers;
|
|
369
374
|
// remainingPeers.length > 0 (checked above) guarantees at least one entry here -- this narrows the type for TypeScript rather than handling a real runtime case.
|
|
370
375
|
if (successor === undefined) return;
|
|
@@ -1,24 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A lock file holding the PID of the process that claimed it: the exclusivity guard for a bridge's identity slot (identity-store.ts) and for appends to the machine's account ledger (ledger-lock.ts). A lock whose recorded process has died is stale and may be taken over, so a holder that crashed never wedges the lock.
|
|
3
3
|
*
|
|
4
|
-
* Every claim writes the PID
|
|
4
|
+
* Every claim writes the PID, a random nonce, the host name and the process's start marker, one per line. The nonce keeps two claims from ever leaving identical content even when a PID is reused. The host and the start marker are what a later claimant checks before judging the holder dead: a lock is taken over only when its holder can be shown dead, so a holder on another host (the directory is shared) and a holder whose PID still names the process that recorded it are left alone, and a PID that now names a process that started at another time is a holder that died. A lock written without them (an older build) is judged by its PID on this host alone. Two hosts that report the same host name are not told apart. Removing a stale lock is the one step that could delete a newer, live claim by mistake (the stale file can be released and re-claimed between reading it and removing it), so it happens only under a second, takeover lock file, and only when the lock still holds exactly the content that was read and judged stale. A live holder releasing its own lock never needs the takeover lock: only its own claim can be at that name while it holds it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import { processStartMarker } from "./process-start.js";
|
|
11
|
+
import { CommsError } from "./store.js";
|
|
9
12
|
|
|
10
13
|
/** Random bytes in each claim's content, enough that two claims never collide. */
|
|
11
14
|
const CLAIM_NONCE_BYTES = 16;
|
|
12
15
|
|
|
16
|
+
/** Whether a process with this pid exists on this host. A process this one is not allowed to signal exists, so it counts as alive. */
|
|
13
17
|
export function isPidAlive(pid: number): boolean {
|
|
14
18
|
try {
|
|
15
19
|
process.kill(pid, 0);
|
|
16
20
|
return true;
|
|
17
|
-
} catch {
|
|
18
|
-
return
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return isErrorCode(err, "EPERM");
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/** The host name every claim records, read once. */
|
|
27
|
+
const OWN_HOST = os.hostname();
|
|
28
|
+
let ownStartMarker: { value: string | undefined } | undefined;
|
|
29
|
+
|
|
30
|
+
function ownMarker(): string | undefined {
|
|
31
|
+
ownStartMarker ??= { value: processStartMarker(process.pid) };
|
|
32
|
+
return ownStartMarker.value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LockHolder {
|
|
36
|
+
pid: number | undefined;
|
|
37
|
+
host: string | undefined;
|
|
38
|
+
start: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function holderOf(content: string): LockHolder {
|
|
42
|
+
const [, , host, start] = content.split("\n");
|
|
43
|
+
return {
|
|
44
|
+
pid: pidOf(content),
|
|
45
|
+
host: host === undefined || host === "" ? undefined : host,
|
|
46
|
+
start: start === undefined || start === "" ? undefined : start,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether the holder a lock records cannot be shown dead: it runs on another host, or it runs here and its pid names a live process that started when the claim recorded (or the claim recorded no start). A lock with no readable pid has no holder. */
|
|
51
|
+
function holderMayBeAlive(holder: Readonly<LockHolder>): boolean {
|
|
52
|
+
if (holder.pid === undefined) return false;
|
|
53
|
+
if (holder.host !== undefined && holder.host !== OWN_HOST) return true;
|
|
54
|
+
if (!isPidAlive(holder.pid)) return false;
|
|
55
|
+
if (holder.start === undefined) return true;
|
|
56
|
+
const current = processStartMarker(holder.pid);
|
|
57
|
+
return current === undefined || current === holder.start;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pid recorded by the lock at lockFile when its holder cannot be shown dead (see holderMayBeAlive), including this process; undefined when there is no lock or its holder is shown dead. */
|
|
61
|
+
export function liveLockHolder(lockFile: string): number | undefined {
|
|
62
|
+
const content = readLockContent(lockFile);
|
|
63
|
+
if (content === undefined) return undefined;
|
|
64
|
+
const holder = holderOf(content);
|
|
65
|
+
return holderMayBeAlive(holder) ? holder.pid : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
function readLockContent(lockFile: string): string | undefined {
|
|
23
69
|
try {
|
|
24
70
|
return fs.readFileSync(lockFile, "utf-8");
|
|
@@ -33,8 +79,7 @@ function pidOf(content: string): number | undefined {
|
|
|
33
79
|
return Number.isInteger(pid) ? pid : undefined;
|
|
34
80
|
}
|
|
35
81
|
|
|
36
|
-
|
|
37
|
-
export function readLockPid(lockFile: string): number | undefined {
|
|
82
|
+
function readLockPid(lockFile: string): number | undefined {
|
|
38
83
|
const content = readLockContent(lockFile);
|
|
39
84
|
return content === undefined ? undefined : pidOf(content);
|
|
40
85
|
}
|
|
@@ -51,7 +96,7 @@ function claimExclusive(file: string): boolean {
|
|
|
51
96
|
const tmpFile = `${file}.${String(process.pid)}.tmp`;
|
|
52
97
|
fs.writeFileSync(
|
|
53
98
|
tmpFile,
|
|
54
|
-
`${String(process.pid)
|
|
99
|
+
`${[String(process.pid), randomBytes(CLAIM_NONCE_BYTES).toString("hex"), OWN_HOST, ownMarker() ?? ""].join("\n")}\n`,
|
|
55
100
|
"utf-8",
|
|
56
101
|
);
|
|
57
102
|
try {
|
|
@@ -65,10 +110,13 @@ function claimExclusive(file: string): boolean {
|
|
|
65
110
|
}
|
|
66
111
|
}
|
|
67
112
|
|
|
68
|
-
/** Whether content names a
|
|
113
|
+
/** Whether content names a holder that can be shown to no longer be holding the lock: one that has died (see holderMayBeAlive), this process itself on this host (which claims a lock only after releasing it, so a lock naming it is left over from before), or no readable PID at all. */
|
|
69
114
|
function isStale(content: string): boolean {
|
|
70
|
-
const
|
|
71
|
-
|
|
115
|
+
const holder = holderOf(content);
|
|
116
|
+
const isThisProcess =
|
|
117
|
+
holder.pid === process.pid &&
|
|
118
|
+
(holder.host === undefined || holder.host === OWN_HOST);
|
|
119
|
+
return isThisProcess || !holderMayBeAlive(holder);
|
|
72
120
|
}
|
|
73
121
|
|
|
74
122
|
/** Removes `file` if it still holds exactly `content`, the content a caller read and judged stale. Any newer claim has different content (each carries its own nonce), so it is left alone. */
|
|
@@ -114,9 +162,89 @@ export function tryAcquireLock(
|
|
|
114
162
|
return { acquired: false, heldBy: readLockPid(lockFile) };
|
|
115
163
|
}
|
|
116
164
|
|
|
117
|
-
/**
|
|
165
|
+
/** Total time a writer waits for another process's write to the same file to finish. A write is one small file read and one atomic rename, so this is far above its real cost, and bounded so a holder that never releases cannot wedge every other writer. */
|
|
166
|
+
export const WRITE_LOCK_WAIT_MS = 5000;
|
|
167
|
+
/** Poll interval while waiting for a write lock. */
|
|
168
|
+
const WRITE_LOCK_POLL_MS = 10;
|
|
169
|
+
/** One Int32 element's worth of bytes: all Atomics.wait needs to block on. */
|
|
170
|
+
const INT32_BYTE_LENGTH = 4;
|
|
171
|
+
/** The `code` of the error thrown when a write lock stays held beyond the wait budget. */
|
|
172
|
+
export const WRITE_LOCK_TIMEOUT = "WRITE_LOCK_TIMEOUT";
|
|
173
|
+
|
|
174
|
+
/** Thrown when the write lock of a file stays held by a live process beyond WRITE_LOCK_WAIT_MS. Nothing was written. */
|
|
175
|
+
export class WriteLockTimeoutError extends CommsError {
|
|
176
|
+
constructor(public readonly file: string) {
|
|
177
|
+
super(
|
|
178
|
+
`Timed out waiting for another process to finish writing ${file}; nothing was written. If no other agent-comms process is running, remove ${writeLockFor(file)}.`,
|
|
179
|
+
WRITE_LOCK_TIMEOUT,
|
|
180
|
+
);
|
|
181
|
+
this.name = "WriteLockTimeoutError";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Blocks this thread for `ms` milliseconds. Only for callers that are themselves synchronous. */
|
|
186
|
+
export function sleepSync(ms: number): void {
|
|
187
|
+
Atomics.wait(
|
|
188
|
+
new Int32Array(new SharedArrayBuffer(INT32_BYTE_LENGTH)),
|
|
189
|
+
0,
|
|
190
|
+
0,
|
|
191
|
+
ms,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The lock file serialising writes to `file`. Its name ends in `.write-lock`, which no lock file this package names for a bridge slot (`.lock`) or any other file ends in, so it cannot equal another file's name whatever the slot's working directory is called.
|
|
197
|
+
*/
|
|
198
|
+
export function writeLockFor(file: string): string {
|
|
199
|
+
return `${file}.write-lock`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Runs `action` holding the write lock of `file`, so the read-modify-write it performs cannot interleave with another process's. The lock is not reentrant: `action` must not take the same lock again (this process would count as having left it stale). A lock whose holder process has died is taken over (see tryAcquireLock). Throws WriteLockTimeoutError when the lock stays held by a live process beyond WRITE_LOCK_WAIT_MS. Blocks this thread while it waits, so a caller that is asynchronous uses withWriteLockAsync.
|
|
204
|
+
*/
|
|
205
|
+
export function withWriteLock<T>(file: string, action: () => T): T {
|
|
206
|
+
const lockFile = writeLockFor(file);
|
|
207
|
+
const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
|
|
208
|
+
while (!tryAcquireLock(lockFile).acquired) {
|
|
209
|
+
if (Date.now() >= deadline) throw new WriteLockTimeoutError(file);
|
|
210
|
+
sleepSync(WRITE_LOCK_POLL_MS);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return action();
|
|
214
|
+
} finally {
|
|
215
|
+
releaseLockFile(lockFile);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** withWriteLock for an asynchronous caller: waits on timers, so the event loop keeps running while another process holds the lock. `action` is synchronous, so the lock is never held across an await. */
|
|
220
|
+
export async function withWriteLockAsync<T>(
|
|
221
|
+
file: string,
|
|
222
|
+
action: () => T,
|
|
223
|
+
): Promise<T> {
|
|
224
|
+
const lockFile = writeLockFor(file);
|
|
225
|
+
const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
|
|
226
|
+
while (!tryAcquireLock(lockFile).acquired) {
|
|
227
|
+
if (Date.now() >= deadline) throw new WriteLockTimeoutError(file);
|
|
228
|
+
await new Promise<void>((resolve) => {
|
|
229
|
+
setTimeout(resolve, WRITE_LOCK_POLL_MS);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return action();
|
|
234
|
+
} finally {
|
|
235
|
+
releaseLockFile(lockFile);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Removes lockFile if this process holds it; a lock another process has since taken over, on this host or another, is left alone. The release half of tryAcquireLock. */
|
|
118
240
|
export function releaseLockFile(lockFile: string): void {
|
|
119
|
-
|
|
241
|
+
const content = readLockContent(lockFile);
|
|
242
|
+
if (content === undefined) return;
|
|
243
|
+
const holder = holderOf(content);
|
|
244
|
+
if (
|
|
245
|
+
holder.pid === process.pid &&
|
|
246
|
+
(holder.host === undefined || holder.host === OWN_HOST)
|
|
247
|
+
) {
|
|
120
248
|
fs.rmSync(lockFile, { force: true });
|
|
121
249
|
}
|
|
122
250
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A marker for when a process started, so a lock that records it tells its holder from an unrelated process that was later given the same pid.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
/** Zero-based index of the start time among the fields of /proc/<pid>/stat that follow the parenthesised command name (field 22 of the file, field 3 being the first after the name). */
|
|
9
|
+
const PROC_STAT_START_TIME_INDEX = 19;
|
|
10
|
+
/** How long the process table listing may take; it reads one entry and returns at once on a healthy host. */
|
|
11
|
+
const PS_TIMEOUT_MS = 2000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An opaque string that is the same for as long as the process lives and differs from the marker of any process that started at another time, or undefined when the process does not exist or this host offers no way to read it. Two markers are only compared with each other for equality.
|
|
15
|
+
*
|
|
16
|
+
* Linux reads the kernel's own start time (clock ticks since boot, so it holds across a clock change); elsewhere the process table's start time is read through `ps`, which has whole-second resolution, so a pid reused within the same second as its predecessor is not told apart.
|
|
17
|
+
*/
|
|
18
|
+
export function processStartMarker(pid: number): string | undefined {
|
|
19
|
+
return process.platform === "linux" ? procStartTime(pid) : psStartTime(pid);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function procStartTime(pid: number): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
const stat = fs.readFileSync(`/proc/${String(pid)}/stat`, "utf-8");
|
|
25
|
+
// The command name is parenthesised and may itself hold spaces and parentheses, so the fields are counted from the last closing one.
|
|
26
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
27
|
+
return fields[PROC_STAT_START_TIME_INDEX];
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function psStartTime(pid: number): string | undefined {
|
|
34
|
+
try {
|
|
35
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
timeout: PS_TIMEOUT_MS,
|
|
40
|
+
}).trim();
|
|
41
|
+
return out === "" ? undefined : out.replace(/\s+/g, " ");
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* RoomLifecycle — room CRUD and the requester's own outbound half of the wire protocol: create/get/list, join/leave (local CRDT mutation for an already-known room, or a real wire-level room.join/room.leave round trip via joinRemoteRoom/leaveRemoteRoom when this store's own identity isn't yet admitted), invite/decline, membership-grant revocation, kick, and destroy. Split out of mesh-store.ts to reduce it under the repo's max-lines cap.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { LedgerGaps } from "./account-ledger.js";
|
|
5
6
|
import {
|
|
6
7
|
deviceIdFromHex,
|
|
7
8
|
deviceIdToHex,
|
|
@@ -115,7 +116,7 @@ export class RoomLifecycle {
|
|
|
115
116
|
`MeshStore: failed to mint room owner grant for ${roomPath}: ${verdict.reason}`,
|
|
116
117
|
);
|
|
117
118
|
}
|
|
118
|
-
saveRoomToken(slot, roomPath, verdict.token);
|
|
119
|
+
await saveRoomToken(slot, roomPath, verdict.token);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
async createRoom(
|
|
@@ -254,7 +255,7 @@ export class RoomLifecycle {
|
|
|
254
255
|
parsedOutcome.data;
|
|
255
256
|
|
|
256
257
|
const { slot } = this.deps.requireIdentity();
|
|
257
|
-
saveRoomToken(slot, roomPath, grantedToken);
|
|
258
|
+
await saveRoomToken(slot, roomPath, grantedToken);
|
|
258
259
|
|
|
259
260
|
const members = memberList.map((member) => deviceIdToHex(member.device));
|
|
260
261
|
const roomState = parseRoomStateExtension(parsedOutcome.data["room-state"]);
|
|
@@ -375,7 +376,7 @@ export class RoomLifecycle {
|
|
|
375
376
|
);
|
|
376
377
|
}
|
|
377
378
|
const { slot } = this.deps.requireIdentity();
|
|
378
|
-
saveRoomToken(slot, dmPath, parsedOutcome.data["granted-token"]);
|
|
379
|
+
await saveRoomToken(slot, dmPath, parsedOutcome.data["granted-token"]);
|
|
379
380
|
}
|
|
380
381
|
|
|
381
382
|
/**
|
|
@@ -542,7 +543,7 @@ export class RoomLifecycle {
|
|
|
542
543
|
"LEAVE_FAILED",
|
|
543
544
|
);
|
|
544
545
|
}
|
|
545
|
-
deleteRoomToken(slot, roomPath);
|
|
546
|
+
await deleteRoomToken(slot, roomPath);
|
|
546
547
|
this.deps.rooms.delete(roomPath);
|
|
547
548
|
this.deps.messages.delete(roomPath);
|
|
548
549
|
}
|
|
@@ -597,7 +598,7 @@ export class RoomLifecycle {
|
|
|
597
598
|
"MINT_FAILED",
|
|
598
599
|
);
|
|
599
600
|
}
|
|
600
|
-
saveIssuedRoomGrant(slot, roomId, targetId, tokenId);
|
|
601
|
+
await saveIssuedRoomGrant(slot, roomId, targetId, tokenId);
|
|
601
602
|
|
|
602
603
|
const inviter = this.deps.agents.get(inviterId);
|
|
603
604
|
const params: Record<string, unknown> = {
|
|
@@ -658,7 +659,7 @@ export class RoomLifecycle {
|
|
|
658
659
|
});
|
|
659
660
|
await revocation.record(entry, { identity });
|
|
660
661
|
await this.deps.requireTransport().broadcastRevocation([entry]);
|
|
661
|
-
deleteIssuedRoomGrant(slot, roomId, memberId);
|
|
662
|
+
await deleteIssuedRoomGrant(slot, roomId, memberId);
|
|
662
663
|
}
|
|
663
664
|
|
|
664
665
|
/**
|
|
@@ -818,12 +819,13 @@ export class RoomLifecycle {
|
|
|
818
819
|
}
|
|
819
820
|
|
|
820
821
|
/**
|
|
821
|
-
* Revokes every dm:send grant for bearerId that the account's replicated ledger holds as outstanding, whichever machine of the account minted it: mints a revocation-entry for each token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and appends it to the ledger so every machine of the account sees it revoked and a later re-admission starts from nothing outstanding. The same revocation shape revokeMemberGrant gives room:member grants, applied to the user principal's own dm:send grants. Does nothing when the ledger holds no outstanding grant for bearerId.
|
|
822
|
+
* Revokes every dm:send grant for bearerId that the account's replicated ledger holds as outstanding, whichever machine of the account minted it: mints a revocation-entry for each token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and appends it to the ledger so every machine of the account sees it revoked and a later re-admission starts from nothing outstanding. The same revocation shape revokeMemberGrant gives room:member grants, applied to the user principal's own dm:send grants. Does nothing when the ledger holds no outstanding grant for bearerId. A ledger entry that cannot be read does not stop the revocation (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 (`unreadable` above zero means the revocation is incomplete).
|
|
822
823
|
*/
|
|
823
|
-
async revokeAgentDmAccess(bearerId: string): Promise<
|
|
824
|
+
async revokeAgentDmAccess(bearerId: string): Promise<LedgerGaps> {
|
|
824
825
|
const { userIdentity, accountLedger, clock, revocation } =
|
|
825
826
|
this.deps.requireIdentity();
|
|
826
|
-
const outstanding
|
|
827
|
+
const { grants: outstanding, unreadable } =
|
|
828
|
+
await accountLedger.outstandingGrantsChecked("dm", bearerId);
|
|
827
829
|
for (const { tokenId } of outstanding) {
|
|
828
830
|
const entry = await mintRevocationEntry({
|
|
829
831
|
identity: userIdentity,
|
|
@@ -834,5 +836,6 @@ export class RoomLifecycle {
|
|
|
834
836
|
await this.deps.requireTransport().broadcastRevocation([entry]);
|
|
835
837
|
await accountLedger.recordRevocation("dm", bearerId, tokenId, entry);
|
|
836
838
|
}
|
|
839
|
+
return { unreadable };
|
|
837
840
|
}
|
|
838
841
|
}
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
CapabilityToken,
|
|
19
19
|
MessageRef,
|
|
20
20
|
} from "wire-mesh-core/generated/protocol";
|
|
21
|
+
import { WriteLockTimeoutError } from "./pid-lock-file.js";
|
|
21
22
|
import { parseRoomPath } from "./room-path.js";
|
|
22
23
|
import {
|
|
23
24
|
ROOM_MEMBER_CAPABILITY,
|
|
@@ -203,7 +204,12 @@ export class RoomProtocol {
|
|
|
203
204
|
const replyTo = RoomProtocol.replyToFromRefs(params.refs);
|
|
204
205
|
const streamingBehavior = RoomProtocol.streamingBehaviorFromParams(params);
|
|
205
206
|
const id = bytesToHex(params["message-id"]);
|
|
206
|
-
const
|
|
207
|
+
const sentAt = new Date(params["sent-at"]);
|
|
208
|
+
// A number outside the range of dates is an invalid date, and formatting one throws.
|
|
209
|
+
if (Number.isNaN(sentAt.getTime())) {
|
|
210
|
+
return { result: "error", code: "malformed_params" };
|
|
211
|
+
}
|
|
212
|
+
const timestamp = sentAt.toISOString();
|
|
207
213
|
const peerId = this.deps.getPeerId();
|
|
208
214
|
|
|
209
215
|
const parsedPath = parseRoomPath(roomPath);
|
|
@@ -691,7 +697,14 @@ export class RoomProtocol {
|
|
|
691
697
|
if (!verdict.ok) {
|
|
692
698
|
return { result: "error", code: "mint_failed" };
|
|
693
699
|
}
|
|
694
|
-
|
|
700
|
+
try {
|
|
701
|
+
await saveIssuedRoomGrant(slot, roomPath, handle.id, tokenId);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
if (err instanceof WriteLockTimeoutError) {
|
|
704
|
+
return { result: "error", code: "storage_busy" };
|
|
705
|
+
}
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
695
708
|
|
|
696
709
|
const room = this.deps.rooms.get(roomPath);
|
|
697
710
|
if (room !== undefined) {
|
|
@@ -745,7 +758,14 @@ export class RoomProtocol {
|
|
|
745
758
|
if (!verdict.ok) {
|
|
746
759
|
return { result: "error", code: "unauthorized" };
|
|
747
760
|
}
|
|
748
|
-
|
|
761
|
+
try {
|
|
762
|
+
await saveRoomToken(slot, roomPath, token);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
if (err instanceof WriteLockTimeoutError) {
|
|
765
|
+
return { result: "error", code: "storage_busy" };
|
|
766
|
+
}
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
749
769
|
|
|
750
770
|
const parsed = parseRoomPath(roomPath);
|
|
751
771
|
const inviterId = parsed.kind === "owner-named" ? parsed.owner : peerId;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { AgentIdentity, AgentStatus, DeliveryEvent } from "./types.js";
|
|
6
|
+
import { parseIsoTimestampMs } from "./iso-timestamp.js";
|
|
6
7
|
import type { MeshStatePatch, PeerInfo } from "./wire-protocol.js";
|
|
7
8
|
|
|
8
9
|
/** The state and collaborators StaleAgentChecker needs from MeshStore -- the core agent/delivery/peer/identity Maps it reads and mutates directly (shared references into MeshStore's own fields), plus the two DeliveryEngine operations a status change and an offline patch need to broadcast. */
|
|
@@ -84,7 +85,8 @@ export class StaleAgentChecker {
|
|
|
84
85
|
const purgeIds: string[] = [];
|
|
85
86
|
for (const [id, agent] of agents) {
|
|
86
87
|
if (agent.status !== "offline") continue;
|
|
87
|
-
|
|
88
|
+
// A start time that does not parse cannot show the agent is recent, so it is purged like an old one.
|
|
89
|
+
const startedAt = parseIsoTimestampMs(agent.startedAt) ?? 0;
|
|
88
90
|
if (startedAt < offlineThreshold) {
|
|
89
91
|
purgeIds.push(id);
|
|
90
92
|
}
|