agent-protocols 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +91 -19
  2. package/dist/delegation.d.ts +57 -36
  3. package/dist/delegation.d.ts.map +1 -1
  4. package/dist/delegation.js +208 -69
  5. package/dist/delegation.js.map +1 -1
  6. package/dist/discourse.d.ts +196 -74
  7. package/dist/discourse.d.ts.map +1 -1
  8. package/dist/discourse.js +568 -116
  9. package/dist/discourse.js.map +1 -1
  10. package/dist/http-client.d.ts +65 -18
  11. package/dist/http-client.d.ts.map +1 -1
  12. package/dist/http-client.js +217 -34
  13. package/dist/http-client.js.map +1 -1
  14. package/dist/identity.d.ts +117 -3
  15. package/dist/identity.d.ts.map +1 -1
  16. package/dist/identity.js +367 -7
  17. package/dist/identity.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/knowledge-schema.d.ts +978 -0
  23. package/dist/knowledge-schema.d.ts.map +1 -0
  24. package/dist/knowledge-schema.js +1187 -0
  25. package/dist/knowledge-schema.js.map +1 -0
  26. package/dist/knowledge.d.ts +275 -0
  27. package/dist/knowledge.d.ts.map +1 -0
  28. package/dist/knowledge.js +795 -0
  29. package/dist/knowledge.js.map +1 -0
  30. package/dist/local-connector/catalog.d.ts +1 -16
  31. package/dist/local-connector/catalog.d.ts.map +1 -1
  32. package/dist/local-connector/catalog.js +53 -71
  33. package/dist/local-connector/catalog.js.map +1 -1
  34. package/dist/local-connector/engine.d.ts +55 -32
  35. package/dist/local-connector/engine.d.ts.map +1 -1
  36. package/dist/local-connector/engine.js +636 -608
  37. package/dist/local-connector/engine.js.map +1 -1
  38. package/dist/local-connector/inputs.d.ts +28 -76
  39. package/dist/local-connector/inputs.d.ts.map +1 -1
  40. package/dist/local-connector/inputs.js +19 -1
  41. package/dist/local-connector/inputs.js.map +1 -1
  42. package/dist/local-connector/projection.d.ts +24 -9
  43. package/dist/local-connector/projection.d.ts.map +1 -1
  44. package/dist/local-connector/projection.js +78 -39
  45. package/dist/local-connector/projection.js.map +1 -1
  46. package/dist/local-connector/state.d.ts +10 -3
  47. package/dist/local-connector/state.d.ts.map +1 -1
  48. package/dist/local-connector/state.js +3 -0
  49. package/dist/local-connector/state.js.map +1 -1
  50. package/dist/local-connector/views.d.ts +36 -12
  51. package/dist/local-connector/views.d.ts.map +1 -1
  52. package/dist/local-connector/views.js +49 -31
  53. package/dist/local-connector/views.js.map +1 -1
  54. package/dist/profile.d.ts +23 -9
  55. package/dist/profile.d.ts.map +1 -1
  56. package/dist/profile.js +120 -3
  57. package/dist/profile.js.map +1 -1
  58. package/package.json +9 -5
