@relaycast/engine 8.16.0 → 9.0.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 (66) hide show
  1. package/dist/db/migrations/0062_direct_dm_idempotency.sql +20 -0
  2. package/dist/db/migrations/0063_node_rotation_recovery.sql +13 -0
  3. package/dist/db/schema.d.ts +200 -0
  4. package/dist/db/schema.d.ts.map +1 -1
  5. package/dist/db/schema.js +29 -0
  6. package/dist/db/schema.js.map +1 -1
  7. package/dist/engine/a2aEgress.d.ts +10 -0
  8. package/dist/engine/a2aEgress.d.ts.map +1 -1
  9. package/dist/engine/a2aEgress.js +35 -3
  10. package/dist/engine/a2aEgress.js.map +1 -1
  11. package/dist/engine/attachments.d.ts.map +1 -1
  12. package/dist/engine/attachments.js +5 -3
  13. package/dist/engine/attachments.js.map +1 -1
  14. package/dist/engine/dm.d.ts +5 -1
  15. package/dist/engine/dm.d.ts.map +1 -1
  16. package/dist/engine/dm.js +131 -20
  17. package/dist/engine/dm.js.map +1 -1
  18. package/dist/engine/dmAll.d.ts +6 -0
  19. package/dist/engine/dmAll.d.ts.map +1 -1
  20. package/dist/engine/dmAll.js +51 -2
  21. package/dist/engine/dmAll.js.map +1 -1
  22. package/dist/engine/inboundWebhook.d.ts +4 -0
  23. package/dist/engine/inboundWebhook.d.ts.map +1 -1
  24. package/dist/engine/inboundWebhook.js +5 -0
  25. package/dist/engine/inboundWebhook.js.map +1 -1
  26. package/dist/engine/messageMetadata.d.ts +4 -0
  27. package/dist/engine/messageMetadata.d.ts.map +1 -1
  28. package/dist/engine/messageMetadata.js +7 -1
  29. package/dist/engine/messageMetadata.js.map +1 -1
  30. package/dist/engine/node.d.ts +37 -7
  31. package/dist/engine/node.d.ts.map +1 -1
  32. package/dist/engine/node.js +107 -19
  33. package/dist/engine/node.js.map +1 -1
  34. package/dist/engine/sessionEvent.d.ts.map +1 -1
  35. package/dist/engine/sessionEvent.js +2 -0
  36. package/dist/engine/sessionEvent.js.map +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/lib/serverTelemetry.d.ts +13 -2
  42. package/dist/lib/serverTelemetry.d.ts.map +1 -1
  43. package/dist/lib/serverTelemetry.js +4 -0
  44. package/dist/lib/serverTelemetry.js.map +1 -1
  45. package/dist/middleware/idempotency.d.ts +15 -9
  46. package/dist/middleware/idempotency.d.ts.map +1 -1
  47. package/dist/middleware/idempotency.js +104 -58
  48. package/dist/middleware/idempotency.js.map +1 -1
  49. package/dist/routes/a2a.js +6 -1
  50. package/dist/routes/a2a.js.map +1 -1
  51. package/dist/routes/agent.js +1 -1
  52. package/dist/routes/agent.js.map +1 -1
  53. package/dist/routes/dm.d.ts.map +1 -1
  54. package/dist/routes/dm.js +70 -42
  55. package/dist/routes/dm.js.map +1 -1
  56. package/dist/routes/node.d.ts.map +1 -1
  57. package/dist/routes/node.js +38 -3
  58. package/dist/routes/node.js.map +1 -1
  59. package/dist/routes/webhookOutbox.d.ts +1 -0
  60. package/dist/routes/webhookOutbox.d.ts.map +1 -1
  61. package/dist/routes/webhookOutbox.js +9 -5
  62. package/dist/routes/webhookOutbox.js.map +1 -1
  63. package/dist/routes/workspace.d.ts.map +1 -1
  64. package/dist/routes/workspace.js +39 -6
  65. package/dist/routes/workspace.js.map +1 -1
  66. package/package.json +3 -3
@@ -16,6 +16,7 @@ import { serializeNodeOp } from './nodeLock.js';
16
16
  import { completeNodeInvocation, dispatchCapacitySpawn, rescheduleInvocationsForLostNode, rescheduleNodeInvocation, } from './action.js';
