agent-protocols 0.7.0 → 0.9.0

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