agent-protocols 0.7.0 → 0.9.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/README.md +38 -9
- package/dist/delegation.d.ts +98 -29
- package/dist/delegation.d.ts.map +1 -1
- package/dist/delegation.js +377 -51
- package/dist/delegation.js.map +1 -1
- package/dist/discourse.d.ts +196 -74
- package/dist/discourse.d.ts.map +1 -1
- package/dist/discourse.js +568 -116
- package/dist/discourse.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/http-client.d.ts +47 -17
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +118 -39
- package/dist/http-client.js.map +1 -1
- package/dist/identity.d.ts +101 -4
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +334 -8
- package/dist/identity.js.map +1 -1
- package/dist/local-connector/catalog.d.ts +1 -16
- package/dist/local-connector/catalog.d.ts.map +1 -1
- package/dist/local-connector/catalog.js +53 -63
- package/dist/local-connector/catalog.js.map +1 -1
- package/dist/local-connector/engine.d.ts +57 -33
- package/dist/local-connector/engine.d.ts.map +1 -1
- package/dist/local-connector/engine.js +652 -607
- package/dist/local-connector/engine.js.map +1 -1
- package/dist/local-connector/inputs.d.ts +29 -76
- package/dist/local-connector/inputs.d.ts.map +1 -1
- package/dist/local-connector/inputs.js +19 -1
- package/dist/local-connector/inputs.js.map +1 -1
- package/dist/local-connector/projection.d.ts +24 -9
- package/dist/local-connector/projection.d.ts.map +1 -1
- package/dist/local-connector/projection.js +79 -40
- package/dist/local-connector/projection.js.map +1 -1
- package/dist/local-connector/state.d.ts +10 -3
- package/dist/local-connector/state.d.ts.map +1 -1
- package/dist/local-connector/state.js +3 -0
- package/dist/local-connector/state.js.map +1 -1
- package/dist/local-connector/views.d.ts +36 -12
- package/dist/local-connector/views.d.ts.map +1 -1
- package/dist/local-connector/views.js +49 -31
- package/dist/local-connector/views.js.map +1 -1
- package/dist/profile.d.ts +23 -9
- package/dist/profile.d.ts.map +1 -1
- package/dist/profile.js +120 -3
- package/dist/profile.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
// The stateful local connector engine. `LocalConnector` is transport-neutral:
|
|
2
|
-
// it signs on the active agent's behalf, calls
|
|
2
|
+
// it signs on the active agent's behalf, calls Agent Protocols services over
|
|
3
3
|
// HTTP, materializes local room state by projecting accepted records, derives
|
|
4
|
-
// an actionable inbox,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// and pure projection live in
|
|
9
|
-
|
|
4
|
+
// an actionable inbox, tracks the head presented to the agent, and holds
|
|
5
|
+
// head-bound drafts on a room-head mismatch — all behind a single `callTool`
|
|
6
|
+
// dispatcher. The agent never sees the signing key or a reusable request JWT.
|
|
7
|
+
// This is one deep module; its methods are mutually recursive over `this`, so
|
|
8
|
+
// the class stays whole here while the data shapes and pure projection live in
|
|
9
|
+
// sibling submodules.
|
|
10
|
+
import { canJoinDirectly, discourseEvent, eventType, isRedactedRecord, roomCreateEvent, roomJoinRequestEvent, validateDiscourseEnvelope, validateRoomPath, verifyServerRecord, } from "../discourse.js";
|
|
10
11
|
import { ClientNonceManager, DEFAULT_REQUEST_JWT_TTL_SECS, createRequestBinding, createRequestJwtClaims, publicKeyBytes, serviceOrigin, unixTimeMillis, unixTimeSecs, withMentions, } from "../identity.js";
|
|
11
|
-
import { DelegationClient, DiscourseClient, ProfileClient, } from "../http-client.js";
|
|
12
|
-
import { delegationGrantEvent, delegationRevokeEvent, isPrincipalAlias,
|
|
12
|
+
import { DelegationClient, DiscourseClient, HttpResponseError, ProfileClient, } from "../http-client.js";
|
|
13
|
+
import { delegationGrantEvent, delegationRevokeEvent, isPrincipalAlias, validateDelegationEventAuthority, verifyDelegationCredential, } from "../delegation.js";
|
|
13
14
|
import { profileUpdateEvent, } from "../profile.js";
|
|
14
|
-
import {
|
|
15
|
+
import { TOOL_AGENT_STATUS_LIST, TOOL_AGENT_STATUS_SET, TOOL_DELEGATIONS_LIST, TOOL_DELEGATION_CHECK, TOOL_DELEGATION_GRANT, TOOL_DELEGATION_REVOKE, TOOL_DRAFTS_LIST, TOOL_DRAFT_COMMIT, TOOL_IDENTITY_CURRENT, TOOL_INBOX_ACK, TOOL_INBOX_NEXT, TOOL_JOIN_REQUESTS_LIST, TOOL_JOIN_REQUEST_REVIEW, TOOL_PRINCIPAL_RESOLVE, TOOL_PROFILE_UPDATE, TOOL_ROOMS_LIST, TOOL_ROOM_CREATE, TOOL_ROOM_JOIN, TOOL_ROOM_MEMBERS_LIST, TOOL_ROOM_SEND_MESSAGE, TOOL_ROOM_STATE, TOOL_ROOM_SUBMIT_EVENT, TOOL_ROOM_TIMELINE, } from "./catalog.js";
|
|
15
16
|
import { invalidPayload, isRecord, normalizeHost, permissionDenied, } from "./internal.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { validateRoomsListScope, } from "./inputs.js";
|
|
18
|
+
import { applyRecordProjection, baseIsCurrent, eventTypeRequiresRoomHead, inboxEntryReady, isDuplicateRecord, materializeCreator, membershipFilter, recordAdvancesRoomHead, validateNextRecord, validateRecordBasePrecondition, } from "./projection.js";
|
|
19
|
+
import { LocalConnectorState, } from "./state.js";
|
|
18
20
|
import { roomSummaryFromResponse, timelineItemFromRecord, } from "./views.js";
|
|
21
|
+
/** Automatic `send_anyway` re-sign attempts before a draft is held. */
|
|
22
|
+
export const SEND_ANYWAY_MAX_ATTEMPTS = 3;
|
|
23
|
+
/** Lease on an inbox item claimed with `claim: true`. */
|
|
24
|
+
export const INBOX_CLAIM_LEASE_MS = 60_000;
|
|
19
25
|
function roomKeyString(key) {
|
|
20
26
|
return `${key.host} ${key.roomId}`;
|
|
21
27
|
}
|
|
@@ -100,7 +106,7 @@ function eventDraftValue(input) {
|
|
|
100
106
|
};
|
|
101
107
|
}
|
|
102
108
|
function heldDraftOptions() {
|
|
103
|
-
return ["revise", "
|
|
109
|
+
return ["revise", "send", "drop"];
|
|
104
110
|
}
|
|
105
111
|
function profileToMemberProfile(profile) {
|
|
106
112
|
return {
|
|
@@ -121,11 +127,22 @@ function parseCursor(cursor) {
|
|
|
121
127
|
const parsed = Number.parseInt(cursor, 10);
|
|
122
128
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
123
129
|
}
|
|
130
|
+
/** Offset-cursor page over a sorted list, with `next_cursor` when more follow. */
|
|
131
|
+
function page(items, cursor, limit) {
|
|
132
|
+
const offset = parseCursor(cursor);
|
|
133
|
+
const slice = items.slice(offset, offset + limit);
|
|
134
|
+
return offset + limit < items.length
|
|
135
|
+
? { items: slice, next_cursor: String(offset + limit) }
|
|
136
|
+
: { items: slice };
|
|
137
|
+
}
|
|
124
138
|
function sortedAgentStatuses(statuses) {
|
|
125
139
|
return [...statuses.entries()]
|
|
126
140
|
.sort((a, b) => compareStrings(a[0], b[0]))
|
|
127
141
|
.map(([, status]) => status);
|
|
128
142
|
}
|
|
143
|
+
function isHttpError(error, code) {
|
|
144
|
+
return error instanceof HttpResponseError && error.code === code;
|
|
145
|
+
}
|
|
129
146
|
export class LocalConnector {
|
|
130
147
|
signer;
|
|
131
148
|
state;
|
|
@@ -181,8 +198,10 @@ export class LocalConnector {
|
|
|
181
198
|
this.applyRecordTo(key, record);
|
|
182
199
|
}
|
|
183
200
|
applyRecordTo(key, record) {
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
if (!isRedactedRecord(record)) {
|
|
202
|
+
validateDiscourseEnvelope(record.envelope);
|
|
203
|
+
validateRoomPath(record.envelope, record.room_id);
|
|
204
|
+
}
|
|
186
205
|
verifyServerRecord(record);
|
|
187
206
|
const activeAgent = this.agentId();
|
|
188
207
|
const room = this.state.rooms.get(roomKeyString(key));
|
|
@@ -193,11 +212,11 @@ export class LocalConnector {
|
|
|
193
212
|
return;
|
|
194
213
|
validateNextRecord(room, record);
|
|
195
214
|
validateRecordBasePrecondition(room, record);
|
|
196
|
-
const item = timelineItemFromRecord(record);
|
|
215
|
+
const item = timelineItemFromRecord(record, room.room.types ?? []);
|
|
197
216
|
const newInbox = [];
|
|
198
217
|
applyRecordProjection(room, record, item, activeAgent, newInbox);
|
|
199
218
|
let clearedStatus;
|
|
200
|
-
if (record.envelope.event.type === eventType.ROOM_MEMBER_REMOVE) {
|
|
219
|
+
if (!isRedactedRecord(record) && record.envelope.event.type === eventType.ROOM_MEMBER_REMOVE) {
|
|
201
220
|
const payload = record.envelope.event.payload;
|
|
202
221
|
clearedStatus = payload?.member;
|
|
203
222
|
}
|
|
@@ -234,8 +253,6 @@ export class LocalConnector {
|
|
|
234
253
|
switch (name) {
|
|
235
254
|
case TOOL_IDENTITY_CURRENT:
|
|
236
255
|
return this.identityCurrent();
|
|
237
|
-
case TOOL_HOSTS_LIST:
|
|
238
|
-
return this.hostsList();
|
|
239
256
|
case TOOL_PRINCIPAL_RESOLVE:
|
|
240
257
|
return this.principalResolve(input);
|
|
241
258
|
case TOOL_DELEGATION_CHECK:
|
|
@@ -246,56 +263,32 @@ export class LocalConnector {
|
|
|
246
263
|
return this.delegationGrant(input);
|
|
247
264
|
case TOOL_DELEGATION_REVOKE:
|
|
248
265
|
return this.delegationRevoke(input);
|
|
249
|
-
case TOOL_ROOMS_SEARCH:
|
|
250
|
-
return this.roomsSearch(input);
|
|
251
266
|
case TOOL_ROOMS_LIST:
|
|
252
267
|
return this.roomsList(input);
|
|
253
|
-
case TOOL_ROOM_OPEN:
|
|
254
|
-
return this.roomOpen(input);
|
|
255
268
|
case TOOL_ROOM_STATE:
|
|
256
|
-
return this.
|
|
269
|
+
return this.roomState(input);
|
|
257
270
|
case TOOL_ROOM_MEMBERS_LIST:
|
|
258
271
|
return this.roomMembersList(input);
|
|
259
|
-
case TOOL_ROOM_MEMBER_GET:
|
|
260
|
-
return this.roomMemberGet(input);
|
|
261
272
|
case TOOL_AGENT_STATUS_LIST:
|
|
262
273
|
return this.agentStatusList(input);
|
|
263
|
-
case TOOL_AGENT_STATUS_GET:
|
|
264
|
-
return this.agentStatusGet(input);
|
|
265
274
|
case TOOL_AGENT_STATUS_SET:
|
|
266
275
|
return this.agentStatusSet(input);
|
|
267
|
-
case TOOL_AGENT_STATUS_CLEAR:
|
|
268
|
-
return this.agentStatusClear(input);
|
|
269
276
|
case TOOL_ROOM_TIMELINE:
|
|
270
277
|
return this.roomTimeline(input);
|
|
271
|
-
case TOOL_ROOM_UNREAD:
|
|
272
|
-
return this.roomUnread(input);
|
|
273
|
-
case TOOL_ROOM_MARK_READ:
|
|
274
|
-
return this.roomMarkRead(input);
|
|
275
278
|
case TOOL_INBOX_NEXT:
|
|
276
279
|
return this.inboxNext(input);
|
|
277
280
|
case TOOL_INBOX_ACK:
|
|
278
281
|
return this.inboxAck(input);
|
|
279
282
|
case TOOL_DRAFTS_LIST:
|
|
280
283
|
return this.draftsList(input);
|
|
281
|
-
case TOOL_DRAFT_GET:
|
|
282
|
-
return this.draftGet(input);
|
|
283
284
|
case TOOL_DRAFT_COMMIT:
|
|
284
285
|
return this.draftCommit(input);
|
|
285
|
-
case TOOL_DRAFT_DROP:
|
|
286
|
-
return this.draftDrop(input);
|
|
287
286
|
case TOOL_PROFILE_UPDATE:
|
|
288
287
|
return this.profileUpdate(input);
|
|
289
288
|
case TOOL_ROOM_CREATE:
|
|
290
289
|
return this.roomCreate(input);
|
|
291
290
|
case TOOL_ROOM_JOIN:
|
|
292
291
|
return this.roomJoin(input);
|
|
293
|
-
case TOOL_ROOM_JOIN_REQUEST:
|
|
294
|
-
return this.roomJoinRequest(input);
|
|
295
|
-
case TOOL_ROOM_JOIN_WHEN_APPROVED:
|
|
296
|
-
return this.roomJoinWhenApproved(input);
|
|
297
|
-
case TOOL_ROOM_LEAVE:
|
|
298
|
-
return this.roomLeave(input);
|
|
299
292
|
case TOOL_ROOM_SEND_MESSAGE:
|
|
300
293
|
return this.roomSendMessage(input);
|
|
301
294
|
case TOOL_ROOM_SUBMIT_EVENT:
|
|
@@ -317,13 +310,14 @@ export class LocalConnector {
|
|
|
317
310
|
hosts: this.sortedHosts(),
|
|
318
311
|
};
|
|
319
312
|
}
|
|
320
|
-
|
|
321
|
-
|
|
313
|
+
async roomsList(input) {
|
|
314
|
+
validateRoomsListScope(input);
|
|
315
|
+
return (input.scope ?? "known") === "public" ? this.publicRooms(input) : this.knownRooms(input);
|
|
322
316
|
}
|
|
323
|
-
async
|
|
317
|
+
async publicRooms(input) {
|
|
324
318
|
const host = normalizeHost(input.host);
|
|
325
319
|
this.requireAllowedHost(host);
|
|
326
|
-
const
|
|
320
|
+
const response = await this.discourse(host).publicRooms({
|
|
327
321
|
status: input.status,
|
|
328
322
|
tag: input.tag,
|
|
329
323
|
keyword: input.keyword,
|
|
@@ -334,72 +328,86 @@ export class LocalConnector {
|
|
|
334
328
|
limit: input.limit,
|
|
335
329
|
cursor: input.cursor,
|
|
336
330
|
});
|
|
337
|
-
for (const room of
|
|
331
|
+
for (const room of response.result)
|
|
338
332
|
this.observeRoom(host, room);
|
|
339
|
-
const
|
|
340
|
-
return
|
|
333
|
+
const rooms = response.result.map((room) => this.summaryForResponse(host, room));
|
|
334
|
+
return response.next_cursor !== undefined
|
|
335
|
+
? { rooms, next_cursor: response.next_cursor }
|
|
336
|
+
: { rooms };
|
|
341
337
|
}
|
|
342
|
-
|
|
343
|
-
const offset = parseCursor(input.cursor);
|
|
344
|
-
const limit = input.limit ?? 50;
|
|
338
|
+
knownRooms(input) {
|
|
345
339
|
const agentId = this.agentId();
|
|
346
|
-
const
|
|
347
|
-
|
|
340
|
+
const host = input.host === undefined ? undefined : normalizeHost(input.host);
|
|
341
|
+
const rooms = [...this.state.rooms.entries()]
|
|
342
|
+
.filter(([, room]) => host === undefined || room.host === host)
|
|
343
|
+
.sort(([, a], [, b]) => compareStrings(a.host, b.host) ||
|
|
348
344
|
compareStrings(a.room.id, b.room.id))
|
|
349
|
-
.filter((room) => input.status !== undefined ? room.room.status === input.status : true)
|
|
350
|
-
.filter((room) => membershipFilter(room, agentId, input.membership))
|
|
351
|
-
.
|
|
352
|
-
|
|
353
|
-
return { rooms };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this.
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
afterSeq:
|
|
345
|
+
.filter(([, room]) => input.status !== undefined ? room.room.status === input.status : true)
|
|
346
|
+
.filter(([keyStr, room]) => membershipFilter(room, agentId, input.membership, this.state.ownJoinRequests.get(keyStr)?.status === "pending"))
|
|
347
|
+
.map(([, room]) => this.summaryForRoom(room));
|
|
348
|
+
const result = page(rooms, input.cursor, input.limit ?? 50);
|
|
349
|
+
return { rooms: result.items, ...(result.next_cursor ? { next_cursor: result.next_cursor } : {}) };
|
|
350
|
+
}
|
|
351
|
+
/** Reads the room resource and every record after the local tip, then verifies and applies them. */
|
|
352
|
+
async syncRoom(key) {
|
|
353
|
+
const client = this.discourse(key.host);
|
|
354
|
+
const jwt = this.requestJwt(key.host);
|
|
355
|
+
const room = await client.room(key.roomId, jwt);
|
|
356
|
+
this.observeRoom(key.host, room);
|
|
357
|
+
// The cursor continues the first query, so its after_seq stays fixed.
|
|
358
|
+
const syncedSeq = this.localRoom(key).syncedSeq;
|
|
359
|
+
let cursor;
|
|
360
|
+
for (;;) {
|
|
361
|
+
const response = await client.events(key.roomId, {
|
|
362
|
+
afterSeq: syncedSeq > 0 ? syncedSeq : undefined,
|
|
363
|
+
cursor,
|
|
367
364
|
jwt,
|
|
368
365
|
});
|
|
369
|
-
for (const record of
|
|
370
|
-
this.applyHostRecord(host, record);
|
|
366
|
+
for (const record of response.result)
|
|
367
|
+
this.applyHostRecord(key.host, record);
|
|
368
|
+
cursor = response.next_cursor;
|
|
369
|
+
if (cursor === undefined)
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async roomState(input) {
|
|
374
|
+
let key;
|
|
375
|
+
if (input.host !== undefined) {
|
|
376
|
+
key = { host: normalizeHost(input.host), roomId: input.room_id };
|
|
371
377
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
378
|
+
else {
|
|
379
|
+
key = this.resolveRoomKey(undefined, input.room_id);
|
|
380
|
+
}
|
|
381
|
+
this.requireAllowedHost(key.host);
|
|
382
|
+
const known = (this.state.rooms.get(roomKeyString(key))?.syncedSeq ?? 0) > 0;
|
|
383
|
+
if (!known || input.refresh)
|
|
384
|
+
await this.syncRoom(key);
|
|
375
385
|
const room = this.localRoom(key);
|
|
386
|
+
if (input.subscribe !== undefined)
|
|
387
|
+
room.subscribed = input.subscribe;
|
|
388
|
+
// The first state read in a session is the agent's starting view.
|
|
389
|
+
if (room.presentedSeq === undefined)
|
|
390
|
+
this.presentHead(room, room.headSeq);
|
|
376
391
|
return {
|
|
377
392
|
room: this.roomStateView(room),
|
|
378
393
|
sync: this.syncState(key),
|
|
379
394
|
active_turn: room.activeTurn,
|
|
380
395
|
};
|
|
381
396
|
}
|
|
382
|
-
async roomStateTool(input) {
|
|
383
|
-
void input.include_types;
|
|
384
|
-
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
385
|
-
if (input.refresh) {
|
|
386
|
-
const host = this.localRoom(key).host;
|
|
387
|
-
return this.roomOpen({ host, room_id: input.room_id, refresh: true });
|
|
388
|
-
}
|
|
389
|
-
const room = this.localRoom(key);
|
|
390
|
-
return { room: this.roomStateView(room), sync: this.syncState(key) };
|
|
391
|
-
}
|
|
392
397
|
roomMembersList(input) {
|
|
393
398
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
394
399
|
const room = this.localRoom(key);
|
|
395
|
-
const offset = parseCursor(input.cursor);
|
|
396
|
-
const limit = input.limit ?? 100;
|
|
397
400
|
const members = [...room.members.values()]
|
|
398
401
|
.sort((a, b) => compareStrings(a.agent_id, b.agent_id))
|
|
399
|
-
.filter((member) => input.
|
|
402
|
+
.filter((member) => input.agent_id !== undefined ? member.agent_id === input.agent_id : true)
|
|
403
|
+
.filter((member) => input.status !== undefined && input.status !== "all"
|
|
404
|
+
? member.status === input.status
|
|
405
|
+
: true)
|
|
400
406
|
.filter((member) => input.role !== undefined ? member.role === input.role : true)
|
|
401
|
-
.slice(offset, offset + limit)
|
|
402
407
|
.map((member) => ({ ...member }));
|
|
408
|
+
if (input.agent_id !== undefined && members.length === 0) {
|
|
409
|
+
throw invalidPayload("room member not found");
|
|
410
|
+
}
|
|
403
411
|
if (input.include_profiles) {
|
|
404
412
|
for (const member of members) {
|
|
405
413
|
if (member.profile === undefined) {
|
|
@@ -409,152 +417,114 @@ export class LocalConnector {
|
|
|
409
417
|
}
|
|
410
418
|
}
|
|
411
419
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
roomMemberGet(input) {
|
|
415
|
-
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
416
|
-
const room = this.localRoom(key);
|
|
417
|
-
const found = room.members.get(input.agent_id);
|
|
418
|
-
if (!found)
|
|
419
|
-
throw invalidPayload("room member not found");
|
|
420
|
-
const member = { ...found };
|
|
421
|
-
if (input.include_profile && member.profile === undefined) {
|
|
422
|
-
const profile = this.state.profiles.get(member.agent_id);
|
|
423
|
-
if (profile)
|
|
424
|
-
member.profile = profileToMemberProfile(profile);
|
|
425
|
-
}
|
|
426
|
-
const recent = input.include_recent_activity
|
|
420
|
+
const result = page(members, input.cursor, input.limit ?? 100);
|
|
421
|
+
const recent = input.agent_id !== undefined && input.include_recent_activity
|
|
427
422
|
? room.timeline
|
|
428
423
|
.filter((item) => item.actor === input.agent_id)
|
|
429
424
|
.slice(-10)
|
|
430
425
|
.reverse()
|
|
431
|
-
:
|
|
432
|
-
return {
|
|
426
|
+
: undefined;
|
|
427
|
+
return {
|
|
428
|
+
members: result.items,
|
|
429
|
+
...(recent !== undefined ? { recent } : {}),
|
|
430
|
+
...(result.next_cursor ? { next_cursor: result.next_cursor } : {}),
|
|
431
|
+
sync: this.syncState(key),
|
|
432
|
+
};
|
|
433
433
|
}
|
|
434
434
|
async agentStatusList(input) {
|
|
435
435
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
436
436
|
const keyStr = roomKeyString(key);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
437
|
+
const cached = this.state.agentStatuses.get(keyStr);
|
|
438
|
+
if (input.agent_id !== undefined) {
|
|
439
|
+
const hit = input.refresh ? undefined : cached?.get(input.agent_id);
|
|
440
|
+
if (hit)
|
|
441
|
+
return { statuses: [hit], sync: this.syncState(key) };
|
|
442
|
+
const host = this.allowedRoomHost(key);
|
|
443
|
+
let status;
|
|
444
|
+
try {
|
|
445
|
+
status = await this.discourse(host).agentStatus(input.room_id, input.agent_id, this.requestJwt(host));
|
|
444
446
|
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (isHttpError(error, "agent_status_not_found"))
|
|
449
|
+
return { statuses: [], sync: this.syncState(key) };
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
this.cacheAgentStatus(keyStr, status);
|
|
453
|
+
return { statuses: [status], sync: this.syncState(key) };
|
|
454
|
+
}
|
|
455
|
+
if (!input.refresh && cached) {
|
|
456
|
+
return { statuses: sortedAgentStatuses(cached), sync: this.syncState(key) };
|
|
445
457
|
}
|
|
446
458
|
const host = this.allowedRoomHost(key);
|
|
447
|
-
const
|
|
448
|
-
const response = await this.discourse(host).agentStatuses(input.room_id, jwt);
|
|
459
|
+
const response = await this.discourse(host).agentStatuses(input.room_id, this.requestJwt(host));
|
|
449
460
|
const statuses = new Map();
|
|
450
|
-
for (const status of response.
|
|
461
|
+
for (const status of response.result)
|
|
451
462
|
statuses.set(status.agent_id, status);
|
|
452
|
-
}
|
|
453
463
|
this.state.agentStatuses.set(keyStr, statuses);
|
|
454
464
|
return { statuses: sortedAgentStatuses(statuses), sync: this.syncState(key) };
|
|
455
465
|
}
|
|
456
|
-
async agentStatusGet(input) {
|
|
457
|
-
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
458
|
-
const keyStr = roomKeyString(key);
|
|
459
|
-
if (!input.refresh) {
|
|
460
|
-
const cached = this.state.agentStatuses.get(keyStr)?.get(input.agent_id);
|
|
461
|
-
if (cached)
|
|
462
|
-
return { status: cached, sync: this.syncState(key) };
|
|
463
|
-
}
|
|
464
|
-
const host = this.allowedRoomHost(key);
|
|
465
|
-
const jwt = this.requestJwt(host);
|
|
466
|
-
const response = await this.discourse(host).agentStatus(input.room_id, input.agent_id, jwt);
|
|
467
|
-
let statuses = this.state.agentStatuses.get(keyStr);
|
|
468
|
-
if (!statuses) {
|
|
469
|
-
statuses = new Map();
|
|
470
|
-
this.state.agentStatuses.set(keyStr, statuses);
|
|
471
|
-
}
|
|
472
|
-
statuses.set(response.status.agent_id, response.status);
|
|
473
|
-
return { status: response.status, sync: this.syncState(key) };
|
|
474
|
-
}
|
|
475
466
|
async agentStatusSet(input) {
|
|
476
467
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
477
468
|
const keyStr = roomKeyString(key);
|
|
478
469
|
const host = this.allowedRoomHost(key);
|
|
479
|
-
const
|
|
470
|
+
const room = this.localRoom(key);
|
|
480
471
|
const request = {
|
|
481
472
|
state: input.state,
|
|
482
473
|
summary: input.summary,
|
|
483
|
-
seen_seq: input.seen_seq,
|
|
484
|
-
seen_hash: input.seen_hash,
|
|
474
|
+
seen_seq: input.seen_seq ?? (room.syncedSeq > 0 ? room.syncedSeq : undefined),
|
|
475
|
+
seen_hash: input.seen_hash ?? (input.seen_seq === undefined ? room.syncedHash : undefined),
|
|
485
476
|
claim_id: input.claim_id,
|
|
486
477
|
activity: input.activity,
|
|
487
478
|
expires_at: input.expires_at,
|
|
488
479
|
extra: input.extra,
|
|
489
480
|
};
|
|
490
|
-
const status = await this.discourse(host).setAgentStatus(input.room_id,
|
|
481
|
+
const status = await this.discourse(host).setAgentStatus(input.room_id, this.requestJwt(host), request);
|
|
482
|
+
if (status.expires_at <= unixTimeMillis()) {
|
|
483
|
+
this.state.agentStatuses.get(keyStr)?.delete(status.agent_id);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
this.cacheAgentStatus(keyStr, status);
|
|
487
|
+
}
|
|
488
|
+
return { status, sync: this.syncState(key) };
|
|
489
|
+
}
|
|
490
|
+
cacheAgentStatus(keyStr, status) {
|
|
491
491
|
let statuses = this.state.agentStatuses.get(keyStr);
|
|
492
492
|
if (!statuses) {
|
|
493
493
|
statuses = new Map();
|
|
494
494
|
this.state.agentStatuses.set(keyStr, statuses);
|
|
495
495
|
}
|
|
496
496
|
statuses.set(status.agent_id, status);
|
|
497
|
-
return { status, sync: this.syncState(key) };
|
|
498
497
|
}
|
|
499
|
-
async
|
|
500
|
-
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
501
|
-
const host = this.allowedRoomHost(key);
|
|
502
|
-
const jwt = this.requestJwt(host);
|
|
503
|
-
const request = {
|
|
504
|
-
state: "away",
|
|
505
|
-
expires_at: unixTimeMillis() - 1,
|
|
506
|
-
};
|
|
507
|
-
await this.discourse(host).setAgentStatus(input.room_id, jwt, request);
|
|
508
|
-
this.state.agentStatuses.get(roomKeyString(key))?.delete(this.agentId());
|
|
509
|
-
return { cleared: true, room_id: input.room_id };
|
|
510
|
-
}
|
|
511
|
-
roomTimeline(input) {
|
|
512
|
-
void input.refresh;
|
|
513
|
-
void input.include_records;
|
|
498
|
+
async roomTimeline(input) {
|
|
514
499
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
500
|
+
if (input.refresh)
|
|
501
|
+
await this.syncRoom(key);
|
|
515
502
|
const room = this.localRoom(key);
|
|
516
503
|
const items = room.timeline
|
|
517
504
|
.filter((item) => input.after_seq !== undefined ? item.seq > input.after_seq : true)
|
|
518
505
|
.filter((item) => input.before_seq !== undefined ? item.seq < input.before_seq : true)
|
|
519
506
|
.filter((item) => input.types !== undefined ? input.types.includes(item.type) : true)
|
|
520
|
-
.filter((item) => input.actors !== undefined
|
|
507
|
+
.filter((item) => input.actors !== undefined
|
|
508
|
+
? item.actor !== undefined && input.actors.includes(item.actor)
|
|
509
|
+
: true)
|
|
521
510
|
.filter((item) => !input.unread_only || item.seq > room.readSeq)
|
|
522
511
|
.slice(0, input.limit ?? 50);
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
.filter((item) => item.seq > room.readSeq)
|
|
535
|
-
.slice(0, input.limit ?? 50);
|
|
536
|
-
const throughSeq = items.length > 0 ? items[items.length - 1].seq : undefined;
|
|
537
|
-
if (input.mark_read) {
|
|
538
|
-
if (throughSeq !== undefined)
|
|
539
|
-
room.readSeq = throughSeq;
|
|
540
|
-
items =
|
|
541
|
-
throughSeq !== undefined
|
|
542
|
-
? room.timeline.filter((item) => item.seq <= throughSeq)
|
|
543
|
-
: [];
|
|
512
|
+
const lastSeq = items.length > 0 ? items[items.length - 1].seq : undefined;
|
|
513
|
+
if (input.mark_read && lastSeq !== undefined) {
|
|
514
|
+
room.readSeq = Math.max(room.readSeq, lastSeq);
|
|
515
|
+
}
|
|
516
|
+
// An unfiltered, gap-free read from the presented head presents the latest
|
|
517
|
+
// head it reaches (local connector Section 4.2).
|
|
518
|
+
if (input.types === undefined && input.actors === undefined && items.length > 0) {
|
|
519
|
+
const base = room.presentedSeq ?? 0;
|
|
520
|
+
const contiguous = items.every((item, index) => index === 0 || item.seq === items[index - 1].seq + 1);
|
|
521
|
+
if (contiguous && items[0].seq <= base + 1)
|
|
522
|
+
this.presentThrough(room, lastSeq);
|
|
544
523
|
}
|
|
545
524
|
return {
|
|
546
525
|
items,
|
|
547
|
-
unread_count: unreadCount(room),
|
|
548
526
|
sync: this.syncState(key),
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
roomMarkRead(input) {
|
|
552
|
-
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
553
|
-
const room = this.localRoom(key);
|
|
554
|
-
room.readSeq = Math.max(room.readSeq, input.through_seq);
|
|
555
|
-
return {
|
|
556
|
-
room_id: input.room_id,
|
|
557
|
-
read_seq: room.readSeq,
|
|
527
|
+
...(lastSeq !== undefined ? { next_after_seq: lastSeq } : {}),
|
|
558
528
|
unread_count: unreadCount(room),
|
|
559
529
|
};
|
|
560
530
|
}
|
|
@@ -577,8 +547,9 @@ export class LocalConnector {
|
|
|
577
547
|
const entry = this.state.inbox.get(id);
|
|
578
548
|
if (entry) {
|
|
579
549
|
items.push(entry.item);
|
|
550
|
+
// A claim is a lease, so a crashed session cannot hold the item forever.
|
|
580
551
|
if (input.claim)
|
|
581
|
-
entry.state = { kind: "claimed" };
|
|
552
|
+
entry.state = { kind: "claimed", until: now + INBOX_CLAIM_LEASE_MS };
|
|
582
553
|
}
|
|
583
554
|
}
|
|
584
555
|
return { items, pending_count: this.pendingInboxCount() };
|
|
@@ -598,8 +569,20 @@ export class LocalConnector {
|
|
|
598
569
|
return { acknowledged, pending_count: this.pendingInboxCount() };
|
|
599
570
|
}
|
|
600
571
|
draftsList(input) {
|
|
601
|
-
|
|
602
|
-
|
|
572
|
+
if (input.draft_id !== undefined) {
|
|
573
|
+
const entry = this.state.drafts.get(input.draft_id);
|
|
574
|
+
if (!entry)
|
|
575
|
+
throw invalidPayload("draft not found");
|
|
576
|
+
const key = {
|
|
577
|
+
host: entry.draft.current_sync.host,
|
|
578
|
+
roomId: entry.draft.room_id,
|
|
579
|
+
};
|
|
580
|
+
const room = this.localRoom(key);
|
|
581
|
+
const changes = this.roomChangesSince(key, entry.draft.base_seq);
|
|
582
|
+
// The changes reach the current head, which is now presented.
|
|
583
|
+
this.presentHead(room, room.headSeq);
|
|
584
|
+
return { drafts: [entry.draft], changes, sync: this.syncState(key) };
|
|
585
|
+
}
|
|
603
586
|
const host = input.host !== undefined ? normalizeHost(input.host) : undefined;
|
|
604
587
|
const drafts = [...this.state.drafts.values()]
|
|
605
588
|
.sort((a, b) => compareStrings(a.draft.id, b.draft.id))
|
|
@@ -607,125 +590,98 @@ export class LocalConnector {
|
|
|
607
590
|
? entry.draft.room_id === input.room_id
|
|
608
591
|
: true)
|
|
609
592
|
.filter((entry) => host !== undefined ? entry.draft.current_sync.host === host : true)
|
|
610
|
-
.slice(offset, offset + limit + 1)
|
|
611
593
|
.map((entry) => entry.draft);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
drafts.pop();
|
|
615
|
-
nextCursor = String(offset + limit);
|
|
616
|
-
}
|
|
617
|
-
return { drafts, next_cursor: nextCursor };
|
|
618
|
-
}
|
|
619
|
-
draftGet(input) {
|
|
620
|
-
const entry = this.state.drafts.get(input.draft_id);
|
|
621
|
-
if (!entry)
|
|
622
|
-
throw invalidPayload("draft not found");
|
|
623
|
-
const key = {
|
|
624
|
-
host: entry.draft.current_sync.host,
|
|
625
|
-
roomId: entry.draft.room_id,
|
|
626
|
-
};
|
|
627
|
-
return {
|
|
628
|
-
draft: entry.draft,
|
|
629
|
-
changes: this.roomChangesSince(key, entry.draft.base_seq),
|
|
630
|
-
sync: this.syncState(key),
|
|
631
|
-
};
|
|
594
|
+
const result = page(drafts, input.cursor, input.limit ?? 50);
|
|
595
|
+
return { drafts: result.items, ...(result.next_cursor ? { next_cursor: result.next_cursor } : {}) };
|
|
632
596
|
}
|
|
633
597
|
async draftCommit(input) {
|
|
634
598
|
const entry = this.state.drafts.get(input.draft_id);
|
|
635
599
|
if (!entry)
|
|
636
600
|
throw invalidPayload("draft not found");
|
|
637
|
-
if (input.action === "
|
|
601
|
+
if (input.action === "drop") {
|
|
638
602
|
this.state.drafts.delete(input.draft_id);
|
|
639
603
|
return { status: "dropped", draft_id: input.draft_id };
|
|
640
604
|
}
|
|
641
|
-
|
|
605
|
+
if (input.action !== "revise" && input.action !== "send") {
|
|
606
|
+
throw invalidPayload(`invalid draft action: ${input.action}`);
|
|
607
|
+
}
|
|
608
|
+
// Both actions sign against the presented head; a further mismatch is
|
|
609
|
+
// handled by on_head_mismatch.
|
|
610
|
+
let request;
|
|
642
611
|
if (entry.request.kind === "message") {
|
|
643
|
-
const
|
|
644
|
-
if (input.action === "
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if (input.
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
request.content_type = input.content_type;
|
|
656
|
-
if (input.mentions !== undefined)
|
|
657
|
-
request.mentions = input.mentions;
|
|
658
|
-
if (input.references !== undefined)
|
|
659
|
-
request.references = input.references;
|
|
660
|
-
if (input.extra !== undefined)
|
|
661
|
-
request.extra = input.extra;
|
|
662
|
-
}
|
|
663
|
-
request.base_seq = input.base_seq;
|
|
664
|
-
request.base_hash = input.base_hash;
|
|
665
|
-
request.on_head_mismatch = input.on_head_mismatch;
|
|
666
|
-
result = await this.roomSendMessage(request);
|
|
612
|
+
const message = { ...entry.request.input };
|
|
613
|
+
if (input.action === "revise") {
|
|
614
|
+
if (input.content !== undefined)
|
|
615
|
+
message.content = input.content;
|
|
616
|
+
if (input.content_type !== undefined)
|
|
617
|
+
message.content_type = input.content_type;
|
|
618
|
+
if (input.mentions !== undefined)
|
|
619
|
+
message.mentions = input.mentions;
|
|
620
|
+
if (input.references !== undefined)
|
|
621
|
+
message.references = input.references;
|
|
622
|
+
if (input.extra !== undefined)
|
|
623
|
+
message.extra = input.extra;
|
|
667
624
|
}
|
|
625
|
+
message.base_seq = undefined;
|
|
626
|
+
message.base_hash = undefined;
|
|
627
|
+
message.on_head_mismatch = input.on_head_mismatch;
|
|
628
|
+
request = { kind: "message", input: message };
|
|
668
629
|
}
|
|
669
630
|
else {
|
|
670
|
-
const
|
|
671
|
-
if (input.action === "
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (input.
|
|
679
|
-
|
|
680
|
-
request.type = input.type;
|
|
681
|
-
if (input.payload !== undefined)
|
|
682
|
-
request.payload = input.payload;
|
|
683
|
-
if (input.mentions !== undefined)
|
|
684
|
-
request.mentions = input.mentions;
|
|
685
|
-
if (input.references !== undefined)
|
|
686
|
-
request.references = input.references;
|
|
687
|
-
}
|
|
688
|
-
request.base_seq = input.base_seq;
|
|
689
|
-
request.base_hash = input.base_hash;
|
|
690
|
-
request.on_head_mismatch = input.on_head_mismatch;
|
|
691
|
-
result = await this.roomSubmitEvent(request);
|
|
631
|
+
const event = { ...entry.request.input };
|
|
632
|
+
if (input.action === "revise") {
|
|
633
|
+
if (input.type !== undefined)
|
|
634
|
+
event.type = input.type;
|
|
635
|
+
if (input.payload !== undefined)
|
|
636
|
+
event.payload = input.payload;
|
|
637
|
+
if (input.mentions !== undefined)
|
|
638
|
+
event.mentions = input.mentions;
|
|
639
|
+
if (input.references !== undefined)
|
|
640
|
+
event.references = input.references;
|
|
692
641
|
}
|
|
642
|
+
event.base_seq = undefined;
|
|
643
|
+
event.base_hash = undefined;
|
|
644
|
+
event.on_head_mismatch = input.on_head_mismatch;
|
|
645
|
+
request = { kind: "event", input: event };
|
|
693
646
|
}
|
|
647
|
+
const result = await this.submitRoomWrite(request);
|
|
694
648
|
if (result.status === "sent" || result.status === "held") {
|
|
695
649
|
this.state.drafts.delete(input.draft_id);
|
|
696
650
|
}
|
|
697
651
|
return result;
|
|
698
652
|
}
|
|
699
|
-
draftDrop(input) {
|
|
700
|
-
this.state.drafts.delete(input.draft_id);
|
|
701
|
-
return {
|
|
702
|
-
status: "dropped",
|
|
703
|
-
draft_id: input.draft_id,
|
|
704
|
-
pending_count: this.state.drafts.size,
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
653
|
/**
|
|
708
|
-
* Resolves a principal per Agent Delegation Section
|
|
654
|
+
* Resolves a principal per Agent Delegation Section 3 and reports whether
|
|
709
655
|
* the requested URL is an alias the principal acknowledges. Any origin can
|
|
710
656
|
* redirect to any principal, so an unlisted URL is never presented as a name
|
|
711
657
|
* for it.
|
|
712
658
|
*/
|
|
713
659
|
async resolvePrincipal(url) {
|
|
714
|
-
const document = await this.
|
|
660
|
+
const document = await new DelegationClient(url, this.fetchImpl).principal(url);
|
|
715
661
|
return {
|
|
716
662
|
document,
|
|
717
663
|
alias: document.id !== url && isPrincipalAlias(document, url),
|
|
718
664
|
};
|
|
719
665
|
}
|
|
666
|
+
/** The principal's authoritative delegation service, located from its `delegation_query_url`. */
|
|
667
|
+
async principalDelegationService(document) {
|
|
668
|
+
const queryUrl = document.delegation_query_url;
|
|
669
|
+
if (queryUrl === undefined) {
|
|
670
|
+
throw invalidPayload(`principal ${document.id} publishes no delegation_query_url`);
|
|
671
|
+
}
|
|
672
|
+
const origin = serviceOrigin(queryUrl);
|
|
673
|
+
this.requireAllowedHost(origin);
|
|
674
|
+
return DelegationClient.discover(origin, this.fetchImpl);
|
|
675
|
+
}
|
|
720
676
|
/**
|
|
721
677
|
* Resolves the principal and refuses when the active Agent ID is not one of
|
|
722
|
-
* its controller keys, so a grant that
|
|
723
|
-
* or transmitted.
|
|
678
|
+
* its current controller keys with delegation authority, so a grant that
|
|
679
|
+
* could never be accepted is not signed or transmitted.
|
|
724
680
|
*/
|
|
725
681
|
async controllerPrincipal(principalId) {
|
|
726
682
|
const { document } = await this.resolvePrincipal(principalId);
|
|
727
683
|
const active = this.agentId();
|
|
728
|
-
if (!document.controllers.
|
|
684
|
+
if (!document.controllers.some(c => c.id === active && c.delegation !== undefined && c.valid_from <= unixTimeMillis())) {
|
|
729
685
|
throw invalidPayload(`active agent ${active} is not a controller key of ${document.id}`);
|
|
730
686
|
}
|
|
731
687
|
return document;
|
|
@@ -740,68 +696,109 @@ export class LocalConnector {
|
|
|
740
696
|
};
|
|
741
697
|
}
|
|
742
698
|
async delegationCheck(input) {
|
|
699
|
+
if (typeof input.audience !== "string")
|
|
700
|
+
throw invalidPayload("audience is required");
|
|
743
701
|
const { document } = await this.resolvePrincipal(input.principal_id);
|
|
744
702
|
// The authoritative service is the one the principal names, never one
|
|
745
703
|
// supplied by whoever presented a credential.
|
|
704
|
+
const service = await this.principalDelegationService(document);
|
|
746
705
|
const queryUrl = document.delegation_query_url;
|
|
747
|
-
|
|
748
|
-
throw invalidPayload(`principal ${document.id} publishes no delegation_query_url`);
|
|
749
|
-
}
|
|
750
|
-
const response = await this.delegationClient(queryUrl).queryDelegationsAt(queryUrl, {
|
|
706
|
+
const response = await service.queryDelegationsAt(queryUrl, {
|
|
751
707
|
subject: input.subject ?? this.agentId(),
|
|
752
708
|
principal_id: document.id,
|
|
753
709
|
id: input.id,
|
|
754
|
-
status: input.status,
|
|
755
710
|
});
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
711
|
+
const now = unixTimeMillis();
|
|
712
|
+
const delegations = [];
|
|
713
|
+
for (const credential of response.result) {
|
|
714
|
+
let records = [];
|
|
715
|
+
try {
|
|
716
|
+
records = await service.allDelegationEvents(credential.id);
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
delegations.push({ credential, verified: false, usable: false, reasons: [`history unavailable: ${error instanceof Error ? error.message : error}`] });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
delegations.push(verifyDelegationCredential(credential, records, document, document.id, input.audience, now));
|
|
723
|
+
}
|
|
724
|
+
return { canonical_id: document.id, query_url: queryUrl, delegations };
|
|
761
725
|
}
|
|
762
726
|
async delegationsList(input) {
|
|
763
727
|
// Enumerating one subject requires authorization; the connector proves the
|
|
764
728
|
// active identity and never enumerates anyone else.
|
|
765
|
-
const
|
|
766
|
-
const
|
|
729
|
+
const origin = serviceOrigin(input.delegation_service);
|
|
730
|
+
const jwt = this.requestJwt(origin);
|
|
731
|
+
const service = await DelegationClient.discover(origin, this.fetchImpl);
|
|
732
|
+
const response = await service.queryDelegations({
|
|
767
733
|
subject: this.agentId(),
|
|
768
734
|
status: input.status,
|
|
769
735
|
limit: input.limit,
|
|
736
|
+
cursor: input.cursor,
|
|
770
737
|
}, jwt);
|
|
771
|
-
return
|
|
738
|
+
return response.next_cursor !== undefined
|
|
739
|
+
? { delegations: response.result, next_cursor: response.next_cursor }
|
|
740
|
+
: { delegations: response.result };
|
|
741
|
+
}
|
|
742
|
+
async delegationPrevious(service, id) {
|
|
743
|
+
try {
|
|
744
|
+
return await service.delegation(id);
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
if (error instanceof HttpResponseError && error.status === 404)
|
|
748
|
+
return undefined;
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async submitDelegation(service, sign) {
|
|
753
|
+
let envelope = sign();
|
|
754
|
+
try {
|
|
755
|
+
return { credential: await service.submitDelegationEvent(envelope), envelope };
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
if (!this.resyncNonce(error))
|
|
759
|
+
throw error;
|
|
760
|
+
envelope = sign();
|
|
761
|
+
return { credential: await service.submitDelegationEvent(envelope), envelope };
|
|
762
|
+
}
|
|
772
763
|
}
|
|
773
764
|
async delegationGrant(input) {
|
|
774
765
|
const principal = await this.controllerPrincipal(input.principal_id);
|
|
766
|
+
const service = await this.principalDelegationService(principal);
|
|
767
|
+
const previous = await this.delegationPrevious(service, input.id);
|
|
775
768
|
const payload = {
|
|
776
|
-
id: input.id,
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
relationship: input.relationship,
|
|
780
|
-
scopes: input.scopes,
|
|
781
|
-
constraints: input.constraints,
|
|
782
|
-
not_before: input.not_before,
|
|
783
|
-
expires_at: input.expires_at,
|
|
769
|
+
id: input.id, principal_id: principal.id, subject: input.subject,
|
|
770
|
+
relationship: input.relationship, scopes: input.scopes, audiences: input.audiences,
|
|
771
|
+
constraints: input.constraints, not_before: input.not_before, expires_at: input.expires_at,
|
|
784
772
|
};
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return
|
|
773
|
+
for (const field of ["relationship", "constraints", "not_before", "expires_at"]) {
|
|
774
|
+
if (payload[field] === undefined)
|
|
775
|
+
delete payload[field];
|
|
776
|
+
}
|
|
777
|
+
return this.submitDelegation(service, () => {
|
|
778
|
+
const createdAt = unixTimeMillis();
|
|
779
|
+
const event = delegationGrantEvent(this.agentId(), createdAt, this.nonces.nextNonce(createdAt), payload);
|
|
780
|
+
validateDelegationEventAuthority(event, principal, createdAt, previous);
|
|
781
|
+
return this.signer.signEvent(event);
|
|
782
|
+
});
|
|
790
783
|
}
|
|
791
784
|
async delegationRevoke(input) {
|
|
792
785
|
const principal = await this.controllerPrincipal(input.principal_id);
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
786
|
+
const service = await this.principalDelegationService(principal);
|
|
787
|
+
const previous = await this.delegationPrevious(service, input.id);
|
|
788
|
+
return this.submitDelegation(service, () => {
|
|
789
|
+
const createdAt = unixTimeMillis();
|
|
790
|
+
const event = delegationRevokeEvent(this.agentId(), createdAt, this.nonces.nextNonce(createdAt), {
|
|
791
|
+
id: input.id, principal_id: principal.id, ...(input.reason !== undefined ? { reason: input.reason } : {}),
|
|
792
|
+
});
|
|
793
|
+
validateDelegationEventAuthority(event, principal, createdAt, previous);
|
|
794
|
+
return this.signer.signEvent(event);
|
|
795
|
+
});
|
|
800
796
|
}
|
|
801
797
|
async profileUpdate(input) {
|
|
802
798
|
if (!isRecord(input.profile)) {
|
|
803
799
|
throw invalidPayload("profile must be an object");
|
|
804
800
|
}
|
|
801
|
+
this.requireAllowedOrigin(input.profile_service);
|
|
805
802
|
const profile = { ...input.profile };
|
|
806
803
|
// payload.id is always the active Agent ID; reject an input that names a
|
|
807
804
|
// different agent instead of silently rewriting it.
|
|
@@ -812,8 +809,18 @@ export class LocalConnector {
|
|
|
812
809
|
else if (profile.id !== activeId) {
|
|
813
810
|
throw invalidPayload("profile.id must be the active Agent ID");
|
|
814
811
|
}
|
|
815
|
-
const
|
|
816
|
-
|
|
812
|
+
const client = this.profileClient(input.profile_service);
|
|
813
|
+
let envelope = this.signProfileUpdate(profile);
|
|
814
|
+
let materialized;
|
|
815
|
+
try {
|
|
816
|
+
materialized = await client.submitProfileUpdate(envelope);
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
if (!this.resyncNonce(error))
|
|
820
|
+
throw error;
|
|
821
|
+
envelope = this.signProfileUpdate(profile);
|
|
822
|
+
materialized = await client.submitProfileUpdate(envelope);
|
|
823
|
+
}
|
|
817
824
|
this.state.profiles.set(materialized.id, materialized);
|
|
818
825
|
return { profile: materialized, envelope };
|
|
819
826
|
}
|
|
@@ -821,6 +828,8 @@ export class LocalConnector {
|
|
|
821
828
|
const host = normalizeHost(input.host);
|
|
822
829
|
this.requireAllowedHost(host);
|
|
823
830
|
const payload = {
|
|
831
|
+
// Binds the signed event to this host (ADP Section 8.1).
|
|
832
|
+
host: serviceOrigin(host),
|
|
824
833
|
topic: input.topic,
|
|
825
834
|
visibility: input.visibility,
|
|
826
835
|
start_time: input.start_time,
|
|
@@ -831,250 +840,283 @@ export class LocalConnector {
|
|
|
831
840
|
language: input.language,
|
|
832
841
|
policy: input.policy,
|
|
833
842
|
types: input.types,
|
|
843
|
+
extra: input.extra,
|
|
834
844
|
};
|
|
835
|
-
const
|
|
836
|
-
|
|
845
|
+
for (const field of ["agenda", "guidance", "tags", "language", "policy", "types", "extra"]) {
|
|
846
|
+
if (payload[field] === undefined)
|
|
847
|
+
delete payload[field];
|
|
848
|
+
}
|
|
849
|
+
const client = this.discourse(host);
|
|
850
|
+
let envelope = this.signRoomCreate(payload);
|
|
851
|
+
let room;
|
|
852
|
+
try {
|
|
853
|
+
room = await client.createRoom(envelope);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
if (!this.resyncNonce(error))
|
|
857
|
+
throw error;
|
|
858
|
+
envelope = this.signRoomCreate(payload);
|
|
859
|
+
room = await client.createRoom(envelope);
|
|
860
|
+
}
|
|
837
861
|
if (!room.envelope)
|
|
838
862
|
room.envelope = envelope;
|
|
839
863
|
this.acceptRoomResponse(host, room);
|
|
840
864
|
const key = { host, roomId: room.id };
|
|
865
|
+
const local = this.localRoom(key);
|
|
866
|
+
// The creator has seen its own room.
|
|
867
|
+
this.presentHead(local, local.headSeq);
|
|
841
868
|
return {
|
|
842
|
-
room: this.roomStateView(
|
|
869
|
+
room: this.roomStateView(local),
|
|
843
870
|
envelope,
|
|
844
871
|
sync: this.syncState(key),
|
|
845
872
|
};
|
|
846
873
|
}
|
|
847
874
|
async roomJoin(input) {
|
|
848
875
|
const roomId = input.room_id;
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
this.requireAllowedHost(host);
|
|
853
|
-
key = { host, roomId };
|
|
854
|
-
}
|
|
855
|
-
else {
|
|
856
|
-
key = this.resolveRoomKey(undefined, roomId);
|
|
857
|
-
this.requireAllowedHost(key.host);
|
|
858
|
-
}
|
|
876
|
+
const key = input.host !== undefined
|
|
877
|
+
? { host: normalizeHost(input.host), roomId }
|
|
878
|
+
: this.resolveRoomKey(undefined, roomId);
|
|
859
879
|
const host = key.host;
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
880
|
+
this.requireAllowedHost(host);
|
|
881
|
+
const keyStr = roomKeyString(key);
|
|
882
|
+
const agentId = this.agentId();
|
|
883
|
+
// A stored request decides the outcome until it resolves.
|
|
884
|
+
const own = this.state.ownJoinRequests.get(keyStr);
|
|
885
|
+
if (own && own.status === "pending") {
|
|
886
|
+
const current = await this.discourse(host).joinRequest(roomId, own.id, this.requestJwt(host));
|
|
887
|
+
this.state.ownJoinRequests.set(keyStr, current);
|
|
888
|
+
if (current.status === "pending")
|
|
889
|
+
return { status: "approval_required", join_request: current, sync: this.maybeSync(key) };
|
|
890
|
+
if (current.status === "rejected")
|
|
891
|
+
return { status: "rejected", join_request: current, sync: this.maybeSync(key) };
|
|
892
|
+
if (current.status === "approved") {
|
|
893
|
+
// The approving review record is the membership event.
|
|
894
|
+
await this.syncRoom(key);
|
|
895
|
+
const member = this.localRoom(key).members.get(agentId);
|
|
896
|
+
if (member?.status !== "active")
|
|
897
|
+
throw invalidPayload("approved membership is not yet visible");
|
|
898
|
+
return { status: "joined", member, join_request: current, sync: this.syncState(key) };
|
|
871
899
|
}
|
|
872
|
-
const payload = {
|
|
873
|
-
request_id: input.request_id,
|
|
874
|
-
role: approvedRole,
|
|
875
|
-
};
|
|
876
|
-
return this.completeJoin(host, key, payload);
|
|
877
900
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
role: input.role,
|
|
889
|
-
perspective: input.perspective,
|
|
890
|
-
reason: input.reason,
|
|
891
|
-
extra: input.extra,
|
|
892
|
-
};
|
|
893
|
-
const status = await this.discourse(host).requestJoin(roomId, jwt, request);
|
|
894
|
-
this.pushJoinRequest(key, status);
|
|
895
|
-
let sync;
|
|
896
|
-
try {
|
|
897
|
-
sync = this.syncState(key);
|
|
901
|
+
// Read the room when possible: invitees and public rooms are readable.
|
|
902
|
+
if (!this.state.rooms.has(keyStr)) {
|
|
903
|
+
try {
|
|
904
|
+
const room = await this.discourse(host).room(roomId, this.requestJwt(host));
|
|
905
|
+
this.acceptRoomResponse(host, room);
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
if (!(error instanceof HttpResponseError))
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
898
911
|
}
|
|
899
|
-
|
|
900
|
-
|
|
912
|
+
const local = this.state.rooms.get(keyStr);
|
|
913
|
+
const visibility = local ? roomVisibility(local.room) : undefined;
|
|
914
|
+
const direct = local !== undefined &&
|
|
915
|
+
visibility !== undefined &&
|
|
916
|
+
local.members.get(agentId)?.status !== "banned" &&
|
|
917
|
+
canJoinDirectly(visibility, roomPolicy(local.room), agentId, input.role);
|
|
918
|
+
if (direct) {
|
|
919
|
+
const payload = { role: input.role };
|
|
920
|
+
if (input.perspective !== undefined)
|
|
921
|
+
payload.perspective = input.perspective;
|
|
922
|
+
let record;
|
|
923
|
+
try {
|
|
924
|
+
record = await this.submitSigned(() => this.signRoomEvent(eventType.ROOM_JOIN, key, undefined, undefined, [], payload), (envelope) => this.discourse(host).joinRoom(roomId, envelope));
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
// A ban may have arrived since the local snapshot; request review below.
|
|
928
|
+
if (!isHttpError(error, "member_banned"))
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
if (record) {
|
|
932
|
+
await this.applyOwnRecord(key, record);
|
|
933
|
+
const member = this.localRoom(key).members.get(agentId);
|
|
934
|
+
if (!member)
|
|
935
|
+
throw invalidPayload("joined member not materialized");
|
|
936
|
+
return { status: "joined", record, member, sync: this.syncState(key) };
|
|
937
|
+
}
|
|
901
938
|
}
|
|
902
|
-
|
|
939
|
+
const requestPayload = { role: input.role };
|
|
940
|
+
if (input.perspective !== undefined)
|
|
941
|
+
requestPayload.perspective = input.perspective;
|
|
942
|
+
if (input.reason !== undefined)
|
|
943
|
+
requestPayload.reason = input.reason;
|
|
944
|
+
if (input.extra !== undefined)
|
|
945
|
+
requestPayload.extra = input.extra;
|
|
946
|
+
const request = await this.submitSigned(() => this.signJoinRequest(roomId, requestPayload), (envelope) => this.discourse(host).requestJoin(roomId, envelope));
|
|
947
|
+
this.state.ownJoinRequests.set(keyStr, request);
|
|
948
|
+
return { status: "approval_required", join_request: request, sync: this.maybeSync(key) };
|
|
903
949
|
}
|
|
904
|
-
async
|
|
905
|
-
|
|
906
|
-
this.requireAllowedHost(host);
|
|
907
|
-
const jwt = this.requestJwt(host);
|
|
908
|
-
const request = {
|
|
909
|
-
role: input.role,
|
|
910
|
-
perspective: input.perspective,
|
|
911
|
-
reason: input.reason,
|
|
912
|
-
extra: input.extra,
|
|
913
|
-
};
|
|
914
|
-
const status = await this.discourse(host).requestJoin(input.room_id, jwt, request);
|
|
915
|
-
this.pushJoinRequest({ host, roomId: input.room_id }, status);
|
|
916
|
-
return { join_request: status };
|
|
950
|
+
async roomSendMessage(input) {
|
|
951
|
+
return this.submitRoomWrite({ kind: "message", input: { ...input } });
|
|
917
952
|
}
|
|
918
|
-
async
|
|
919
|
-
|
|
920
|
-
const host = this.allowedRoomHost(key);
|
|
921
|
-
const status = await this.approvedJoinRequest(host, input.room_id, input.request_id);
|
|
922
|
-
const role = status.approved_role ?? status.request.role;
|
|
923
|
-
const payload = {
|
|
924
|
-
request_id: input.request_id,
|
|
925
|
-
role,
|
|
926
|
-
};
|
|
927
|
-
const result = await this.completeJoin(host, key, payload);
|
|
928
|
-
return { record: result.record, member: result.member, sync: result.sync };
|
|
953
|
+
async roomSubmitEvent(input) {
|
|
954
|
+
return this.submitRoomWrite({ kind: "event", input: { ...input } });
|
|
929
955
|
}
|
|
930
|
-
|
|
956
|
+
/**
|
|
957
|
+
* One room write under the Section 5.1 freshness rules. Contract and signal
|
|
958
|
+
* writes only anchor, so they are signed against the base and never held.
|
|
959
|
+
* Message and control writes apply `on_head_mismatch` when the local head
|
|
960
|
+
* moved past their base or the host returns `room_head_mismatch`.
|
|
961
|
+
*/
|
|
962
|
+
async submitRoomWrite(request) {
|
|
963
|
+
const input = request.input;
|
|
931
964
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
932
965
|
const host = this.allowedRoomHost(key);
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
966
|
+
const type = request.kind === "message" ? eventType.MESSAGE_CREATE : request.input.type;
|
|
967
|
+
const headBound = eventTypeRequiresRoomHead(this.localRoom(key), type);
|
|
968
|
+
if ((input.base_seq === undefined) !== (input.base_hash === undefined)) {
|
|
969
|
+
throw invalidPayload("base_seq and base_hash must be provided together");
|
|
970
|
+
}
|
|
971
|
+
let base = this.writeBase(key, input.base_seq, input.base_hash);
|
|
972
|
+
const policy = input.on_head_mismatch ?? "hold";
|
|
973
|
+
for (let attempts = 0;; attempts++) {
|
|
974
|
+
if (headBound) {
|
|
975
|
+
const room = this.localRoom(key);
|
|
976
|
+
if (!baseIsCurrent(room, base)) {
|
|
977
|
+
if (policy === "send_anyway" && attempts < SEND_ANYWAY_MAX_ATTEMPTS) {
|
|
978
|
+
base = [room.headSeq, room.headHash];
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
return policy === "reject"
|
|
982
|
+
? this.rejectedHeadMismatch(key, base[0])
|
|
983
|
+
: this.holdDraft(key, request, base);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const presentedSeq = this.localRoom(key).presentedSeq;
|
|
988
|
+
const previousHeadSeq = this.localRoom(key).headSeq;
|
|
989
|
+
try {
|
|
990
|
+
const record = await this.submitSigned(() => this.signWrite(request, key, base), (envelope) => this.discourse(host).submitEvent(input.room_id, envelope));
|
|
991
|
+
await this.applyOwnRecord(key, record);
|
|
992
|
+
const after = this.localRoom(key);
|
|
993
|
+
// The agent's own write extends what it saw only when nothing it has
|
|
994
|
+
// not seen advanced the head in between.
|
|
995
|
+
if (after.headSeq === record.seq && this.headBefore(after, record.seq, previousHeadSeq) === presentedSeq) {
|
|
996
|
+
this.presentHead(after, record.seq);
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
status: "sent",
|
|
1000
|
+
record: record,
|
|
1001
|
+
item: this.timelineItemByEvent(key, record.envelope.hash),
|
|
1002
|
+
sync: this.syncState(key),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
if (!headBound || !isHttpError(error, "room_head_mismatch"))
|
|
1007
|
+
throw error;
|
|
1008
|
+
await this.syncRoom(key);
|
|
1009
|
+
// A host that reports a mismatch the verified history does not show
|
|
1010
|
+
// cannot be resolved by retrying.
|
|
1011
|
+
if (baseIsCurrent(this.localRoom(key), base))
|
|
1012
|
+
throw error;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
942
1015
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1016
|
+
/**
|
|
1017
|
+
* Applies the record the host returned for the agent's own write, first
|
|
1018
|
+
* syncing any records accepted before it that the connector has not seen.
|
|
1019
|
+
*/
|
|
1020
|
+
async applyOwnRecord(key, record) {
|
|
1021
|
+
const room = this.localRoom(key);
|
|
1022
|
+
if (record.seq > room.syncedSeq + 1)
|
|
1023
|
+
await this.syncRoom(key);
|
|
1024
|
+
else
|
|
1025
|
+
this.applyHostRecord(key.host, record);
|
|
1026
|
+
}
|
|
1027
|
+
/** Seq of the latest head-advancing record before `seq`, or 0. */
|
|
1028
|
+
headBefore(room, seq, previousHeadSeq) {
|
|
1029
|
+
for (let s = seq - 1; s > 0; s--) {
|
|
1030
|
+
const record = room.records.find((candidate) => candidate.seq === s);
|
|
1031
|
+
if (record && recordAdvancesRoomHead(room, record))
|
|
1032
|
+
return s;
|
|
1033
|
+
}
|
|
1034
|
+
// A room snapshot may provide a verified head without its older records.
|
|
1035
|
+
return previousHeadSeq < seq ? previousHeadSeq : 0;
|
|
949
1036
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
content_type: input.content_type ?? "text/plain",
|
|
955
|
-
content: input.content,
|
|
956
|
-
};
|
|
957
|
-
if (input.references && input.references.length > 0) {
|
|
958
|
-
payload.references = input.references;
|
|
1037
|
+
/** Signs and submits once, re-signing a single time after a bounded Max-Seen-Nonce resync. */
|
|
1038
|
+
async submitSigned(sign, submit) {
|
|
1039
|
+
try {
|
|
1040
|
+
return await submit(sign());
|
|
959
1041
|
}
|
|
960
|
-
|
|
961
|
-
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
if (!this.resyncNonce(error))
|
|
1044
|
+
throw error;
|
|
1045
|
+
return submit(sign());
|
|
962
1046
|
}
|
|
963
|
-
const envelope = this.signRoomEvent(eventType.MESSAGE_CREATE, key, input.base_seq, input.base_hash, input.mentions ?? [], payload);
|
|
964
|
-
const record = await this.discourse(host).submitEvent(input.room_id, envelope);
|
|
965
|
-
this.applyHostRecord(host, record);
|
|
966
|
-
const item = this.timelineItemByEvent(key, record.envelope.hash);
|
|
967
|
-
return {
|
|
968
|
-
status: "sent",
|
|
969
|
-
record: record,
|
|
970
|
-
item,
|
|
971
|
-
sync: this.syncState(key),
|
|
972
|
-
};
|
|
973
1047
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
? eventTypeAdvancesRoomHead(room, input.type)
|
|
981
|
-
: true;
|
|
982
|
-
if (advancesHead) {
|
|
983
|
-
const held = this.headMismatchEventResult(key, input);
|
|
984
|
-
if (held)
|
|
985
|
-
return held;
|
|
986
|
-
}
|
|
987
|
-
return this.submitEventUnchecked(input);
|
|
1048
|
+
/** Applies `Max-Seen-Nonce` from a `nonce_not_greater` rejection; reports whether to retry. */
|
|
1049
|
+
resyncNonce(error) {
|
|
1050
|
+
if (!isHttpError(error, "nonce_not_greater") || error.maxSeenNonce === undefined)
|
|
1051
|
+
return false;
|
|
1052
|
+
this.nonces.observeMaxNonce(error.maxSeenNonce);
|
|
1053
|
+
return true;
|
|
988
1054
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1055
|
+
signWrite(request, key, base) {
|
|
1056
|
+
if (request.kind === "message") {
|
|
1057
|
+
const input = request.input;
|
|
1058
|
+
const payload = {
|
|
1059
|
+
content_type: input.content_type ?? "text/plain",
|
|
1060
|
+
content: input.content,
|
|
1061
|
+
};
|
|
1062
|
+
if (input.references && input.references.length > 0)
|
|
1063
|
+
payload.references = input.references;
|
|
1064
|
+
if (input.extra && Object.keys(input.extra).length > 0)
|
|
1065
|
+
payload.extra = input.extra;
|
|
1066
|
+
return this.signRoomEvent(eventType.MESSAGE_CREATE, key, base[0], base[1], input.mentions ?? [], payload);
|
|
1067
|
+
}
|
|
1068
|
+
const input = request.input;
|
|
1069
|
+
const payload = payloadWithReferences({ ...input.payload }, input.references ?? []);
|
|
1070
|
+
return this.signRoomEvent(input.type, key, base[0], base[1], input.mentions ?? [], payload);
|
|
1003
1071
|
}
|
|
1004
1072
|
async joinRequestsList(input) {
|
|
1005
1073
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
1006
1074
|
const host = this.allowedRoomHost(key);
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
requests = requests.slice(0, input.limit);
|
|
1017
|
-
this.state.joinRequests.set(roomKeyString(key), requests);
|
|
1018
|
-
return { join_requests: requests };
|
|
1075
|
+
const response = await this.discourse(host).joinRequests(input.room_id, this.requestJwt(host), {
|
|
1076
|
+
status: input.status,
|
|
1077
|
+
limit: input.limit,
|
|
1078
|
+
cursor: input.cursor,
|
|
1079
|
+
});
|
|
1080
|
+
this.state.joinRequests.set(roomKeyString(key), response.result);
|
|
1081
|
+
return response.next_cursor !== undefined
|
|
1082
|
+
? { join_requests: response.result, next_cursor: response.next_cursor }
|
|
1083
|
+
: { join_requests: response.result };
|
|
1019
1084
|
}
|
|
1020
1085
|
async joinRequestReview(input) {
|
|
1021
1086
|
const key = this.resolveRoomKey(input.host, input.room_id);
|
|
1022
1087
|
const host = this.allowedRoomHost(key);
|
|
1023
|
-
|
|
1024
|
-
|
|
1088
|
+
if (input.decision === "approve" && input.role === undefined) {
|
|
1089
|
+
throw invalidPayload("approving a join request requires a role");
|
|
1090
|
+
}
|
|
1091
|
+
const joinRequest = await this.discourse(host).joinRequest(input.room_id, input.request_id, this.requestJwt(host));
|
|
1025
1092
|
const payload = {
|
|
1026
|
-
request:
|
|
1093
|
+
request: joinRequest.request,
|
|
1027
1094
|
decision: input.decision,
|
|
1028
|
-
role: input.role,
|
|
1029
|
-
reason: input.reason,
|
|
1030
|
-
extra: {},
|
|
1031
1095
|
};
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1096
|
+
if (input.role !== undefined)
|
|
1097
|
+
payload.role = input.role;
|
|
1098
|
+
if (input.reason !== undefined)
|
|
1099
|
+
payload.reason = input.reason;
|
|
1100
|
+
const record = await this.submitSigned(() => this.signRoomEvent(eventType.ROOM_JOIN_REVIEW, key, undefined, undefined, [], payload), (envelope) => this.discourse(host).submitEvent(input.room_id, envelope));
|
|
1101
|
+
await this.applyOwnRecord(key, record);
|
|
1035
1102
|
return { record, sync: this.syncState(key) };
|
|
1036
1103
|
}
|
|
1037
|
-
// ── Join helpers shared by the join tools.
|
|
1038
|
-
async approvedJoinRequest(host, roomId, requestId) {
|
|
1039
|
-
const jwt = this.requestJwt(host);
|
|
1040
|
-
const status = await this.discourse(host).joinRequest(roomId, requestId, jwt);
|
|
1041
|
-
if (status.request.applicant !== this.agentId()) {
|
|
1042
|
-
throw invalidPayload("join request belongs to another agent");
|
|
1043
|
-
}
|
|
1044
|
-
if (status.status !== "approved") {
|
|
1045
|
-
throw invalidPayload("join request is not approved");
|
|
1046
|
-
}
|
|
1047
|
-
return status;
|
|
1048
|
-
}
|
|
1049
|
-
async completeJoin(host, key, payload) {
|
|
1050
|
-
const envelope = this.signRoomEvent(eventType.ROOM_JOIN, key, undefined, undefined, [], payload);
|
|
1051
|
-
const record = await this.discourse(host).joinRoom(key.roomId, envelope);
|
|
1052
|
-
this.applyHostRecord(host, record);
|
|
1053
|
-
const member = this.localRoom(key).members.get(this.agentId());
|
|
1054
|
-
if (!member)
|
|
1055
|
-
throw invalidPayload("joined member not materialized");
|
|
1056
|
-
return {
|
|
1057
|
-
status: "joined",
|
|
1058
|
-
record: record,
|
|
1059
|
-
member,
|
|
1060
|
-
sync: this.syncState(key),
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
pushJoinRequest(key, status) {
|
|
1064
|
-
const keyStr = roomKeyString(key);
|
|
1065
|
-
const existing = this.state.joinRequests.get(keyStr);
|
|
1066
|
-
if (existing)
|
|
1067
|
-
existing.push(status);
|
|
1068
|
-
else
|
|
1069
|
-
this.state.joinRequests.set(keyStr, [status]);
|
|
1070
|
-
}
|
|
1071
1104
|
// ── Signing.
|
|
1072
1105
|
signProfileUpdate(payload) {
|
|
1073
|
-
const
|
|
1106
|
+
const createdAt = unixTimeMillis();
|
|
1107
|
+
const event = profileUpdateEvent(this.agentId(), createdAt, this.nonces.nextNonce(createdAt), payload);
|
|
1074
1108
|
return this.signer.signEvent(event);
|
|
1075
1109
|
}
|
|
1076
1110
|
signRoomCreate(payload) {
|
|
1077
|
-
const
|
|
1111
|
+
const createdAt = unixTimeMillis();
|
|
1112
|
+
const event = roomCreateEvent(this.agentId(), createdAt, this.nonces.nextNonce(createdAt), payload);
|
|
1113
|
+
const envelope = this.signer.signEvent(event);
|
|
1114
|
+
validateDiscourseEnvelope(envelope);
|
|
1115
|
+
return envelope;
|
|
1116
|
+
}
|
|
1117
|
+
signJoinRequest(roomId, payload) {
|
|
1118
|
+
const createdAt = unixTimeMillis();
|
|
1119
|
+
const event = roomJoinRequestEvent(this.agentId(), createdAt, this.nonces.nextNonce(createdAt), roomId, payload);
|
|
1078
1120
|
const envelope = this.signer.signEvent(event);
|
|
1079
1121
|
validateDiscourseEnvelope(envelope);
|
|
1080
1122
|
return envelope;
|
|
@@ -1082,134 +1124,117 @@ export class LocalConnector {
|
|
|
1082
1124
|
signRoomEvent(type, key, baseSeq, baseHash, mentions, payload) {
|
|
1083
1125
|
const host = this.localRoom(key).host;
|
|
1084
1126
|
this.requireAllowedHost(host);
|
|
1085
|
-
const [resolvedSeq, resolvedHash] = this.
|
|
1086
|
-
const
|
|
1127
|
+
const [resolvedSeq, resolvedHash] = this.writeBase(key, baseSeq, baseHash);
|
|
1128
|
+
const createdAt = unixTimeMillis();
|
|
1129
|
+
let event = discourseEvent(type, this.agentId(), createdAt, this.nonces.nextNonce(createdAt), key.roomId, resolvedSeq, resolvedHash, payload);
|
|
1130
|
+
if (mentions.length > 0)
|
|
1131
|
+
event = withMentions(event, mentions);
|
|
1087
1132
|
const envelope = this.signer.signEvent(event);
|
|
1088
1133
|
validateDiscourseEnvelope(envelope);
|
|
1089
1134
|
return envelope;
|
|
1090
1135
|
}
|
|
1091
|
-
|
|
1136
|
+
/**
|
|
1137
|
+
* The base for a write: the explicit base, else the presented head, else the
|
|
1138
|
+
* current verified head (local connector Section 4.2).
|
|
1139
|
+
*/
|
|
1140
|
+
writeBase(key, baseSeq, baseHash) {
|
|
1092
1141
|
if (baseSeq !== undefined && baseHash !== undefined) {
|
|
1093
1142
|
if (baseSeq > 0 && baseHash.trim() !== "")
|
|
1094
1143
|
return [baseSeq, baseHash];
|
|
1095
1144
|
throw invalidPayload("base_seq and base_hash must identify a valid room head");
|
|
1096
1145
|
}
|
|
1097
|
-
if (baseSeq
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
return [
|
|
1146
|
+
if (baseSeq !== undefined || baseHash !== undefined) {
|
|
1147
|
+
throw invalidPayload("base_seq and base_hash must be provided together");
|
|
1148
|
+
}
|
|
1149
|
+
const room = this.localRoom(key);
|
|
1150
|
+
if (room.presentedSeq !== undefined && room.presentedHash !== undefined) {
|
|
1151
|
+
return [room.presentedSeq, room.presentedHash];
|
|
1103
1152
|
}
|
|
1104
|
-
|
|
1153
|
+
const sync = this.syncState(key);
|
|
1154
|
+
if (sync.head_seq === 0 || sync.head_hash.trim() === "") {
|
|
1155
|
+
throw invalidPayload("current room head is not known locally");
|
|
1156
|
+
}
|
|
1157
|
+
return [sync.head_seq, sync.head_hash];
|
|
1105
1158
|
}
|
|
1106
1159
|
requestJwt(host) {
|
|
1107
1160
|
// The request JWT aud is always the origin of the host API.
|
|
1108
|
-
const
|
|
1161
|
+
const normalized = normalizeHost(host);
|
|
1162
|
+
this.requireAllowedHost(normalized);
|
|
1163
|
+
const audience = serviceOrigin(normalized);
|
|
1109
1164
|
const claims = createRequestJwtClaims(this.agentId(), createRequestBinding(audience), unixTimeSecs(), DEFAULT_REQUEST_JWT_TTL_SECS);
|
|
1110
1165
|
return this.signer.signRequestJwt(claims);
|
|
1111
1166
|
}
|
|
1112
|
-
// ──
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
if (
|
|
1116
|
-
return
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
input.base_hash = undefined;
|
|
1136
|
-
return undefined;
|
|
1137
|
-
case "reject":
|
|
1138
|
-
return this.rejectedHeadMismatchResult(headMismatch);
|
|
1139
|
-
default:
|
|
1140
|
-
return this.holdEventDraft(input, headMismatch);
|
|
1167
|
+
// ── Presented head.
|
|
1168
|
+
/** Presents the head at `seq`, which must be a known head-advancing record or the current head. */
|
|
1169
|
+
presentHead(room, seq) {
|
|
1170
|
+
if (seq <= 0)
|
|
1171
|
+
return;
|
|
1172
|
+
const hash = seq === room.headSeq
|
|
1173
|
+
? room.headHash
|
|
1174
|
+
: room.records.find((record) => record.seq === seq)?.hash;
|
|
1175
|
+
if (hash === undefined)
|
|
1176
|
+
return;
|
|
1177
|
+
room.presentedSeq = seq;
|
|
1178
|
+
room.presentedHash = hash;
|
|
1179
|
+
}
|
|
1180
|
+
/** Presents the latest head-advancing record at or before `seq`, if it moves the presented head forward. */
|
|
1181
|
+
presentThrough(room, seq) {
|
|
1182
|
+
const base = room.presentedSeq ?? 0;
|
|
1183
|
+
for (let s = Math.min(seq, room.syncedSeq); s > base; s--) {
|
|
1184
|
+
const record = room.records.find((candidate) => candidate.seq === s);
|
|
1185
|
+
if (record && recordAdvancesRoomHead(room, record)) {
|
|
1186
|
+
room.presentedSeq = s;
|
|
1187
|
+
room.presentedHash = record.hash;
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1141
1190
|
}
|
|
1142
1191
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
const hashMismatch = baseHash !== undefined && sync.head_hash !== baseHash;
|
|
1149
|
-
if (!seqMismatch && !hashMismatch)
|
|
1150
|
-
return undefined;
|
|
1151
|
-
return { sync, changes: this.roomChangesSince(key, baseSeq) };
|
|
1152
|
-
}
|
|
1153
|
-
rejectedHeadMismatchResult(headMismatch) {
|
|
1192
|
+
// ── Head-mismatch handling and draft holding.
|
|
1193
|
+
rejectedHeadMismatch(key, baseSeq) {
|
|
1194
|
+
const room = this.localRoom(key);
|
|
1195
|
+
const changes = this.roomChangesSince(key, baseSeq);
|
|
1196
|
+
this.presentHead(room, room.headSeq);
|
|
1154
1197
|
return {
|
|
1155
1198
|
status: "rejected",
|
|
1156
1199
|
reason: "room_head_mismatch",
|
|
1157
|
-
changes
|
|
1158
|
-
sync:
|
|
1159
|
-
};
|
|
1160
|
-
}
|
|
1161
|
-
holdMessageDraft(input, headMismatch) {
|
|
1162
|
-
input.host = headMismatch.sync.host;
|
|
1163
|
-
const draftId = this.nextDraftId(input.room_id);
|
|
1164
|
-
const draft = {
|
|
1165
|
-
id: draftId,
|
|
1166
|
-
room_id: input.room_id,
|
|
1167
|
-
kind: "message",
|
|
1168
|
-
created_at: unixTimeMillis(),
|
|
1169
|
-
base_seq: input.base_seq,
|
|
1170
|
-
base_hash: input.base_hash,
|
|
1171
|
-
current_sync: headMismatch.sync,
|
|
1172
|
-
draft: messageDraftValue(input),
|
|
1173
|
-
reason: "room_head_mismatch",
|
|
1174
|
-
options: heldDraftOptions(),
|
|
1175
|
-
};
|
|
1176
|
-
this.state.drafts.set(draftId, {
|
|
1177
|
-
draft,
|
|
1178
|
-
request: { kind: "message", input },
|
|
1179
|
-
});
|
|
1180
|
-
return {
|
|
1181
|
-
status: "held",
|
|
1182
|
-
reason: "room_head_mismatch",
|
|
1183
|
-
draft,
|
|
1184
|
-
changes: headMismatch.changes,
|
|
1185
|
-
sync: headMismatch.sync,
|
|
1200
|
+
changes,
|
|
1201
|
+
sync: this.syncState(key),
|
|
1186
1202
|
};
|
|
1187
1203
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1204
|
+
holdDraft(key, request, base) {
|
|
1205
|
+
const room = this.localRoom(key);
|
|
1206
|
+
const changes = this.roomChangesSince(key, base[0]);
|
|
1207
|
+
// The held result shows every change up to the current head.
|
|
1208
|
+
this.presentHead(room, room.headSeq);
|
|
1209
|
+
const sync = this.syncState(key);
|
|
1210
|
+
const input = { ...request.input, host: sync.host, base_seq: base[0], base_hash: base[1] };
|
|
1190
1211
|
const draftId = this.nextDraftId(input.room_id);
|
|
1191
1212
|
const draft = {
|
|
1192
1213
|
id: draftId,
|
|
1193
1214
|
room_id: input.room_id,
|
|
1194
|
-
kind:
|
|
1215
|
+
kind: request.kind,
|
|
1195
1216
|
created_at: unixTimeMillis(),
|
|
1196
|
-
base_seq:
|
|
1197
|
-
base_hash:
|
|
1198
|
-
current_sync:
|
|
1199
|
-
draft:
|
|
1217
|
+
base_seq: base[0],
|
|
1218
|
+
base_hash: base[1],
|
|
1219
|
+
current_sync: sync,
|
|
1220
|
+
draft: request.kind === "message"
|
|
1221
|
+
? messageDraftValue(input)
|
|
1222
|
+
: eventDraftValue(input),
|
|
1200
1223
|
reason: "room_head_mismatch",
|
|
1201
1224
|
options: heldDraftOptions(),
|
|
1202
1225
|
};
|
|
1203
1226
|
this.state.drafts.set(draftId, {
|
|
1204
1227
|
draft,
|
|
1205
|
-
request:
|
|
1228
|
+
request: request.kind === "message"
|
|
1229
|
+
? { kind: "message", input: input }
|
|
1230
|
+
: { kind: "event", input: input },
|
|
1206
1231
|
});
|
|
1207
1232
|
return {
|
|
1208
1233
|
status: "held",
|
|
1209
1234
|
reason: "room_head_mismatch",
|
|
1210
1235
|
draft,
|
|
1211
|
-
changes
|
|
1212
|
-
sync
|
|
1236
|
+
changes,
|
|
1237
|
+
sync,
|
|
1213
1238
|
};
|
|
1214
1239
|
}
|
|
1215
1240
|
roomChangesSince(key, baseSeq) {
|
|
@@ -1223,7 +1248,10 @@ export class LocalConnector {
|
|
|
1223
1248
|
const room = [...roomId]
|
|
1224
1249
|
.map((ch) => (/[A-Za-z0-9_-]/.test(ch) ? ch : "_"))
|
|
1225
1250
|
.join("");
|
|
1226
|
-
|
|
1251
|
+
let n = this.state.drafts.size + 1;
|
|
1252
|
+
while (this.state.drafts.has(`draft_${room}_${n}`))
|
|
1253
|
+
n += 1;
|
|
1254
|
+
return `draft_${room}_${n}`;
|
|
1227
1255
|
}
|
|
1228
1256
|
// ── Views.
|
|
1229
1257
|
syncState(key) {
|
|
@@ -1234,6 +1262,9 @@ export class LocalConnector {
|
|
|
1234
1262
|
room_id: key.roomId,
|
|
1235
1263
|
head_seq: room.headSeq,
|
|
1236
1264
|
head_hash: headHash,
|
|
1265
|
+
...(room.presentedSeq !== undefined
|
|
1266
|
+
? { presented_seq: room.presentedSeq, presented_hash: room.presentedHash }
|
|
1267
|
+
: {}),
|
|
1237
1268
|
synced_seq: room.syncedSeq,
|
|
1238
1269
|
remote_seq: Math.max(room.room.seq, room.syncedSeq),
|
|
1239
1270
|
subscribed: room.subscribed,
|
|
@@ -1241,6 +1272,9 @@ export class LocalConnector {
|
|
|
1241
1272
|
pending_inbox_count: this.pendingInboxCount(key.roomId),
|
|
1242
1273
|
};
|
|
1243
1274
|
}
|
|
1275
|
+
maybeSync(key) {
|
|
1276
|
+
return this.state.rooms.has(roomKeyString(key)) ? this.syncState(key) : undefined;
|
|
1277
|
+
}
|
|
1244
1278
|
roomStateView(room) {
|
|
1245
1279
|
const selfMember = room.members.get(this.agentId());
|
|
1246
1280
|
return {
|
|
@@ -1251,8 +1285,8 @@ export class LocalConnector {
|
|
|
1251
1285
|
topic: roomTopic(room.room),
|
|
1252
1286
|
agenda: roomAgenda(room.room),
|
|
1253
1287
|
guidance: roomGuidance(room.room),
|
|
1254
|
-
creator: room.room.envelope?.event.actor,
|
|
1255
|
-
created_at: room.room.envelope?.event.created_at,
|
|
1288
|
+
creator: room.room.creator ?? room.room.envelope?.event.actor,
|
|
1289
|
+
created_at: room.room.created_at ?? room.room.envelope?.event.created_at,
|
|
1256
1290
|
start_time: roomStartTime(room.room),
|
|
1257
1291
|
end_time: roomEndTime(room.room),
|
|
1258
1292
|
tags: roomTags(room.room),
|
|
@@ -1293,10 +1327,24 @@ export class LocalConnector {
|
|
|
1293
1327
|
return room;
|
|
1294
1328
|
}
|
|
1295
1329
|
requireAllowedHost(host) {
|
|
1296
|
-
const record = this.state.hosts.get(host);
|
|
1330
|
+
const record = this.state.hosts.get(normalizeHost(host));
|
|
1297
1331
|
if (!record || !record.allowed)
|
|
1298
1332
|
throw permissionDenied();
|
|
1299
1333
|
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Operator policy for a service URL that is not a discourse host: its origin
|
|
1336
|
+
* must be an allowed host or the profile service of one.
|
|
1337
|
+
*/
|
|
1338
|
+
requireAllowedOrigin(url) {
|
|
1339
|
+
const origin = serviceOrigin(url);
|
|
1340
|
+
if (this.state.hosts.get(origin)?.allowed)
|
|
1341
|
+
return;
|
|
1342
|
+
for (const host of this.state.hosts.values()) {
|
|
1343
|
+
if (host.allowed && host.profile_service !== undefined && serviceOrigin(host.profile_service) === origin)
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
throw permissionDenied();
|
|
1347
|
+
}
|
|
1300
1348
|
allowedRoomHost(key) {
|
|
1301
1349
|
const host = this.localRoom(key).host;
|
|
1302
1350
|
this.requireAllowedHost(host);
|
|
@@ -1335,8 +1383,5 @@ export class LocalConnector {
|
|
|
1335
1383
|
profileClient(url) {
|
|
1336
1384
|
return new ProfileClient(url, this.fetchImpl);
|
|
1337
1385
|
}
|
|
1338
|
-
delegationClient(url) {
|
|
1339
|
-
return new DelegationClient(url, this.fetchImpl);
|
|
1340
|
-
}
|
|
1341
1386
|
}
|
|
1342
1387
|
//# sourceMappingURL=engine.js.map
|