@enbox/agent 0.5.16 → 0.6.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 (43) hide show
  1. package/dist/browser.mjs +11 -11
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dwn-api.js +433 -33
  4. package/dist/esm/dwn-api.js.map +1 -1
  5. package/dist/esm/dwn-encryption.js +131 -12
  6. package/dist/esm/dwn-encryption.js.map +1 -1
  7. package/dist/esm/dwn-key-delivery.js +64 -47
  8. package/dist/esm/dwn-key-delivery.js.map +1 -1
  9. package/dist/esm/enbox-connect-protocol.js +400 -3
  10. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  11. package/dist/esm/permissions-api.js +11 -1
  12. package/dist/esm/permissions-api.js.map +1 -1
  13. package/dist/esm/sync-engine-level.js +407 -6
  14. package/dist/esm/sync-engine-level.js.map +1 -1
  15. package/dist/esm/sync-messages.js +10 -3
  16. package/dist/esm/sync-messages.js.map +1 -1
  17. package/dist/types/dwn-api.d.ts +159 -0
  18. package/dist/types/dwn-api.d.ts.map +1 -1
  19. package/dist/types/dwn-encryption.d.ts +39 -2
  20. package/dist/types/dwn-encryption.d.ts.map +1 -1
  21. package/dist/types/dwn-key-delivery.d.ts +1 -9
  22. package/dist/types/dwn-key-delivery.d.ts.map +1 -1
  23. package/dist/types/enbox-connect-protocol.d.ts +166 -1
  24. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  25. package/dist/types/permissions-api.d.ts.map +1 -1
  26. package/dist/types/sync-engine-level.d.ts +45 -1
  27. package/dist/types/sync-engine-level.d.ts.map +1 -1
  28. package/dist/types/sync-messages.d.ts +2 -2
  29. package/dist/types/sync-messages.d.ts.map +1 -1
  30. package/dist/types/types/permissions.d.ts +9 -0
  31. package/dist/types/types/permissions.d.ts.map +1 -1
  32. package/dist/types/types/sync.d.ts +70 -2
  33. package/dist/types/types/sync.d.ts.map +1 -1
  34. package/package.json +5 -4
  35. package/src/dwn-api.ts +494 -38
  36. package/src/dwn-encryption.ts +160 -11
  37. package/src/dwn-key-delivery.ts +73 -61
  38. package/src/enbox-connect-protocol.ts +575 -6
  39. package/src/permissions-api.ts +13 -1
  40. package/src/sync-engine-level.ts +368 -4
  41. package/src/sync-messages.ts +14 -5
  42. package/src/types/permissions.ts +9 -0
  43. package/src/types/sync.ts +86 -2
@@ -244,9 +244,16 @@ export class AgentPermissionsApi implements PermissionsApi {
244
244
  requestId : createGrantParams.requestId,
245
245
  description : createGrantParams.description,
246
246
  delegated,
247
- scope : createGrantParams.scope
247
+ scope : createGrantParams.scope,
248
248
  };
249
249
 
250
+ // Attach delegate key-delivery metadata to the grant data payload.
251
+ // This is stored in the grant's encoded data (not tags) to avoid
252
+ // SQL column size limits on tag values.
253
+ if (createGrantParams.delegateKeyDelivery) {
254
+ permissionGrantData.delegateKeyDelivery = createGrantParams.delegateKeyDelivery;
255
+ }
256
+
250
257
  const permissionsGrantBytes = Convert.object(permissionGrantData).toUint8Array();
251
258
 