@@ -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 an Agent Discourse host over
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, and holds drafts on a room-head mismatch — all behind a
7
- // single `callTool` dispatcher. The agent never sees the signing key or a
8
- // reusable request JWT. This is one deep module; its methods are mutually
9
- // recursive over `this`, so the class stays whole here while the data shapes
10
- // and pure projection live in sibling submodules.
11
- import { discourseEvent, eventType, roomCreateEvent, validateDiscourseEnvelope, validateRoomPath, verifyServerRecord, } from "../discourse.js";
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 { TOOL_AGENT_STATUS_CLEAR, TOOL_AGENT_STATUS_GET, TOOL_AGENT_STATUS_LIST, TOOL_AGENT_STATUS_SET, TOOL_DRAFTS_LIST, TOOL_DRAFT_COMMIT, TOOL_DRAFT_DROP, TOOL_DRAFT_GET, TOOL_HOSTS_LIST, TOOL_IDENTITY_CURRENT, TOOL_INBOX_ACK, TOOL_INBOX_NEXT, TOOL_JOIN_REQUESTS_LIST, TOOL_JOIN_REQUEST_REVIEW, TOOL_PROFILE_UPDATE, TOOL_ROOMS_LIST, TOOL_PRINCIPAL_RESOLVE, TOOL_DELEGATION_CHECK, TOOL_DELEGATIONS_LIST, TOOL_DELEGATION_GRANT, TOOL_DELEGATION_REVOKE, TOOL_ROOMS_SEARCH, TOOL_ROOM_CREATE, TOOL_ROOM_JOIN, TOOL_ROOM_JOIN_REQUEST, TOOL_ROOM_JOIN_WHEN_APPROVED, TOOL_ROOM_LEAVE, TOOL_ROOM_MARK_READ, TOOL_ROOM_MEMBERS_LIST, TOOL_ROOM_MEMBER_GET, TOOL_ROOM_OPEN, TOOL_ROOM_SEND_MESSAGE, TOOL_ROOM_STATE, TOOL_ROOM_SUBMIT_EVENT, TOOL_ROOM_TIMELINE, TOOL_ROOM_UNREAD, } from "./catalog.js";
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 { applyRecordProjection, eventTypeAdvancesRoomHead, inboxEntryReady, isDuplicateRecord, materializeCreator, membershipFilter, recordAdvancesRoomHead, validateNextRecord, validateRecordBasePrecondition, } from "./projection.js";
19
- import { LocalConnectorState } from "./state.js";
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", "send_as_is", "stay_silent", "send_anyway"];
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
- validateDiscourseEnvelope(record.envelope);
187
- validateRoomPath(record.envelope, record.room_id);
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.roomStateTool(input);
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
- hostsList() {
323
- return { hosts: this.sortedHosts() };
313
+ async roomsList(input) {
314
+ validateRoomsListScope(input);
315
+ return (input.scope ?? "known") === "public" ? this.publicRooms(input) : this.knownRooms(input);
324
316
  }
325
- async roomsSearch(input) {
317
+ async publicRooms(input) {
326
318
  const host = normalizeHost(input.host);
327
319
  this.requireAllowedHost(host);
328
- const rooms = await this.discourse(host).publicRooms({
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 rooms)
331
+ for (const room of response.result)
340
332
  this.observeRoom(host, room);
341
- const summaries = rooms.map((room) => this.summaryForResponse(host, room));
342
- return { rooms: summaries };
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
- roomsList(input) {
345
- const offset = parseCursor(input.cursor);
346
- const limit = input.limit ?? 50;
338
+ knownRooms(input) {
347
339
  const agentId = this.agentId();
348
- const rooms = [...this.state.rooms.values()]
349
- .sort((a, b) => compareStrings(a.host, b.host) ||
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
- .slice(offset, offset + limit)
354
- .map((room) => this.summaryForRoom(room));
355
- return { rooms };
356
- }
357
- async roomOpen(input) {
358
- const host = normalizeHost(input.host);
359
- this.requireAllowedHost(host);
360
- const key = { host, roomId: input.room_id };
361
- const previousSeq = this.state.rooms.get(roomKeyString(key))?.syncedSeq ?? 0;
362
- if (input.refresh || previousSeq === 0) {
363
- const client = this.discourse(host);
364
- const room = await client.room(input.room_id);
365
- this.observeRoom(host, room);
366
- const jwt = this.requestJwt(host);
367
- const records = await client.events(input.room_id, {
368
- afterSeq: previousSeq > 0 ? previousSeq : undefined,
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 records)
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
- const opened = this.state.rooms.get(roomKeyString(key));
375
- if (opened)
376
- opened.subscribed = input.subscribe ?? false;
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.status !== undefined ? member.status === input.status : true)
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
- return { members, sync: this.syncState(key) };
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 { member, recent, sync: this.syncState(key) };
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
- if (!input.refresh) {
440
- const cached = this.state.agentStatuses.get(keyStr);
441
- if (cached) {
442
- return {
443
- statuses: sortedAgentStatuses(cached),
444
- sync: this.syncState(key),
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 jwt = this.requestJwt(host);
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.statuses) {
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 jwt = this.requestJwt(host);
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, jwt, request);
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 agentStatusClear(input) {
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 ? input.actors.includes(item.actor) : true)
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 nextAfterSeq = items.length > 0 ? items[items.length - 1].seq : undefined;
526
- return {
527
- items,
528
- sync: this.syncState(key),
529
- next_after_seq: nextAfterSeq,
530
- };
531
- }
532
- roomUnread(input) {
533
- const key = this.resolveRoomKey(input.host, input.room_id);
534
- const room = this.localRoom(key);
535
- let items = room.timeline
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
- const offset = parseCursor(input.cursor);
604
- const limit = input.limit ?? 50;
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
- let nextCursor;
615
- if (drafts.length > limit) {
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 === "stay_silent") {
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
- let result;
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 request = { ...entry.request.input };
646
- if (input.action === "send_anyway") {
647
- request.base_seq = undefined;
648
- request.base_hash = undefined;
649
- request.on_head_mismatch = "send_anyway";
650
- result = await this.submitMessageUnchecked(request);
651
- }
652
- else {
653
- if (input.action === "revise") {
654
- if (input.content !== undefined)
655
- request.content = input.content;
656
- if (input.content_type !== undefined)
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 request = { ...entry.request.input };
673
- if (input.action === "send_anyway") {
674
- request.base_seq = undefined;
675
- request.base_hash = undefined;
676
- request.on_head_mismatch = "send_anyway";
677
- result = await this.submitEventUnchecked(request);
678
- }
679
- else {
680
- if (input.action === "revise") {
681
- if (input.type !== undefined)
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.delegationClient(url).principal(url);
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 could never be accepted is not signed
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
- if (queryUrl === undefined) {
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
- return {
759
- canonical_id: document.id,
760
- query_url: queryUrl,
761
- delegations: response.result,
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 jwt = this.requestJwt(input.delegation_service);
768
- const response = await this.delegationClient(input.delegation_service).queryDelegations({
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 { delegations: response.result };
738
+ return response.next_cursor !== undefined
739
+ ? { delegations: response.result, next_cursor: response.next_cursor }
740
+ : { delegations: response.result };
774
741
  }
775
- async delegationPrevious(principal, service, id) {
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 this.delegationClient(service).delegation(id);
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 previous = await this.delegationPrevious(principal, input.delegation_service, input.id);
766
+ const service = await this.principalDelegationService(principal);
767
+ const previous = await this.delegationPrevious(service, input.id);
792
768
  const payload = {
793
- id: input.id, principal: { id: principal.id }, subject: input.subject,
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 createdAt = unixTimeMillis();
798
- const event = delegationGrantEvent(this.agentId(), createdAt, this.nonces.nextNonce(), payload);
799
- validateDelegationEventAuthority(event, principal, createdAt, previous);
800
- const envelope = this.signer.signEvent(event);
801
- const credential = await this.delegationClient(input.delegation_service).submitDelegationEvent(envelope);
802
- return { credential, envelope };
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 previous = await this.delegationPrevious(principal, input.delegation_service, input.id);
807
- const createdAt = unixTimeMillis();
808
- const event = delegationRevokeEvent(this.agentId(), createdAt, this.nonces.nextNonce(), {
809
- id: input.id, principal_id: principal.id, reason: input.reason,
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 envelope = this.signProfileUpdate(profile);
831
- const materialized = await this.profileClient(input.profile_service).submitProfileUpdate(envelope);
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 envelope = this.signRoomCreate(payload);
851
- const room = await this.discourse(host).createRoom(envelope);
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(this.localRoom(key)),
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
- let key;
865
- if (input.host !== undefined) {
866
- const host = normalizeHost(input.host);
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
- if (!this.state.rooms.has(roomKeyString(key))) {
876
- const room = await this.discourse(host).room(roomId);
877
- this.acceptRoomResponse(host, room);
878
- }
879
- if (input.request_id !== undefined) {
880
- const status = await this.approvedJoinRequest(host, roomId, input.request_id);
881
- const approvedRole = status.approved_role ?? status.request.role;
882
- // The completion call signs room.join with the approved role; a differing
883
- // input role is a mismatch error, never a silent substitution.
884
- if (input.role !== approvedRole) {
885
- throw invalidPayload(`join_request_role_mismatch: approved role is ${approvedRole}`);
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
- if (roomVisibility(this.localRoom(key).room) === "public") {
894
- const payload = {
895
- request_id: undefined,
896
- role: input.role,
897
- perspective: input.perspective,
898
- };
899
- return this.completeJoin(host, key, payload);
900
- }
901
- const jwt = this.requestJwt(host);
902
- const request = {
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
- catch {
915
- sync = undefined;
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
- return { status: "approval_required", join_request: status, sync };
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 roomJoinRequest(input) {
920
- const host = normalizeHost(input.host);
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 roomJoinWhenApproved(input) {
934
- const key = this.resolveRoomKey(input.host, input.room_id);
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
- async roomLeave(input) {
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 payload = {
949
- reason: input.reason,
950
- references: [],
951
- extra: {},
952
- };
953
- const envelope = this.signRoomEvent(eventType.ROOM_LEAVE, key, undefined, undefined, [], payload);
954
- const record = await this.discourse(host).leaveRoom(input.room_id, envelope);
955
- this.applyHostRecord(host, record);
956
- return { record, sync: this.syncState(key) };
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
- async roomSendMessage(input) {
959
- const key = this.resolveRoomKey(input.host, input.room_id);
960
- const held = this.headMismatchMessageResult(key, input);
961
- if (held)
962
- return held;
963
- return this.submitMessageUnchecked(input);
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
- async submitMessageUnchecked(input) {
966
- const key = this.resolveRoomKey(input.host, input.room_id);
967
- const host = this.allowedRoomHost(key);
968
- const payload = {
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
- if (input.extra && Object.keys(input.extra).length > 0) {
976
- payload.extra = input.extra;
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
- async roomSubmitEvent(input) {
990
- // For signal-kind writes — including the membership events — the base is
991
- // only an anchor: never hold the draft, ignore on_head_mismatch.
992
- const key = this.resolveRoomKey(input.host, input.room_id);
993
- const room = this.state.rooms.get(roomKeyString(key));
994
- const advancesHead = room
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
- async submitEventUnchecked(input) {
1005
- const key = this.resolveRoomKey(input.host, input.room_id);
1006
- const host = this.allowedRoomHost(key);
1007
- const payload = payloadWithReferences(input.payload, input.references ?? []);
1008
- const envelope = this.signRoomEvent(input.type, key, input.base_seq, input.base_hash, input.mentions ?? [], payload);
1009
- const record = await this.discourse(host).submitEvent(input.room_id, envelope);
1010
- this.applyHostRecord(host, record);
1011
- const item = this.timelineItemByEvent(key, record.envelope.hash);
1012
- return {
1013
- status: "sent",
1014
- record: record,
1015
- item,
1016
- sync: this.syncState(key),
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 jwt = this.requestJwt(host);
1023
- let requests = await this.discourse(host).joinRequests(input.room_id, jwt);
1024
- if (input.status !== undefined) {
1025
- requests = requests.filter((request) => request.status === input.status);
1026
- }
1027
- const offset = parseCursor(input.cursor);
1028
- if (offset > 0)
1029
- requests = requests.slice(offset);
1030
- if (input.limit !== undefined)
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
- const jwt = this.requestJwt(host);
1039
- const status = await this.discourse(host).joinRequest(input.room_id, input.request_id, jwt);
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: status.request,
1093
+ request: joinRequest.request,
1042
1094
  decision: input.decision,
1043
- role: input.role,
1044
- reason: input.reason,
1045
- extra: {},
1046
1095
  };
1047
- const envelope = this.signRoomEvent(eventType.ROOM_JOIN_REVIEW, key, undefined, undefined, [], payload);
1048
- const record = await this.discourse(host).submitEvent(input.room_id, envelope);
1049
- this.applyHostRecord(host, record);
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 event = profileUpdateEvent(this.agentId(), unixTimeMillis(), this.nonces.nextNonce(), payload);
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 event = roomCreateEvent(this.agentId(), unixTimeMillis(), this.nonces.nextNonce(), payload);
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.roomHeadForWrite(key, baseSeq, baseHash);
1101
- const event = withMentions(discourseEvent(type, this.agentId(), unixTimeMillis(), this.nonces.nextNonce(), key.roomId, resolvedSeq, resolvedHash, payload), mentions);
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
- roomHeadForWrite(key, baseSeq, baseHash) {
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 === undefined && baseHash === undefined) {
1113
- const sync = this.syncState(key);
1114
- if (sync.head_seq === 0 || sync.head_hash.trim() === "") {
1115
- throw invalidPayload("current room head is not known locally");
1116
- }
1117
- return [sync.head_seq, sync.head_hash];
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
- throw invalidPayload("base_seq and base_hash must be provided together");
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
- // ── Head-mismatch handling and draft holding.
1130
- headMismatchMessageResult(key, input) {
1131
- const headMismatch = this.headMismatchWriteState(key, input.base_seq, input.base_hash);
1132
- if (!headMismatch)
1133
- return undefined;
1134
- switch (input.on_head_mismatch ?? "hold") {
1135
- case "send_anyway":
1136
- input.base_seq = undefined;
1137
- input.base_hash = undefined;
1138
- return undefined;
1139
- case "reject":
1140
- return this.rejectedHeadMismatchResult(headMismatch);
1141
- default:
1142
- return this.holdMessageDraft(input, headMismatch);
1143
- }
1144
- }
1145
- headMismatchEventResult(key, input) {
1146
- const headMismatch = this.headMismatchWriteState(key, input.base_seq, input.base_hash);
1147
- if (!headMismatch)
1148
- return undefined;
1149
- switch (input.on_head_mismatch ?? "hold") {
1150
- case "send_anyway":
1151
- input.base_seq = undefined;
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
- headMismatchWriteState(key, baseSeq, baseHash) {
1161
- if (baseSeq === undefined && baseHash === undefined)
1162
- return undefined;
1163
- const sync = this.syncState(key);
1164
- const seqMismatch = baseSeq !== undefined && baseSeq !== sync.head_seq;
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: headMismatch.changes,
1175
- sync: headMismatch.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
- holdEventDraft(input, headMismatch) {
1206
- input.host = headMismatch.sync.host;
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: "event",
1215
+ kind: request.kind,
1212
1216
  created_at: unixTimeMillis(),
1213
- base_seq: input.base_seq,
1214
- base_hash: input.base_hash,
1215
- current_sync: headMismatch.sync,
1216
- draft: eventDraftValue(input),
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: { kind: "event", input },
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: headMismatch.changes,
1229
- sync: headMismatch.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
- return `draft_${room}_${this.state.drafts.size + 1}`;
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