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