@cotal-ai/core 0.112.0 → 0.113.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.
- package/dist/checkpoint-answer.js +3 -3
- package/dist/checkpoint-answer.js.map +1 -1
- package/dist/connector.d.ts +5 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-action.js +5 -5
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-checkpoint.js +7 -7
- package/dist/endpoint-checkpoint.js.map +1 -1
- package/dist/endpoint-records.d.ts +7 -0
- package/dist/endpoint-records.d.ts.map +1 -1
- package/dist/endpoint-records.js +16 -7
- package/dist/endpoint-records.js.map +1 -1
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +9 -17
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-virtual.d.ts.map +1 -1
- package/dist/endpoint-virtual.js +3 -4
- package/dist/endpoint-virtual.js.map +1 -1
- package/dist/endpoint-work.d.ts.map +1 -1
- package/dist/endpoint-work.js +6 -14
- package/dist/endpoint-work.js.map +1 -1
- package/dist/endpoint.d.ts +23 -1
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +123 -74
- package/dist/endpoint.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/issued-authority.d.ts +8 -0
- package/dist/issued-authority.d.ts.map +1 -1
- package/dist/issued-authority.js +18 -6
- package/dist/issued-authority.js.map +1 -1
- package/dist/issued-store-recreate.d.ts +45 -0
- package/dist/issued-store-recreate.d.ts.map +1 -0
- package/dist/issued-store-recreate.js +135 -0
- package/dist/issued-store-recreate.js.map +1 -0
- package/dist/lifecycle-state.d.ts.map +1 -1
- package/dist/lifecycle-state.js +7 -44
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/members.d.ts +4 -0
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +29 -1
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +1 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +25 -3
- package/dist/provision.js.map +1 -1
- package/dist/run-migration.d.ts.map +1 -1
- package/dist/run-migration.js +2 -2
- package/dist/run-migration.js.map +1 -1
- package/dist/run-notice.d.ts.map +1 -1
- package/dist/run-notice.js +2 -2
- package/dist/run-notice.js.map +1 -1
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +7 -16
- package/dist/schema-profile.js.map +1 -1
- package/dist/spaces.js +1 -1
- package/dist/spaces.js.map +1 -1
- package/dist/subjects.d.ts +8 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +25 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -32,7 +32,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
|
|
|
32
32
|
import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
33
33
|
import { openDeliveryRegistry } from "./lease.js";
|
|
34
34
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, decodeChannelEntry, } from "./channels.js";
|
|
35
|
-
import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
35
|
+
import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, isPresenceRow, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
36
36
|
import { isPermissionDenied } from "./permission-denied.js";
|
|
37
37
|
export { isPermissionDenied };
|
|
38
38
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
@@ -255,6 +255,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
255
255
|
* {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
|
|
256
256
|
deliveryServeSub;
|
|
257
257
|
deliveryAdminServeSub;
|
|
258
|
+
/** The lifecycles whose membership inventory this daemon has been asked for, as principal to uids
|
|
259
|
+
* (#4965). A durable join or leave bound to one of them is refused, so no row of a retiring lifecycle
|
|
260
|
+
* is written after its inventory read starts. An entry goes when a later inventory for the same
|
|
261
|
+
* principal finds its ACL row retired; an alias that never retires again keeps its one entry until
|
|
262
|
+
* the daemon restarts. */
|
|
263
|
+
retiringLifecycles = new Map();
|
|
264
|
+
/** The durable joins and leaves running now, by `<principal>/<lifecycleUid>`: the writes an
|
|
265
|
+
* inventory of that lifecycle waits for before it reads. */
|
|
266
|
+
membershipWrites = new Map();
|
|
258
267
|
/** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
|
|
259
268
|
* Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
|
|
260
269
|
plane3Quiesced = false;
|
|
@@ -2139,7 +2148,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2139
2148
|
* PEER's reply lane (`ctl.delivery.<victim>.reply.<n>`) and turn the responder into a confused
|
|
2140
2149
|
* deputy — the broker does NOT permission-check the requester's embedded reply subject. With it, a
|
|
2141
2150
|
* reply is published only when `m.reply` is under the AUTHENTICATED request subject
|
|
2142
|
-
* (`${m.subject}.reply.…`), binding the reply to the broker-policed sender token.
|
|
2151
|
+
* (`${m.subject}.reply.…`), binding the reply to the broker-policed sender token.
|
|
2152
|
+
*
|
|
2153
|
+
* Requests are answered one at a time, in arrival order. A request `concurrent` accepts is answered
|
|
2154
|
+
* in its own time instead: it waits for none of the requests in front of it, including one still
|
|
2155
|
+
* running, and holds up none behind it. Only for a request that shares no state with the service's
|
|
2156
|
+
* other requests. */
|
|
2143
2157
|
serveControl(service, handler, opts = {}) {
|
|
2144
2158
|
if (!this.nc)
|
|
2145
2159
|
throw new Error("endpoint not started");
|
|
@@ -2147,6 +2161,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2147
2161
|
queue: service,
|
|
2148
2162
|
});
|
|
2149
2163
|
this.subs.push(sub);
|
|
2164
|
+
const inOwnTime = opts.concurrent;
|
|
2165
|
+
// With `concurrent`, the in-order requests are answered one after another on this chain, and the
|
|
2166
|
+
// reading below never waits on it, so a request answered in its own time is read and answered
|
|
2167
|
+
// while an in-order one is still running.
|
|
2168
|
+
let inOrder = Promise.resolve();
|
|
2150
2169
|
void (async () => {
|
|
2151
2170
|
for await (const m of sub) {
|
|
2152
2171
|
// Sender-bound reply guard (confused-deputy fix): never respond to a reply target outside the
|
|
@@ -2155,47 +2174,64 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2155
2174
|
this.emit("error", new Error(`rejected ${service} request on ${m.subject}: reply target "${m.reply ?? "(none)"}" is not under the sender's own reply subtree`));
|
|
2156
2175
|
continue;
|
|
2157
2176
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
// Authenticity guard (fail closed): control is the most privileged surface
|
|
2163
|
-
// (start/stop). The sender is encoded in the subject (ctl.<svc>.<sender>), which
|
|
2164
|
-
// the server policed who could publish; the payload `from` is advisory and must
|
|
2165
|
-
// match. Reject before the handler acts on a request claiming a forged sender.
|
|
2166
|
-
const parsed = parseSubject(m.subject);
|
|
2167
|
-
if (!parsed || req.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
2168
|
-
this.emit("error", new Error(`rejected control request on ${m.subject}: from ${req.from?.id ?? "(none)"} ` +
|
|
2169
|
-
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
2170
|
-
reply = { ok: false, error: "sender mismatch - request rejected" };
|
|
2171
|
-
}
|
|
2172
|
-
else {
|
|
2173
|
-
reply = await handler(req);
|
|
2174
|
-
}
|
|
2175
|
-
body = JSON.stringify(reply);
|
|
2176
|
-
}
|
|
2177
|
-
catch (e) {
|
|
2178
|
-
body = JSON.stringify({ ok: false, error: e.message });
|
|
2177
|
+
const answer = () => this.answerControl(service, handler, m);
|
|
2178
|
+
if (inOwnTime === undefined) {
|
|
2179
|
+
await answer();
|
|
2180
|
+
continue;
|
|
2179
2181
|
}
|
|
2182
|
+
let own = false;
|
|
2180
2183
|
try {
|
|
2181
|
-
m.
|
|
2182
|
-
}
|
|
2183
|
-
catch (e) {
|
|
2184
|
-
// `respond` signals a missing reply inbox by returning false; it throws when the reply exceeds the
|
|
2185
|
-
// broker's `max_payload`. Silence would leave the caller a bare timeout it cannot tell apart from
|
|
2186
|
-
// a dead service.
|
|
2187
|
-
const refused = { ok: false, error: `the ${service} reply is ${Buffer.byteLength(body)} bytes and was not published: ${e.message}` };
|
|
2188
|
-
try {
|
|
2189
|
-
m.respond(JSON.stringify(refused));
|
|
2190
|
-
}
|
|
2191
|
-
catch (err) {
|
|
2192
|
-
this.emit("error", new Error(`could not answer ${service} request on ${m.subject}: ${refused.error}, and the refusal failed too: ${err.message}`));
|
|
2193
|
-
}
|
|
2184
|
+
own = inOwnTime(m.json());
|
|
2194
2185
|
}
|
|
2186
|
+
catch { /* unreadable: answered in order, as a refusal */ }
|
|
2187
|
+
if (own)
|
|
2188
|
+
void answer().catch((e) => this.emit("error", e));
|
|
2189
|
+
else
|
|
2190
|
+
inOrder = inOrder.then(answer).catch((e) => { this.emit("error", e); });
|
|
2195
2191
|
}
|
|
2196
2192
|
})().catch((e) => this.emit("error", e));
|
|
2197
2193
|
return sub;
|
|
2198
2194
|
}
|
|
2195
|
+
/** Answer one control request for {@link serveControl}: the sender guard, the handler, and the reply. */
|
|
2196
|
+
async answerControl(service, handler, m) {
|
|
2197
|
+
let body;
|
|
2198
|
+
try {
|
|
2199
|
+
let reply;
|
|
2200
|
+
const req = m.json();
|
|
2201
|
+
// Authenticity guard (fail closed): control is the most privileged surface
|
|
2202
|
+
// (start/stop). The sender is encoded in the subject (ctl.<svc>.<sender>), which
|
|
2203
|
+
// the server policed who could publish; the payload `from` is advisory and must
|
|
2204
|
+
// match. Reject before the handler acts on a request claiming a forged sender.
|
|
2205
|
+
const parsed = parseSubject(m.subject);
|
|
2206
|
+
if (!parsed || req.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
|
|
2207
|
+
this.emit("error", new Error(`rejected control request on ${m.subject}: from ${req.from?.id ?? "(none)"} ` +
|
|
2208
|
+
`does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
|
|
2209
|
+
reply = { ok: false, error: "sender mismatch - request rejected" };
|
|
2210
|
+
}
|
|
2211
|
+
else {
|
|
2212
|
+
reply = await handler(req);
|
|
2213
|
+
}
|
|
2214
|
+
body = JSON.stringify(reply);
|
|
2215
|
+
}
|
|
2216
|
+
catch (e) {
|
|
2217
|
+
body = JSON.stringify({ ok: false, error: e.message });
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
m.respond(body);
|
|
2221
|
+
}
|
|
2222
|
+
catch (e) {
|
|
2223
|
+
// `respond` signals a missing reply inbox by returning false; it throws when the reply exceeds the
|
|
2224
|
+
// broker's `max_payload`. Silence would leave the caller a bare timeout it cannot tell apart from
|
|
2225
|
+
// a dead service.
|
|
2226
|
+
const refused = { ok: false, error: `the ${service} reply is ${Buffer.byteLength(body)} bytes and was not published: ${e.message}` };
|
|
2227
|
+
try {
|
|
2228
|
+
m.respond(JSON.stringify(refused));
|
|
2229
|
+
}
|
|
2230
|
+
catch (err) {
|
|
2231
|
+
this.emit("error", new Error(`could not answer ${service} request on ${m.subject}: ${refused.error}, and the refusal failed too: ${err.message}`));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2199
2235
|
/** Send a control request to a service and await its reply (client side). Like {@link requestDelivery},
|
|
2200
2236
|
* the reply rides a BOUNDED subject UNDER the request subject (`ctl.<service>.<id>.reply.<uuid>`), not
|
|
2201
2237
|
* the per-id `_INBOX`.
|
|
@@ -4922,13 +4958,36 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4922
4958
|
return { ok: false, error: `durableJoin: no read ACL on record for ${caller} (not provisioned for durable delivery)` };
|
|
4923
4959
|
if (!channelInAllow(acl.allowSubscribe, channel))
|
|
4924
4960
|
return { ok: false, error: `channel "${channel}" is not within your read ACL [${acl.allowSubscribe.join(", ")}]` };
|
|
4961
|
+
const uid = acl.lifecycleUid;
|
|
4925
4962
|
try {
|
|
4926
|
-
return { ok: true, data: await this.durableJoinFor(caller, channel,
|
|
4963
|
+
return { ok: true, data: await this.membershipWrite(caller, uid, () => this.durableJoinFor(caller, channel, uid)) };
|
|
4927
4964
|
}
|
|
4928
4965
|
catch (e) {
|
|
4929
4966
|
return { ok: false, error: e.message };
|
|
4930
4967
|
}
|
|
4931
4968
|
}
|
|
4969
|
+
/** Run one durable join or leave that writes `principal`'s rows of `lifecycleUid`, refused once that
|
|
4970
|
+
* lifecycle's inventory has been asked for ({@link retiringLifecycles}). The check and the
|
|
4971
|
+
* registration have no await between them, so an inventory either finds this write running and
|
|
4972
|
+
* waits for it, or this write finds the inventory's mark and is refused. */
|
|
4973
|
+
async membershipWrite(principal, lifecycleUid, write) {
|
|
4974
|
+
if (this.retiringLifecycles.get(principal)?.has(lifecycleUid))
|
|
4975
|
+
throw new Error(`lifecycle ${lifecycleUid} of ${principal} is retiring: its membership inventory was requested, so its durable memberships no longer change`);
|
|
4976
|
+
const key = `${principal}/${lifecycleUid}`;
|
|
4977
|
+
const running = write();
|
|
4978
|
+
let writes = this.membershipWrites.get(key);
|
|
4979
|
+
if (writes === undefined)
|
|
4980
|
+
this.membershipWrites.set(key, (writes = new Set()));
|
|
4981
|
+
writes.add(running);
|
|
4982
|
+
try {
|
|
4983
|
+
return await running;
|
|
4984
|
+
}
|
|
4985
|
+
finally {
|
|
4986
|
+
writes.delete(running);
|
|
4987
|
+
if (writes.size === 0)
|
|
4988
|
+
this.membershipWrites.delete(key);
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4932
4991
|
/** LEAVE must NOT require current-ACL coverage. Leave fires precisely when the ACL was narrowed/revoked
|
|
4933
4992
|
* (a refused live sub → {@link closeRefusedMembership}); gating the tombstone on the current ACL would
|
|
4934
4993
|
* loop forever and leave the SPEC §7 boundary open (the membership could resume if the ACL is later
|
|
@@ -4959,8 +5018,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4959
5018
|
const existing = await readMember(await this.membersRegistry(), channel, caller, uid);
|
|
4960
5019
|
if (!existing)
|
|
4961
5020
|
return { ok: true, data: { channel, alreadyLeft: true } }; // nothing to tombstone — idempotent
|
|
5021
|
+
const generation = args.generation;
|
|
4962
5022
|
try {
|
|
4963
|
-
await this.durableLeaveFor(caller, channel, uid,
|
|
5023
|
+
await this.membershipWrite(caller, uid, () => this.durableLeaveFor(caller, channel, uid, generation));
|
|
4964
5024
|
}
|
|
4965
5025
|
catch (e) {
|
|
4966
5026
|
return { ok: false, error: e.message };
|
|
@@ -5183,7 +5243,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5183
5243
|
if (i >= 0)
|
|
5184
5244
|
this.subs.splice(i, 1);
|
|
5185
5245
|
}
|
|
5186
|
-
|
|
5246
|
+
// The membership inventory is read-only and shares nothing with the other admin commands, so it
|
|
5247
|
+
// does not queue behind them. In a mass stop each retirement's inventory followed every other
|
|
5248
|
+
// seat's `evictPrincipal` on this rail and missed its 5 s deadline (#4965).
|
|
5249
|
+
this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), {
|
|
5250
|
+
boundReply: true,
|
|
5251
|
+
concurrent: (req) => req.op === "lifecycleMemberships",
|
|
5252
|
+
});
|
|
5187
5253
|
}
|
|
5188
5254
|
// ---- the peer-readable liveness surface (#1577) --------------------------
|
|
5189
5255
|
/** Bind this endpoint as the liveness RESPONDER for one plane, answering presence and nothing
|
|
@@ -5509,6 +5575,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5509
5575
|
catch (e) {
|
|
5510
5576
|
return { ok: false, error: `lifecycleMemberships: ${e.message}` };
|
|
5511
5577
|
}
|
|
5578
|
+
// The lifecycle is retiring, so its memberships stop changing here (#4965): a join or leave bound
|
|
5579
|
+
// to it is refused from now on, and the read starts after the ones already running have finished.
|
|
5580
|
+
// A row they write is then in the read; nothing it misses can be written afterwards.
|
|
5581
|
+
let marked = this.retiringLifecycles.get(principal);
|
|
5582
|
+
if (marked === undefined)
|
|
5583
|
+
this.retiringLifecycles.set(principal, (marked = new Set()));
|
|
5584
|
+
// An earlier lifecycle of this principal whose ACL row is retired can bind no join or leave any
|
|
5585
|
+
// more, so its mark goes. A failed read keeps the mark, which only ever refuses.
|
|
5586
|
+
for (const earlier of [...marked])
|
|
5587
|
+
if (earlier !== uid && await aclRetired(await this.aclRegistry(), principal, earlier).catch(() => false))
|
|
5588
|
+
marked.delete(earlier);
|
|
5589
|
+
marked.add(uid);
|
|
5590
|
+
await Promise.allSettled(this.membershipWrites.get(`${principal}/${uid}`) ?? []);
|
|
5512
5591
|
try {
|
|
5513
5592
|
const channels = await listLifecycleMemberChannels(await this.membersRegistry(), principal, uid);
|
|
5514
5593
|
return { ok: true, data: { complete: true, channels } };
|
|
@@ -7099,21 +7178,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
7099
7178
|
catch {
|
|
7100
7179
|
return undefined;
|
|
7101
7180
|
}
|
|
7102
|
-
if (!
|
|
7181
|
+
if (!isPresenceRow(p, e.key)) {
|
|
7103
7182
|
this.presenceBindingDrops++;
|
|
7104
|
-
this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a record with a string card.name, a known status, a finite ts, string display fields and a condition with a string code`));
|
|
7105
|
-
return undefined;
|
|
7106
|
-
}
|
|
7107
|
-
// Defense-in-depth: the KV key IS the publisher's identity —
|
|
7108
|
-
// publishPresence() writes its record under card.id — so a record whose embedded card.id disagrees
|
|
7109
|
-
// with its bucket key is forged or corrupt. Drop it rather than surface a spoofed roster identity.
|
|
7110
|
-
// The write-side scoping ($KV.<presenceBucket>.<own-id>) is the primary guard; this rejects a
|
|
7111
|
-
// mis-keyed record even if a broad writer slips one in under another agent's key.
|
|
7112
|
-
if (p.card.id !== e.key) {
|
|
7113
|
-
this.presenceBindingDrops++;
|
|
7114
|
-
this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: card.id ${JSON.stringify(p.card.id)} does not match its KV key`));
|
|
7183
|
+
this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a usable presence row (a record with card.id equal to its KV key, a string card.name, a known status, a finite ts, string display fields and a condition with a string code)`));
|
|
7115
7184
|
return undefined;
|
|
7116
7185
|
}
|
|
7186
|
+
// isPresenceRow is the one gate: it refuses a row whose card.id disagrees with the KV key, so a
|
|
7187
|
+
// forged or mis-keyed record never surfaces a spoofed roster identity. The write-side scoping
|
|
7188
|
+
// ($KV.<presenceBucket>.<own-id>) is the primary guard.
|
|
7117
7189
|
return p;
|
|
7118
7190
|
}
|
|
7119
7191
|
applyPresence(id, raw) {
|
|
@@ -7537,29 +7609,6 @@ function assertPartsSerializable(parts) {
|
|
|
7537
7609
|
}
|
|
7538
7610
|
}
|
|
7539
7611
|
}
|
|
7540
|
-
/** A `Record` so that adding a status to {@link PresenceStatus} fails to compile until the reader accepts it. */
|
|
7541
|
-
const PRESENCE_STATUSES = { idle: true, waiting: true, working: true, offline: true };
|
|
7542
|
-
/** The presence fields this observer computes with, in their SPEC §6 types. A missing `ts` or text
|
|
7543
|
-
* such as `"nope"` makes the liveness arithmetic NaN, and a JSON `1e400` parses as `Infinity`;
|
|
7544
|
-
* neither age is ever greater than the TTL, so the row would stay live after its key expired
|
|
7545
|
-
* (#2612). `card.name` is the roster sort key: a non-string throws inside the watch loop and
|
|
7546
|
-
* ends the watch. `status` decides offline and is what roster consumers read. `card.role`,
|
|
7547
|
-
* `activity`, `attention` and `condition.code` are printed as text, and the peer writes its own
|
|
7548
|
-
* row: an object such as `{"toString":null,"valueOf":null}` throws on conversion and would fail
|
|
7549
|
-
* every reader's roster. A present `condition` must be an object with a string `code`, which SPEC
|
|
7550
|
-
* §6 makes required: `null`, a bare string or an object with no code is dropped too. `card.id` is
|
|
7551
|
-
* left to the KV-key check, which rejects anything but the key string. */
|
|
7552
|
-
function isUsablePresence(value) {
|
|
7553
|
-
return isRecord(value) && isRecord(value.card) && typeof value.card.name === "string" &&
|
|
7554
|
-
isOptionalString(value.card.role) &&
|
|
7555
|
-
typeof value.status === "string" && Object.hasOwn(PRESENCE_STATUSES, value.status) &&
|
|
7556
|
-
typeof value.ts === "number" && Number.isFinite(value.ts) &&
|
|
7557
|
-
isOptionalString(value.activity) && isOptionalString(value.attention) &&
|
|
7558
|
-
(value.condition === undefined || (isRecord(value.condition) && typeof value.condition.code === "string"));
|
|
7559
|
-
}
|
|
7560
|
-
function isOptionalString(value) {
|
|
7561
|
-
return value === undefined || typeof value === "string";
|
|
7562
|
-
}
|
|
7563
7612
|
/** Shallow-equal two per-channel-mode maps (presence dedup): a change must re-emit, so an attention
|
|
7564
7613
|
* toggle isn't swallowed as a quiet heartbeat. Absent and empty compare equal. */
|
|
7565
7614
|
function sameChannelModes(a, b) {
|