17
17
  import { emitInvocationCompletionEffects, emitAgentExitedEffects, emitNodeStatusEffects, fleetInvocationId } from './invocationCompletion.js';
18
18
  import { ackDeliveriesUpToSeq, deliverPendingToNode } from './delivery.js';
19
+ const NODE_ROTATION_RECOVERY_TTL_MS = 24 * 60 * 60 * 1_000;
19
20
  function resolveProviderIdentity(message, connectionId) {
20
21
  if (message.provider)
21
22
  return message.provider;
@@ -278,8 +279,8 @@ function normalizeLegacyAdapter(adapter) {
278
279
  return 'ws.node.v1';
279
280
  return adapter;
280
281
  }
281
- export async function createNodeToken(db, workspaceId, data) {
282
- const token = `nt_live_${randomHex(24)}`;
282
+ export async function createNodeToken(db, workspaceId, data, authorization = {}) {
283
+ const token = authorization.rotationRecovery?.replacementToken ?? `nt_live_${randomHex(24)}`;
283
284
  const tokenHash = await sha256Hex(token);
284
285
  const name = data.name.startsWith('#') ? data.name.slice(1) : data.name;
285
286
  // Reject names that are empty or still #-prefixed after normalization: an
@@ -292,7 +293,7 @@ export async function createNodeToken(db, workspaceId, data) {
292
293
  // lookup is only a fallback for callers without a stable id. A name held by
293
294
  // a *different* node id is a conflict — mirroring node.register — never a
294
295
  // silent rewrite of the other node.
295
- const existing = await resolveNodeForEnroll(db, workspaceId, data);
296
+ const existing = await resolveNodeForEnroll(db, workspaceId, data, authorization.expectedTokenHash);
296
297
  if (data.node_id !== undefined) {
297
298
  const nameHolder = await getNodeByName(db, workspaceId, name);
298
299
  if (nameHolder && nameHolder.id !== data.node_id) {
@@ -320,11 +321,42 @@ export async function createNodeToken(db, workspaceId, data) {
320
321
  throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400);
321
322
  }
322
323
  if (existing) {
324
+ const hasPresentedTokenProof = authorization.expectedTokenHash !== undefined;
325
+ const authorizedDirectNode = authorization.directAgentId !== undefined
326
+ && existing.id === directNodeIdForAgent(authorization.directAgentId)
327
+ && existing.role === 'direct';
328
+ const recovery = authorization.rotationRecovery;
329
+ const isIdempotentRotationReplay = (candidate) => hasPresentedTokenProof
330
+ && recovery !== undefined
331
+ && candidate.tokenHash === tokenHash
332
+ && candidate.previousTokenHash === authorization.expectedTokenHash
333
+ && candidate.rotationIdempotencyKeyHash === recovery.idempotencyKeyHash
334
+ && candidate.rotationRequestDigest === recovery.requestDigest
335
+ && candidate.previousTokenExpiresAt !== null
336
+ && candidate.previousTokenExpiresAt.getTime() > now.getTime();
337
+ if (isIdempotentRotationReplay(existing)) {
338
+ return { ...publicNode(existing), token };
339
+ }
340
+ if (!hasPresentedTokenProof && !authorizedDirectNode) {
341
+ throw codedError('Node already exists; its current node token is required to rotate or modify it', 'node_token_proof_required', 409);
342
+ }
323
343
  const [updated] = await db
324
344
  .update(nodes)
325
345
  .set({
326
346
  name,
327
347
  tokenHash,
348
+ previousTokenHash: hasPresentedTokenProof && !authorizedDirectNode && recovery
349
+ ? authorization.expectedTokenHash
350
+ : null,
351
+ previousTokenExpiresAt: hasPresentedTokenProof && !authorizedDirectNode && recovery
352
+ ? new Date(now.getTime() + NODE_ROTATION_RECOVERY_TTL_MS)
353
+ : null,
354
+ rotationIdempotencyKeyHash: hasPresentedTokenProof && !authorizedDirectNode && recovery
355
+ ? recovery.idempotencyKeyHash
356
+ : null,
357
+ rotationRequestDigest: hasPresentedTokenProof && !authorizedDirectNode && recovery
358
+ ? recovery.requestDigest
359
+ : null,
328
360
  kind,
329
361
  role,
330
362
  deliveryAdapter,
@@ -344,8 +376,23 @@ export async function createNodeToken(db, workspaceId, data) {
344
376
  load: 0,
345
377
  loadReported: false,
346
378
  })
347
- .where(eq(nodes.id, existing.id))
379
+ // Token proof is a generation fence, not just a read-time check. If a
380
+ // concurrent rotation changes the credential after `existing` was read,
381
+ // this request must not overwrite the winner with stale authority.
382
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.id), ...(hasPresentedTokenProof && !authorizedDirectNode
383
+ ? [eq(nodes.tokenHash, authorization.expectedTokenHash)]
384
+ : [])))
348
385
  .returning();
386
+ if (!updated) {
387
+ // Two identical retries can both read the previous generation before
388
+ // either update runs. If the other request committed this exact keyed
389
+ // rotation, recover its result instead of returning a spurious conflict.
390
+ const winner = recovery ? await getNodeById(db, workspaceId, existing.id) : null;
391
+ if (winner && isIdempotentRotationReplay(winner)) {
392
+ return { ...publicNode(winner), token };
393
+ }
394
+ throw codedError('Node token changed before enrollment completed; retry with the current node token', 'node_token_proof_required', 409);
395
+ }
349
396
  return { ...publicNode(updated), token };
350
397
  }
351
398
  const [created] = await db
@@ -417,21 +464,18 @@ const MACHINE_MATCH_SCAN_LIMIT = 20;
417
464
  * separate nodes. Declining the match costs an extra roster row; rejecting the
418
465
  * enrollment outright would break a whole fleet booted from one image.
419
466
  *
420
- * Ordered oldest-first so a roster that already holds several rows for one
421
- * machine converges onto its earliest reusable row instead of picking
422
- * arbitrarily.
467
+ * Current-token proof selects its exact reusable generation when several
468
+ * brokers share one machine. Without matching proof, the fallback remains
469
+ * oldest-first so a roster converges instead of picking arbitrarily.
423
470
  */
424
- export async function getBrokerNodeByMachineId(db, workspaceId, machineId) {
471
+ export async function getBrokerNodeByMachineId(db, workspaceId, machineId, expectedTokenHash) {
425
472
  // Exclude live rows in SQL so the bound below counts only reusable
426
473
  // candidates. Bounding first and filtering after would let a machine with
427
474
  // enough live brokers hide its one offline row past the limit, and enrollment
428
475
  // would insert a new row instead of reusing it.
429
476
  const now = new Date();
430
477
  const liveCutoff = new Date(now.getTime() - NODE_LIVENESS_TTL_MS);
431
- const candidates = await db
432
- .select()
433
- .from(nodes)
434
- .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.machineId, machineId), eq(nodes.role, 'broker'),
478
+ const reusableWhere = and(eq(nodes.workspaceId, workspaceId), eq(nodes.machineId, machineId), eq(nodes.role, 'broker'),
435
479
  // Mirrors isReusableForMachineMatch in full, so the scan bound below
436
480
  // counts only rows that are actually reusable. A stale proof alone is not
437
481
  // enough: a broker that registered recently has a fresh lastHeartbeatAt
@@ -441,7 +485,25 @@ export async function getBrokerNodeByMachineId(db, workspaceId, machineId) {
441
485
  // not live — mirrors isNodeLive
442
486
  or(ne(nodes.status, 'online'), isNull(nodes.lastHeartbeatAt), lt(nodes.lastHeartbeatAt, liveCutoff)),
443
487
  // no future-dated heartbeat — server clock rollback
444
- or(isNull(nodes.lastHeartbeatAt), lte(nodes.lastHeartbeatAt, now))))
488
+ or(isNull(nodes.lastHeartbeatAt), lte(nodes.lastHeartbeatAt, now)));
489
+ // Several legitimate brokers may share one machine id (VM clones and
490
+ // containers are common). When the caller retains its token, select that
491
+ // exact reusable generation before the oldest-row fallback so one broker's
492
+ // proof is never checked against a sibling row.
493
+ if (expectedTokenHash !== undefined) {
494
+ const [provenCandidate] = await db
495
+ .select()
496
+ .from(nodes)
497
+ .where(and(reusableWhere, eq(nodes.tokenHash, expectedTokenHash)))
498
+ .limit(1);
499
+ if (provenCandidate && isReusableForMachineMatch(provenCandidate)) {
500
+ return provenCandidate;
501
+ }
502
+ }
503
+ const candidates = await db
504
+ .select()
505
+ .from(nodes)
506
+ .where(reusableWhere)
445
507
  .orderBy(asc(nodes.createdAt), asc(nodes.id))
446
508
  .limit(MACHINE_MATCH_SCAN_LIMIT);
447
509
  // The SQL above is a pre-filter for `isReusableForMachineMatch`, which is the
@@ -473,8 +535,10 @@ export function requestedNodeRole(data) {
473
535
  * The machine_id step is what stops the roster refilling. A host that enrolls
474
536
  * without a persisted node_id arrives under a fresh name on every boot, and
475
537
  * each of those names used to mint a brand-new row that nothing ever reclaimed.
476
- * Falling back to the machine's existing broker turns re-enrollment into a
477
- * token rotation on the row that is already there.
538
+ * Falling back to the machine's existing broker lets an authorized holder
539
+ * rotate the row that is already there. createNodeToken separately requires
540
+ * current-node-token proof for enrollment, or authenticated-agent authorization
541
+ * for that agent's canonical direct node, before an existing row can change.
478
542
  *
479
543
  * node_id and name still win, so a caller that pins either keeps the exact
480
544
  * identity it asked for; passing node_id is the way to opt out of machine
@@ -484,17 +548,41 @@ export function requestedNodeRole(data) {
484
548
  * getBrokerNodeByMachineId — so a running broker is never adopted by another
485
549
  * caller presenting its machine_id.
486
550
  */
487
- export async function resolveNodeForEnroll(db, workspaceId, data) {
551
+ export async function resolveNodeForEnroll(db, workspaceId, data, expectedTokenHash) {
488
552
  if (data.node_id !== undefined) {
489
- return getNodeById(db, workspaceId, data.node_id);
553
+ const byId = await getNodeById(db, workspaceId, data.node_id);
554
+ if (byId)
555
+ return byId;
556
+ // A caller retrying a committed rotation may change the request body by
557
+ // mistake. Resolve the superseded proof below so that request is rejected
558
+ // against the rotated row rather than treated as a new-node enrollment.
559
+ return expectedTokenHash === undefined
560
+ ? null
561
+ : getNodeByPreviousTokenHash(db, workspaceId, expectedTokenHash);
490
562
  }
491
563
  const byName = await getNodeByName(db, workspaceId, data.name);
492
564
  if (byName)
493
565
  return byName;
494
566
  if (data.machine_id !== undefined && requestedNodeRole(data) === 'broker') {
495
- return getBrokerNodeByMachineId(db, workspaceId, data.machine_id);
567
+ const byMachine = await getBrokerNodeByMachineId(db, workspaceId, data.machine_id, expectedTokenHash);
568
+ if (byMachine)
569
+ return byMachine;
496
570
  }
497
- return null;
571
+ // Machine reuse clears provenLiveAt, so a committed keyed rotation no
572
+ // longer qualifies for the normal reusable-row lookup. Falling back to the
573
+ // bounded superseded proof makes an exact retry recoverable and a changed
574
+ // body/key fail closed instead of inserting a second node.
575
+ return expectedTokenHash === undefined
576
+ ? null
577
+ : getNodeByPreviousTokenHash(db, workspaceId, expectedTokenHash);
578
+ }
579
+ async function getNodeByPreviousTokenHash(db, workspaceId, tokenHash) {
580
+ const [node] = await db
581
+ .select()
582
+ .from(nodes)
583
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.previousTokenHash, tokenHash)))
584
+ .limit(1);
585
+ return node ?? null;
498
586
  }
499
587
  export async function registerNode(db, workspaceId, authenticatedNodeId, message, provider) {
500
588
  if (message.node_id !== authenticatedNodeId) {