252
259
  const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = {
@@ -346,12 +353,17 @@ export class AgentPermissionsApi implements PermissionsApi {
346
353
  tags
347
354
  };
348
355
 
356
+ if (params.permissionGrantId) {
357
+ messageParams.permissionGrantId = params.permissionGrantId;
358
+ }
359
+
349
360
  const { reply, message } = await this.agent.processDwnRequest({
350
361
  store,
351
362
  author,
352
363
  target : author,
353
364
  messageType : DwnInterface.RecordsWrite,
354
365
  messageParams,
366
+ granteeDid : params.granteeDid,
355
367
  dataStream : new Blob([ permissionRevocationBytes as BlobPart ])
356
368
  });
357
369
 
@@ -10,8 +10,8 @@ import { Encoder, hashToHex, initDefaultHashes, Message } from '@enbox/dwn-sdk-j
10
10
 
11
11
  import type { ClosureEvaluationContext } from './sync-closure-types.js';
12
12
  import type { PermissionsApi } from './types/permissions.js';
13
+ import type { DeadLetterCategory, DeadLetterEntry, PushResult, ReplicationLinkState, StartSyncParams, SyncConnectivityState, SyncEngine, SyncEvent, SyncEventListener, SyncHealthSummary, SyncIdentityOptions, SyncMode, SyncScope } from './types/sync.js';
13
14
  import type { EnboxAgent, EnboxPlatformAgent } from './types/agent.js';
14
- import type { PushResult, ReplicationLinkState, StartSyncParams, SyncConnectivityState, SyncEngine, SyncEvent, SyncEventListener, SyncIdentityOptions, SyncMode, SyncScope } from './types/sync.js';
15
15
 
16
16
  import { evaluateClosure } from './sync-closure-resolver.js';
17
17
  import { MAX_PENDING_TOKENS } from './types/sync.js';
@@ -268,6 +268,14 @@ export class SyncEngineLevel implements SyncEngine {
268
268
  /** Backoff multiplier for consecutive failures (caps at 4x the configured interval). */
269
269
  private static readonly MAX_BACKOFF_MULTIPLIER = 4;
270
270
 
271
+ /**
272
+ * Bound browser event handlers so they can be added and removed.
273
+ * Set in `startBrowserConnectivityListeners`, cleared in `stopBrowserConnectivityListeners`.
274
+ */
275
+ private _onOnline?: () => void;
276
+ private _onOffline?: () => void;
277
+ private _onVisibilityChange?: () => void;
278
+
271
279
  constructor({ agent, dataPath, db }: SyncEngineLevelParams) {
272
280
  this._agent = agent;
273
281
  this._permissionsApi = new AgentPermissionsApi({ agent: agent as EnboxAgent });
@@ -282,6 +290,11 @@ export class SyncEngineLevel implements SyncEngine {
282
290
  return this._ledger;
283
291
  }
284
292
 
293
+ /** LevelDB sublevel for permanently failed messages (dead letters). */
294
+ private get _deadLetters(): AbstractLevel<string | Buffer | Uint8Array, string, string> {
295
+ return this._db.sublevel('deadLetters') as unknown as AbstractLevel<string | Buffer | Uint8Array, string, string>;
296
+ }
297
+
285
298
  /**
286
299
  * Retrieves the `EnboxPlatformAgent` execution context.
287
300
  *
@@ -584,6 +597,10 @@ export class SyncEngineLevel implements SyncEngine {
584
597
  * 4. Schedules an infrequent SMT integrity check at `interval`.
585
598
  */
586
599
  private async startLiveSync(intervalMilliseconds: number): Promise<void> {
600
+ // Step 0: Register browser connectivity listeners for instant recovery
601
+ // on network switch, sleep/wake, or tab foregrounding. No-op in Node.
602
+ this.startBrowserConnectivityListeners();
603
+
587
604
  // Step 1: Initial SMT catch-up.
588
605
  try {
589
606
  await this.sync();
@@ -980,6 +997,12 @@ export class SyncEngineLevel implements SyncEngine {
980
997
  const prevRepairConnectivity = link.connectivity;
981
998
  link.connectivity = 'online';
982
999
  await this.ledger.setStatus(link, 'live');
1000
+
1001
+ // Auto-clear dead letters for this link — repair has verified
1002
+ // convergence via SMT reconciliation so any previously recorded
1003
+ // failures (closure, push-exhausted, pull-processing) for this
1004
+ // specific link are no longer current.
1005
+ void this.clearDeadLettersForLink(did, dwnUrl, protocol);
983
1006
  this.emitEvent({ type: 'repair:completed', tenantDid: did, remoteEndpoint: dwnUrl, protocol });
984
1007
  if (prevRepairConnectivity !== 'online') {
985
1008
  this.emitEvent({ type: 'link:connectivity-change', tenantDid: did, remoteEndpoint: dwnUrl, protocol, from: prevRepairConnectivity, to: 'online' });
@@ -1095,7 +1118,112 @@ export class SyncEngineLevel implements SyncEngine {
1095
1118
  /**
1096
1119
  * Tears down all live subscriptions and push listeners.
1097
1120
  */
1121
+ // ---------------------------------------------------------------------------
1122
+ // Browser connectivity: online/offline + visibilitychange
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ /**
1126
+ * Registers browser `online`, `offline`, and `visibilitychange` event
1127
+ * listeners to detect connectivity changes that WebSocket `close` events
1128
+ * miss (NAT timeout, network switch, sleep/wake). Safe to call in Node —
1129
+ * the guards skip registration when browser APIs are unavailable.
1130
+ */
1131
+ private startBrowserConnectivityListeners(): void {
1132
+ this.stopBrowserConnectivityListeners();
1133
+
1134
+ // Guard: only run in browser environments with the required APIs.
1135
+ if (typeof globalThis.addEventListener !== 'function') { return; }
1136
+
1137
+ const generation = this._engineGeneration;
1138
+
1139
+ this._onOnline = (): void => {
1140
+ if (this._engineGeneration !== generation) { return; }
1141
+ console.info('SyncEngineLevel: browser online — triggering immediate integrity check');
1142
+ // Don't set _connectivityState here — individual links will transition
1143
+ // to online as their WebSocket connections actually recover during the
1144
+ // sync below. The public getter uses per-link aggregation.
1145
+
1146
+ // Kick off an immediate SMT reconciliation to catch up after being offline.
1147
+ if (!this._syncLock) {
1148
+ this.sync().catch((err) => {
1149
+ console.error('SyncEngineLevel: post-online sync failed', err);
1150
+ });
1151
+ }
1152
+ };
1153
+
1154
+ this._onOffline = (): void => {
1155
+ if (this._engineGeneration !== generation) { return; }
1156
+ console.info('SyncEngineLevel: browser offline');
1157
+ this._connectivityState = 'offline';
1158
+
1159
+ // Transition every active link to offline so the public
1160
+ // connectivityState getter (which aggregates per-link state)
1161
+ // reflects the browser's network status immediately.
1162
+ for (const link of this._activeLinks.values()) {
1163
+ const prev = link.connectivity;
1164
+ if (prev !== 'offline') {
1165
+ link.connectivity = 'offline';
1166
+ this.emitEvent({
1167
+ type : 'link:connectivity-change',
1168
+ tenantDid : link.tenantDid,
1169
+ remoteEndpoint : link.remoteEndpoint,
1170
+ protocol : link.protocol,
1171
+ from : prev,
1172
+ to : 'offline',
1173
+ });
1174
+ }
1175
+ }
1176
+ };
1177
+
1178
+ this._onVisibilityChange = (): void => {
1179
+ if (this._engineGeneration !== generation) { return; }
1180
+
1181
+ // Only act when the page becomes visible again — the user is back.
1182
+ if (typeof document === 'undefined' || document.visibilityState !== 'visible') { return; }
1183
+
1184
+ console.info('SyncEngineLevel: page became visible — triggering integrity check');
1185
+
1186
+ // The device may have slept and WebSockets may be dead. An immediate
1187
+ // sync via SMT reconciliation detects and repairs any divergence.
1188
+ if (!this._syncLock) {
1189
+ this.sync().catch((err) => {
1190
+ console.error('SyncEngineLevel: post-visibility sync failed', err);
1191
+ });
1192
+ }
1193
+ };
1194
+
1195
+ globalThis.addEventListener('online', this._onOnline);
1196
+ globalThis.addEventListener('offline', this._onOffline);
1197
+
1198
+ if (typeof document !== 'undefined') {
1199
+ document.addEventListener('visibilitychange', this._onVisibilityChange);
1200
+ }
1201
+ }
1202
+
1203
+ /** Removes browser connectivity listeners if they were registered. */
1204
+ private stopBrowserConnectivityListeners(): void {
1205
+ if (this._onOnline) {
1206
+ globalThis.removeEventListener('online', this._onOnline);
1207
+ this._onOnline = undefined;
1208
+ }
1209
+ if (this._onOffline) {
1210
+ globalThis.removeEventListener('offline', this._onOffline);
1211
+ this._onOffline = undefined;
1212
+ }
1213
+ if (this._onVisibilityChange && typeof document !== 'undefined') {
1214
+ document.removeEventListener('visibilitychange', this._onVisibilityChange);
1215
+ this._onVisibilityChange = undefined;
1216
+ }
1217
+ }
1218
+
1219
+ // ---------------------------------------------------------------------------
1220
+ // Teardown
1221
+ // ---------------------------------------------------------------------------
1222
+
1098
1223
  private async teardownLiveSync(): Promise<void> {
1224
+ // Remove browser connectivity listeners before tearing down.
1225
+ this.stopBrowserConnectivityListeners();
1226
+
1099
1227
  // Increment generation to invalidate all in-flight async operations
1100
1228
  // (repairs, retry timers, degraded-poll ticks). Any async work that
1101
1229
  // captured the previous generation will bail on its next checkpoint.
@@ -1357,10 +1485,25 @@ export class SyncEngineLevel implements SyncEngine {
1357
1485
  );
1358
1486
 
1359
1487
  if (!closureResult.complete) {
1488
+ const failureCode = closureResult.failure!.code;
1489
+ const failureDetail = closureResult.failure!.detail;
1360
1490
  console.warn(
1361
1491
  `SyncEngineLevel: Closure incomplete for ${did} -> ${dwnUrl}: ` +
1362
- `${closureResult.failure!.code} — ${closureResult.failure!.detail}`
1492
+ `${failureCode} — ${failureDetail}`
1363
1493
  );
1494
+
1495
+ // Record the message that triggered the closure failure.
1496
+ const closureCid = await Message.getCid(event.message);
1497
+ void this.recordDeadLetter({
1498
+ messageCid : closureCid,
1499
+ tenantDid : did,
1500
+ remoteEndpoint : dwnUrl,
1501
+ protocol,
1502
+ category : 'closure',
1503
+ errorCode : failureCode,
1504
+ errorDetail : failureDetail,
1505
+ });
1506
+
1364
1507
  await this.transitionToRepairing(cursorKey, link);
1365
1508
  return;
1366
1509
  }
@@ -1380,6 +1523,10 @@ export class SyncEngineLevel implements SyncEngine {
1380
1523
  this._recentlyPulledCids.set(`${pulledCid}|${dwnUrl}`, Date.now() + SyncEngineLevel.ECHO_SUPPRESS_TTL_MS);
1381
1524
  this.evictExpiredEchoEntries();
1382
1525
 
1526
+ // Auto-clear any dead letter for this CID — it was processed
1527
+ // successfully, so a previous failure has been self-healed.
1528
+ this.clearFailedMessage(pulledCid, dwnUrl).catch(() => { /* teardown race */ });
1529
+
1383
1530
  // Mark this ordinal as committed and drain the checkpoint.
1384
1531
  // Guard: if the link transitioned to repairing while this handler was
1385
1532
  // in-flight (e.g., an earlier ordinal's handler failed concurrently),
@@ -1413,6 +1560,24 @@ export class SyncEngineLevel implements SyncEngine {
1413
1560
  }
1414
1561
  } catch (error: any) {
1415
1562
  console.error(`SyncEngineLevel: Error processing live-pull event for ${did}`, error);
1563
+
1564
+ // Record the failing message in the dead letter store before
1565
+ // transitioning to repair. The CID identifies which specific
1566
+ // message caused the transition.
1567
+ try {
1568
+ const failedCid = await Message.getCid(event.message);
1569
+ void this.recordDeadLetter({
1570
+ messageCid : failedCid,
1571
+ tenantDid : did,
1572
+ remoteEndpoint : dwnUrl,
1573
+ protocol,
1574
+ category : 'pull-processing',
1575
+ errorDetail : error.message ?? String(error),
1576
+ });
1577
+ } catch {
1578
+ // Best effort — don't let dead letter recording block repair.
1579
+ }
1580
+
1416
1581
  // A failed processRawMessage means local state is incomplete.
1417
1582
  // Transition to repairing immediately — do NOT advance the checkpoint
1418
1583
  // past this failure or let later ordinals commit past it. SMT
@@ -1647,6 +1812,25 @@ export class SyncEngineLevel implements SyncEngine {
1647
1812
  permissionsApi : this._permissionsApi,
1648
1813
  });
1649
1814
 
1815
+ // Auto-clear dead letters for CIDs that succeeded — a previously
1816
+ // failed message may have been repaired by reconciliation.
1817
+ for (const cid of result.succeeded) {
1818
+ this.clearFailedMessage(cid, dwnUrl).catch(() => { /* teardown race */ });
1819
+ }
1820
+
1821
+ // Record permanently failed messages in the dead letter store.
1822
+ for (const entry of result.permanentlyFailed) {
1823
+ await this.recordDeadLetter({
1824
+ messageCid : entry.cid,
1825
+ tenantDid : did,
1826
+ remoteEndpoint : dwnUrl,
1827
+ protocol,
1828
+ category : 'push-permanent',
1829
+ errorCode : String(entry.statusCode ?? ''),
1830
+ errorDetail : entry.detail ?? 'permanent push failure',
1831
+ });
1832
+ }
1833
+
1650
1834
  if (result.failed.length > 0) {
1651
1835
  const failedSet = new Set(result.failed);
1652
1836
  const failedEntries = pushEntries.filter((entry) => failedSet.has(entry.cid));
@@ -1703,7 +1887,18 @@ export class SyncEngineLevel implements SyncEngine {
1703
1887
  const pushRuntime = this.getOrCreatePushRuntime(targetKey, pending);
1704
1888
 
1705
1889
  if (pending.retryCount >= maxRetries) {
1706
- // Retry budget exhausted — mark link dirty for reconciliation.
1890
+ // Retry budget exhausted — record each CID as a dead letter and mark
1891
+ // the link dirty for reconciliation.
1892
+ for (const entry of pending.entries) {
1893
+ void this.recordDeadLetter({
1894
+ messageCid : entry.cid,
1895
+ tenantDid : pending.did,
1896
+ remoteEndpoint : pending.dwnUrl,
1897
+ protocol : pending.protocol,
1898
+ category : 'push-exhausted',
1899
+ errorDetail : `push retries exhausted after ${maxRetries} attempts`,
1900
+ });
1901
+ }
1707
1902
  if (pushRuntime.timer) {
1708
1903
  clearTimeout(pushRuntime.timer);
1709
1904
  }
@@ -1815,6 +2010,9 @@ export class SyncEngineLevel implements SyncEngine {
1815
2010
 
1816
2011
  if (reconcileOutcome.converged) {
1817
2012
  await this.ledger.clearNeedsReconcile(link);
2013
+ // SMT roots match — this link is converged. Clear dead letters
2014
+ // scoped to this specific link (tenantDid, remoteEndpoint, protocol).
2015
+ void this.clearDeadLettersForLink(did, dwnUrl, protocol);
1818
2016
  this.emitEvent({ type: 'reconcile:completed', tenantDid: did, remoteEndpoint: dwnUrl, protocol });
1819
2017
  } else {
1820
2018
  // Roots still differ — retry after a delay. This can happen when
@@ -2282,11 +2480,23 @@ export class SyncEngineLevel implements SyncEngine {
2282
2480
  messageCids: string[];
2283
2481
  prefetched?: MessagesSyncDiffEntry[];
2284
2482
  }): Promise<void> {
2285
- return pullMessages({
2483
+ const failedCids = await pullMessages({
2286
2484
  did, dwnUrl, delegateDid, protocol, messageCids, prefetched,
2287
2485
  agent : this.agent,
2288
2486
  permissionsApi : this._permissionsApi,
2289
2487
  });
2488
+
2489
+ // Record permanently failed pull entries in the dead letter store.
2490
+ for (const cid of failedCids) {
2491
+ await this.recordDeadLetter({
2492
+ messageCid : cid,
2493
+ tenantDid : did,
2494
+ remoteEndpoint : dwnUrl,
2495
+ protocol,
2496
+ category : 'pull-processing',
2497
+ errorDetail : 'pull processing failed after retry passes exhausted',
2498
+ });
2499
+ }
2290
2500
  }
2291
2501
 
2292
2502
  // ---------------------------------------------------------------------------
@@ -2367,6 +2577,160 @@ export class SyncEngineLevel implements SyncEngine {
2367
2577
  return topologicalSort(messages);
2368
2578
  }
2369
2579
 
2580
+ // ---------------------------------------------------------------------------
2581
+ // Dead letter tracking
2582
+ // ---------------------------------------------------------------------------
2583
+
2584
+ /**
2585
+ * Clear dead letter entries scoped to a specific sync link. Matches on
2586
+ * (tenantDid, remoteEndpoint, protocol) so that repairing protocol A
2587
+ * does not erase still-valid failures for protocol B on the same remote.
2588
+ * When `protocol` is undefined (full-tenant link), clears entries that
2589
+ * also have no protocol.
2590
+ */
2591
+ private async clearDeadLettersForLink(tenantDid: string, remoteEndpoint: string, protocol?: string): Promise<void> {
2592
+ const batch: { type: 'del'; key: string }[] = [];
2593
+ try {
2594
+ for await (const [key, value] of this._deadLetters.iterator()) {
2595
+ const entry = JSON.parse(value) as DeadLetterEntry;
2596
+ if (entry.tenantDid === tenantDid &&
2597
+ entry.remoteEndpoint === remoteEndpoint &&
2598
+ entry.protocol === protocol) {
2599
+ batch.push({ type: 'del', key });
2600
+ }
2601
+ }
2602
+ if (batch.length > 0) {
2603
+ await this._deadLetters.batch(batch);
2604
+ }
2605
+ } catch (error) {
2606
+ const e = error as { code?: string };
2607
+ if (e.code !== 'LEVEL_DATABASE_NOT_OPEN') { throw error; }
2608
+ }
2609
+ }
2610
+
2611
+ /**
2612
+ * Build a compound dead letter key. Different remotes can fail the same CID
2613
+ * for different reasons, so the key includes the remote endpoint.
2614
+ */
2615
+ private static deadLetterKey(messageCid: string, remoteEndpoint?: string): string {
2616
+ return remoteEndpoint ? `${messageCid}|${remoteEndpoint}` : messageCid;
2617
+ }
2618
+
2619
+ public async recordDeadLetter(params: {
2620
+ messageCid : string;
2621
+ tenantDid : string;
2622
+ remoteEndpoint? : string;
2623
+ protocol? : string;
2624
+ category : DeadLetterCategory;
2625
+ errorCode? : string;
2626
+ errorDetail : string;
2627
+ }): Promise<void> {
2628
+ const entry: DeadLetterEntry = {
2629
+ ...params,
2630
+ failedAt: new Date().toISOString(),
2631
+ };
2632
+ const key = SyncEngineLevel.deadLetterKey(params.messageCid, params.remoteEndpoint);
2633
+ try {
2634
+ await this._deadLetters.put(key, JSON.stringify(entry));
2635
+ } catch (error) {
2636
+ // Suppress only the expected teardown race — any other error surfaces.
2637
+ const e = error as { code?: string };
2638
+ if (e.code !== 'LEVEL_DATABASE_NOT_OPEN') {
2639
+ throw error;
2640
+ }
2641
+ }
2642
+ }
2643
+
2644
+ public async getFailedMessages(tenantDid?: string): Promise<DeadLetterEntry[]> {
2645
+ const entries: DeadLetterEntry[] = [];
2646
+ for await (const [, value] of this._deadLetters.iterator()) {
2647
+ const entry = JSON.parse(value) as DeadLetterEntry;
2648
+ if (!tenantDid || entry.tenantDid === tenantDid) {
2649
+ entries.push(entry);
2650
+ }
2651
+ }
2652
+ // Deterministic ordering: newest first so apps see the most recent failures.
2653
+ entries.sort((a, b) => b.failedAt.localeCompare(a.failedAt));
2654
+ return entries;
2655
+ }
2656
+
2657
+ public async clearFailedMessage(messageCid: string, remoteEndpoint?: string): Promise<boolean> {
2658
+ if (remoteEndpoint) {
2659
+ // Clear a specific CID + remote pair.
2660
+ const key = SyncEngineLevel.deadLetterKey(messageCid, remoteEndpoint);
2661
+ try {
2662
+ await this._deadLetters.get(key);
2663
+ await this._deadLetters.del(key);
2664
+ return true;
2665
+ } catch (error) {
2666
+ const e = error as { code?: string };
2667
+ if (e.code === 'LEVEL_NOT_FOUND') { return false; }
2668
+ throw error;
2669
+ }
2670
+ }
2671
+
2672
+ // No remote specified — clear ALL entries for this CID (any remote).
2673
+ let found = false;
2674
+ const batch: { type: 'del'; key: string }[] = [];
2675
+ for await (const [key, value] of this._deadLetters.iterator()) {
2676
+ const entry = JSON.parse(value) as DeadLetterEntry;
2677
+ if (entry.messageCid === messageCid) {
2678
+ batch.push({ type: 'del', key });
2679
+ found = true;
2680
+ }
2681
+ }
2682
+ if (batch.length > 0) {
2683
+ await this._deadLetters.batch(batch);
2684
+ }
2685
+ return found;
2686
+ }
2687
+
2688
+ public async clearAllFailedMessages(tenantDid?: string): Promise<void> {
2689
+ if (!tenantDid) {
2690
+ await this._deadLetters.clear();
2691
+ return;
2692
+ }
2693
+
2694
+ const batch: { type: 'del'; key: string }[] = [];
2695
+ for await (const [key, value] of this._deadLetters.iterator()) {
2696
+ const entry = JSON.parse(value) as DeadLetterEntry;
2697
+ if (entry.tenantDid === tenantDid) {
2698
+ batch.push({ type: 'del', key });
2699
+ }
2700
+ }
2701
+ if (batch.length > 0) {
2702
+ await this._deadLetters.batch(batch);
2703
+ }
2704
+ }
2705
+
2706
+ public async getSyncHealth(): Promise<SyncHealthSummary> {
2707
+ let failedMessageCount = 0;
2708
+ for await (const _ of this._deadLetters.iterator()) {
2709
+ failedMessageCount++;
2710
+ }
2711
+
2712
+ // Count degraded links from the durable ledger, not just in-memory
2713
+ // _activeLinks. Links persist across restarts; a repairing/degraded_poll
2714
+ // link from a previous session must still be reported.
2715
+ let degradedLinkCount = 0;
2716
+ const allLinks = await this.ledger.getAllLinks();
2717
+ for (const link of allLinks) {
2718
+ if (link.status === 'repairing' || link.status === 'degraded_poll') {
2719
+ degradedLinkCount++;
2720
+ }
2721
+ }
2722
+
2723
+ return {
2724
+ connectivity: this.connectivityState,
2725
+ failedMessageCount,
2726
+ degradedLinkCount,
2727
+ };
2728
+ }
2729
+
2730
+ // ---------------------------------------------------------------------------
2731
+ // Sync targets
2732
+ // ---------------------------------------------------------------------------
2733
+
2370
2734
  /**
2371
2735
  * Returns the list of sync targets: (did, dwnUrl, delegateDid?, protocol?) tuples.
2372
2736
  */
@@ -1,7 +1,8 @@
1
+ import type { GenericMessage, MessagesReadReply, MessagesSyncDiffEntry, UnionMessageReply } from '@enbox/dwn-sdk-js';
2
+
1
3
  import type { EnboxPlatformAgent } from './types/agent.js';
2
4
  import type { PermissionsApi } from './types/permissions.js';
3
- import type { PushResult } from './types/sync.js';
4
- import type { GenericMessage, MessagesReadReply, MessagesSyncDiffEntry, UnionMessageReply } from '@enbox/dwn-sdk-js';
5
+ import type { PermanentPushFailure, PushResult } from './types/sync.js';
5
6
 
6
7
  import { DwnInterfaceName, DwnMethodName, Encoder, Message } from '@enbox/dwn-sdk-js';
7
8
 
@@ -81,7 +82,7 @@ export async function pullMessages({ did, dwnUrl, delegateDid, protocol, message
81
82
  prefetched?: MessagesSyncDiffEntry[];
82
83
  agent: EnboxPlatformAgent;
83
84
  permissionsApi: PermissionsApi;
84
- }): Promise<void> {
85
+ }): Promise<string[]> {
85
86
  // Convert prefetched diff entries into SyncMessageEntry format.
86
87
  const prefetchedEntries: SyncMessageEntry[] = [];
87
88
  if (prefetched) {
@@ -165,6 +166,14 @@ export async function pullMessages({ did, dwnUrl, delegateDid, protocol, message
165
166
  pending = [];
166
167
  }
167
168
  }
169
+
170
+ // Return CIDs that permanently failed after all retry passes.
171
+ const permanentlyFailed: string[] = [];
172
+ for (const entry of pending) {
173
+ const cid = await getMessageCid(entry.message);
174
+ permanentlyFailed.push(cid);
175
+ }
176
+ return permanentlyFailed;
168
177
  }
169
178
 
170
179
  /**
@@ -327,7 +336,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
327
336
  }): Promise<PushResult> {
328
337
  const succeeded: string[] = [];
329
338
  const failed: string[] = [];
330
- const permanentlyFailed: string[] = [];
339
+ const permanentlyFailed: PermanentPushFailure[] = [];
331
340
 
332
341
  // Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
333
342
  const fetched: SyncMessageEntry[] = [];
@@ -369,7 +378,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
369
378
  } else {
370
379
  console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
371
380
  }
372
- permanentlyFailed.push(cid);
381
+ permanentlyFailed.push({ cid, statusCode: reply.status.code, detail: reply.status.detail ?? '' });
373
382
  } else {
374
383
  // Transient failures (5xx, etc.) — worth retrying.
375
384
  console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
@@ -48,6 +48,11 @@ export type CreateGrantParams = {
48
48
  grantedTo: string;
49
49
  scope: DwnPermissionScope;
50
50
  delegated?: boolean;
51
+ /** Delegate key-delivery metadata for cross-device context key delivery. */
52
+ delegateKeyDelivery?: {
53
+ rootKeyId: string;
54
+ publicKeyJwk: Record<string, any>;
55
+ };
51
56
  };
52
57
 
53
58
  export type CreateRequestParams = {
@@ -63,6 +68,10 @@ export type CreateRevocationParams = {
63
68
  author: string;
64
69
  grant: DwnPermissionGrant;
65
70
  description?: string;
71
+ /** For delegated revocation: the delegate DID that signs the revocation. */
72
+ granteeDid?: string;
73
+ /** For delegated revocation: the grant ID that authorizes the revocation write. */
74
+ permissionGrantId?: string;
66
75
  };
67
76
 
68
77
  export type GetPermissionParams = {