@enbox/agent 0.7.7 → 0.7.8

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 (77) hide show
  1. package/dist/browser.mjs +9 -9
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dwn-api.js +3 -2
  4. package/dist/esm/dwn-api.js.map +1 -1
  5. package/dist/esm/enbox-connect-protocol.js +5 -5
  6. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  7. package/dist/esm/index.js +1 -1
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/permissions-api.js +7 -34
  10. package/dist/esm/permissions-api.js.map +1 -1
  11. package/dist/esm/sync-closure-resolver.js +229 -110
  12. package/dist/esm/sync-closure-resolver.js.map +1 -1
  13. package/dist/esm/sync-closure-types.js +24 -7
  14. package/dist/esm/sync-closure-types.js.map +1 -1
  15. package/dist/esm/sync-engine-level.js +1961 -764
  16. package/dist/esm/sync-engine-level.js.map +1 -1
  17. package/dist/esm/sync-link-id.js +4 -13
  18. package/dist/esm/sync-link-id.js.map +1 -1
  19. package/dist/esm/sync-link-reconciler.js +26 -8
  20. package/dist/esm/sync-link-reconciler.js.map +1 -1
  21. package/dist/esm/sync-messages.js +218 -154
  22. package/dist/esm/sync-messages.js.map +1 -1
  23. package/dist/esm/sync-permission-grants.js +208 -0
  24. package/dist/esm/sync-permission-grants.js.map +1 -0
  25. package/dist/esm/sync-replication-ledger.js +23 -40
  26. package/dist/esm/sync-replication-ledger.js.map +1 -1
  27. package/dist/esm/sync-scope-acceptance.js +126 -0
  28. package/dist/esm/sync-scope-acceptance.js.map +1 -0
  29. package/dist/esm/sync-topological-sort.js +57 -15
  30. package/dist/esm/sync-topological-sort.js.map +1 -1
  31. package/dist/esm/types/sync.js +130 -22
  32. package/dist/esm/types/sync.js.map +1 -1
  33. package/dist/types/dwn-api.d.ts.map +1 -1
  34. package/dist/types/index.d.ts +1 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/permissions-api.d.ts +1 -2
  37. package/dist/types/permissions-api.d.ts.map +1 -1
  38. package/dist/types/sync-closure-resolver.d.ts.map +1 -1
  39. package/dist/types/sync-closure-types.d.ts +14 -3
  40. package/dist/types/sync-closure-types.d.ts.map +1 -1
  41. package/dist/types/sync-engine-level.d.ts +127 -25
  42. package/dist/types/sync-engine-level.d.ts.map +1 -1
  43. package/dist/types/sync-link-id.d.ts +3 -9
  44. package/dist/types/sync-link-id.d.ts.map +1 -1
  45. package/dist/types/sync-link-reconciler.d.ts +12 -2
  46. package/dist/types/sync-link-reconciler.d.ts.map +1 -1
  47. package/dist/types/sync-messages.d.ts +16 -13
  48. package/dist/types/sync-messages.d.ts.map +1 -1
  49. package/dist/types/sync-permission-grants.d.ts +52 -0
  50. package/dist/types/sync-permission-grants.d.ts.map +1 -0
  51. package/dist/types/sync-replication-ledger.d.ts +5 -13
  52. package/dist/types/sync-replication-ledger.d.ts.map +1 -1
  53. package/dist/types/sync-scope-acceptance.d.ts +28 -0
  54. package/dist/types/sync-scope-acceptance.d.ts.map +1 -0
  55. package/dist/types/sync-topological-sort.d.ts +2 -1
  56. package/dist/types/sync-topological-sort.d.ts.map +1 -1
  57. package/dist/types/types/permissions.d.ts +2 -0
  58. package/dist/types/types/permissions.d.ts.map +1 -1
  59. package/dist/types/types/sync.d.ts +137 -75
  60. package/dist/types/types/sync.d.ts.map +1 -1
  61. package/package.json +3 -3
  62. package/src/dwn-api.ts +3 -2
  63. package/src/enbox-connect-protocol.ts +5 -5
  64. package/src/index.ts +10 -1
  65. package/src/permissions-api.ts +11 -42
  66. package/src/sync-closure-resolver.ts +306 -126
  67. package/src/sync-closure-types.ts +38 -9
  68. package/src/sync-engine-level.ts +2560 -797
  69. package/src/sync-link-id.ts +9 -14
  70. package/src/sync-link-reconciler.ts +43 -10
  71. package/src/sync-messages.ts +263 -159
  72. package/src/sync-permission-grants.ts +297 -0
  73. package/src/sync-replication-ledger.ts +55 -50
  74. package/src/sync-scope-acceptance.ts +186 -0
  75. package/src/sync-topological-sort.ts +89 -21
  76. package/src/types/permissions.ts +2 -0
  77. package/src/types/sync.ts +235 -62
@@ -0,0 +1,297 @@
1
+ import type { DwnInterface } from './types/dwn.js';
2
+ import type { GenericMessage, GenericSignaturePayload, MessagesPermissionScope, RecordsProjectionScope } from '@enbox/dwn-sdk-js';
3
+ import type { NonEmptyStringArray, SyncAuthorizationGrant, SyncScope } from './types/sync.js';
4
+ import type { PermissionGrantEntry, PermissionsApi } from './types/permissions.js';
5
+
6
+ import { Jws, Message, PermissionScopeMatcher } from '@enbox/dwn-sdk-js';
7
+
8
+ import { lexicographicalCompare, syncScopeFromRecordsProjectionScopes } from './types/sync.js';
9
+
10
+ export type MessagesSyncScopeResolution = {
11
+ scope: SyncScope;
12
+ permissionGrants: PermissionGrantEntry[];
13
+ };
14
+
15
+ /** Returns a sorted, duplicate-free grant ID set, or `undefined` for owner requests. */
16
+ export function toMessagesPermissionGrantIds(permissionGrantIds: string[] | undefined): NonEmptyStringArray | undefined {
17
+ if (permissionGrantIds === undefined || permissionGrantIds.length === 0) {
18
+ return undefined;
19
+ }
20
+ return [...new Set(permissionGrantIds)].sort(lexicographicalCompare) as NonEmptyStringArray;
21
+ }
22
+
23
+ /**
24
+ * Gets the active permission grant IDs that authorize a Messages operation.
25
+ *
26
+ * Owner-authored sync does not invoke grants. Delegate full sync requires at
27
+ * least one active unscoped Messages.Read grant. Delegate protocol-set sync
28
+ * requires each requested protocol to be covered by an active Messages.Read
29
+ * grant, then invokes every active grant that participates in the projection.
30
+ * This keeps the authorization epoch tied to grant churn without widening the
31
+ * CID projection being compared.
32
+ */
33
+ export async function getMessagesPermissionGrantsForScope({
34
+ did,
35
+ delegateDid,
36
+ protocols,
37
+ messageType,
38
+ permissionsApi,
39
+ }: {
40
+ did: string;
41
+ delegateDid?: string;
42
+ protocols?: NonEmptyStringArray;
43
+ messageType: DwnInterface;
44
+ permissionsApi: PermissionsApi;
45
+ }): Promise<PermissionGrantEntry[]> {
46
+ const requestedScope: SyncScope = protocols === undefined
47
+ ? { kind: 'full' }
48
+ : { kind: 'protocolSet', protocols };
49
+ const resolutions = await resolveMessagesSyncScopes({
50
+ did,
51
+ delegateDid,
52
+ requestedScope,
53
+ messageType,
54
+ permissionsApi,
55
+ });
56
+ return resolutions
57
+ .flatMap(resolution => resolution.permissionGrants)
58
+ .sort((a, b) => lexicographicalCompare(a.grant.id, b.grant.id));
59
+ }
60
+
61
+ /**
62
+ * Resolves active Messages.Read grants into one or more sync targets.
63
+ *
64
+ * Broad protocol coverage remains on StateIndex full/protocol roots. Exact
65
+ * protocolPath and contextId grants are grouped into a Records-primary
66
+ * projection target so a narrow grant never authorizes a broad protocol root.
67
+ */
68
+ export async function resolveMessagesSyncScopes({
69
+ did,
70
+ delegateDid,
71
+ requestedScope,
72
+ messageType,
73
+ permissionsApi,
74
+ }: {
75
+ did: string;
76
+ delegateDid?: string;
77
+ requestedScope: SyncScope;
78
+ messageType: DwnInterface;
79
+ permissionsApi: PermissionsApi;
80
+ }): Promise<MessagesSyncScopeResolution[]> {
81
+ if (!delegateDid) {
82
+ return [{ scope: requestedScope, permissionGrants: [] }];
83
+ }
84
+
85
+ const now = new Date().toISOString();
86
+ const permissionGrants = (await permissionsApi.fetchGrants({
87
+ author : delegateDid,
88
+ target : delegateDid,
89
+ grantor : did,
90
+ grantee : delegateDid,
91
+ })).filter(entry => isActiveMessagesGrant(entry, did, delegateDid, now));
92
+
93
+ if (requestedScope.kind === 'full') {
94
+ return [resolveFullScope(permissionGrants, requestedScope, messageType)];
95
+ }
96
+
97
+ if (requestedScope.kind === 'protocolSet') {
98
+ return resolveProtocolSetScope(permissionGrants, requestedScope, messageType);
99
+ }
100
+
101
+ return [resolveRecordsProjectionScope(permissionGrants, requestedScope, messageType)];
102
+ }
103
+
104
+ function resolveFullScope(
105
+ permissionGrants: PermissionGrantEntry[],
106
+ requestedScope: Extract<SyncScope, { kind: 'full' }>,
107
+ messageType: DwnInterface,
108
+ ): MessagesSyncScopeResolution {
109
+ const grants = permissionGrants
110
+ .filter(entry => grantMatchesProtocol(entry, undefined))
111
+ .sort((a, b) => lexicographicalCompare(a.grant.id, b.grant.id));
112
+ if (grants.length === 0) {
113
+ throw new Error(`SyncPermissions: No active Messages.Read permission found for ${messageType}: all protocols`);
114
+ }
115
+
116
+ return { scope: requestedScope, permissionGrants: grants };
117
+ }
118
+
119
+ function resolveProtocolSetScope(
120
+ permissionGrants: PermissionGrantEntry[],
121
+ requestedScope: Extract<SyncScope, { kind: 'protocolSet' }>,
122
+ messageType: DwnInterface,
123
+ ): MessagesSyncScopeResolution[] {
124
+ const broadProtocols: string[] = [];
125
+ const narrowScopes: RecordsProjectionScope[] = [];
126
+
127
+ for (const protocol of requestedScope.protocols) {
128
+ if (permissionGrants.some(entry => grantMatchesProtocol(entry, protocol))) {
129
+ broadProtocols.push(protocol);
130
+ continue;
131
+ }
132
+
133
+ const protocolNarrowScopes = permissionGrants
134
+ .map(entry => grantProjectionScopeForProtocol(entry, protocol))
135
+ .filter((scope): scope is RecordsProjectionScope => scope !== undefined);
136
+ if (protocolNarrowScopes.length === 0) {
137
+ throw new Error(`SyncPermissions: No active Messages.Read permission found for ${messageType}: ${protocol}`);
138
+ }
139
+ narrowScopes.push(...protocolNarrowScopes);
140
+ }
141
+
142
+ return [
143
+ ...broadProtocolResolution(permissionGrants, broadProtocols),
144
+ ...recordsProjectionResolution(permissionGrants, narrowScopes, messageType),
145
+ ];
146
+ }
147
+
148
+ function resolveRecordsProjectionScope(
149
+ permissionGrants: PermissionGrantEntry[],
150
+ requestedScope: Extract<SyncScope, { kind: 'recordsProjection' }>,
151
+ messageType: DwnInterface,
152
+ ): MessagesSyncScopeResolution {
153
+ if (!requestedScope.scopes.every(scope => isProjectionScopeCovered(permissionGrants, scope))) {
154
+ throw new Error(`SyncPermissions: No active Messages.Read permission found for ${messageType}: projected Records scope`);
155
+ }
156
+
157
+ const grants = permissionGrants
158
+ .filter(entry => requestedScope.scopes.some(scope => PermissionScopeMatcher.matches(entry.grant.scope, scope)))
159
+ .sort((a, b) => lexicographicalCompare(a.grant.id, b.grant.id));
160
+
161
+ return { scope: requestedScope, permissionGrants: grants };
162
+ }
163
+
164
+ function isProjectionScopeCovered(
165
+ permissionGrants: PermissionGrantEntry[],
166
+ scope: RecordsProjectionScope,
167
+ ): boolean {
168
+ return permissionGrants.some(entry => PermissionScopeMatcher.matches(entry.grant.scope, scope));
169
+ }
170
+
171
+ function broadProtocolResolution(
172
+ permissionGrants: PermissionGrantEntry[],
173
+ broadProtocols: string[],
174
+ ): MessagesSyncScopeResolution[] {
175
+ if (broadProtocols.length === 0) {
176
+ return [];
177
+ }
178
+
179
+ const protocols = [...new Set(broadProtocols)].sort(lexicographicalCompare) as NonEmptyStringArray;
180
+ const grants = permissionGrants
181
+ .filter(entry => grantParticipatesInProtocolSet(entry, protocols))
182
+ .sort((a, b) => lexicographicalCompare(a.grant.id, b.grant.id));
183
+
184
+ return [{
185
+ scope: {
186
+ kind: 'protocolSet',
187
+ protocols,
188
+ },
189
+ permissionGrants: grants,
190
+ }];
191
+ }
192
+
193
+ function recordsProjectionResolution(
194
+ permissionGrants: PermissionGrantEntry[],
195
+ projectionScopes: RecordsProjectionScope[],
196
+ messageType: DwnInterface,
197
+ ): MessagesSyncScopeResolution[] {
198
+ const [firstScope, ...remainingScopes] = projectionScopes;
199
+ if (firstScope === undefined) {
200
+ return [];
201
+ }
202
+
203
+ const requestedScope = syncScopeFromRecordsProjectionScopes([firstScope, ...remainingScopes]);
204
+ return [resolveRecordsProjectionScope(permissionGrants, requestedScope, messageType)];
205
+ }
206
+
207
+ function grantProjectionScopeForProtocol(
208
+ entry: PermissionGrantEntry,
209
+ protocol: string,
210
+ ): RecordsProjectionScope | undefined {
211
+ const scope = entry.grant.scope;
212
+ if (!isMessagesReadScope(scope) || scope.protocol !== protocol) {
213
+ return undefined;
214
+ }
215
+ if (scope.protocolPath !== undefined) {
216
+ return { protocol, protocolPath: scope.protocolPath };
217
+ }
218
+ if (scope.contextId !== undefined) {
219
+ return { protocol, contextId: scope.contextId };
220
+ }
221
+ }
222
+
223
+ function isMessagesReadScope(scope: PermissionGrantEntry['grant']['scope']): scope is MessagesPermissionScope {
224
+ return scope.interface === 'Messages' &&
225
+ scope.method === 'Read';
226
+ }
227
+
228
+ function grantParticipatesInProtocolSet(
229
+ entry: PermissionGrantEntry,
230
+ protocols: NonEmptyStringArray,
231
+ ): boolean {
232
+ return protocols.some(protocol => grantMatchesProtocol(entry, protocol));
233
+ }
234
+
235
+ /** Converts permission grant entries into authorization epoch inputs. */
236
+ export function toSyncAuthorizationGrants(permissionGrants: PermissionGrantEntry[]): [SyncAuthorizationGrant, ...SyncAuthorizationGrant[]] {
237
+ if (permissionGrants.length === 0) {
238
+ throw new Error('SyncPermissions: delegate authorization requires at least one grant.');
239
+ }
240
+ return permissionGrants
241
+ .map(({ grant }) => ({
242
+ dateExpires : grant.dateExpires,
243
+ dateGranted : grant.dateGranted,
244
+ id : grant.id,
245
+ }))
246
+ .sort((a, b) => lexicographicalCompare(a.id, b.id)) as [SyncAuthorizationGrant, ...SyncAuthorizationGrant[]];
247
+ }
248
+
249
+ function isActiveMessagesGrant(
250
+ entry: PermissionGrantEntry,
251
+ grantor: string,
252
+ grantee: string,
253
+ now: string,
254
+ ): boolean {
255
+ const { grant } = entry;
256
+ if (grant.grantor !== grantor || grant.grantee !== grantee) {
257
+ return false;
258
+ }
259
+
260
+ if (grant.dateGranted > now || grant.dateExpires <= now) {
261
+ return false;
262
+ }
263
+
264
+ const scope = grant.scope;
265
+ return scope.interface === 'Messages' &&
266
+ scope.method === 'Read';
267
+ }
268
+
269
+ function grantMatchesProtocol(
270
+ entry: PermissionGrantEntry,
271
+ protocol: string | undefined,
272
+ ): boolean {
273
+ return PermissionScopeMatcher.matches(entry.grant.scope, { protocol });
274
+ }
275
+
276
+ /** Returns sorted grant IDs from permission grant entries. */
277
+ export function permissionGrantIdsFromEntries(permissionGrants: PermissionGrantEntry[]): NonEmptyStringArray | undefined {
278
+ return toMessagesPermissionGrantIds(permissionGrants.map(entry => entry.grant.id));
279
+ }
280
+
281
+ /**
282
+ * Returns the permission grant IDs invoked by a message.
283
+ *
284
+ * Real DWN messages use the author signature payload as the source of truth.
285
+ */
286
+ export function getInvokedPermissionGrantIds(message: GenericMessage): string[] {
287
+ if (message.authorization === undefined) {
288
+ return [];
289
+ }
290
+
291
+ try {
292
+ const signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature) as GenericSignaturePayload;
293
+ return Message.getPermissionGrantIds(signaturePayload);
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
@@ -1,9 +1,9 @@
1
1
  import type { AbstractLevel } from 'abstract-level';
2
2
  import type { ProgressToken } from '@enbox/dwn-sdk-js';
3
3
 
4
- import type { DirectionCheckpoint, LinkStatus, ReplicationLinkState, SyncScope } from './types/sync.js';
4
+ import type { DirectionCheckpoint, LinkStatus, ReplicationLinkState, SyncAuthorization, SyncScope } from './types/sync.js';
5
5
 
6
- import { computeScopeId } from './types/sync.js';
6
+ import { canonicalizeSyncScope, computeProjectionId } from './types/sync.js';
7
7
 
8
8
  /** Separator used in compound LevelDB keys. */
9
9
  const KEY_SEP = '^';
@@ -13,7 +13,7 @@ const KEY_SEP = '^';
13
13
  * sync link in a LevelDB sublevel. Provides CRUD operations and replication
14
14
  * checkpoint helpers.
15
15
  *
16
- * Key format: `{tenantDid}^{remoteEndpoint}^{scopeId}`
16
+ * Key format: `{tenantDid}^{remoteEndpoint}^{projectionId}^{authorizationEpoch}`
17
17
  *
18
18
  * Each link tracks independent pull and push {@link DirectionCheckpoint}s.
19
19
  * The ledger does not own subscriptions or timers — it is a passive state
@@ -33,13 +33,19 @@ export class ReplicationLedger {
33
33
  // ---------------------------------------------------------------------------
34
34
 
35
35
  /** Build the compound key for a link. */
36
- private static buildKey(tenantDid: string, remoteEndpoint: string, scopeId: string): string {
37
- return `${tenantDid}${KEY_SEP}${remoteEndpoint}${KEY_SEP}${scopeId}`;
36
+ private static buildKey(
37
+ tenantDid: string,
38
+ remoteEndpoint: string,
39
+ projectionId: string,
40
+ authorizationEpoch: string,
41
+ ): string {
42
+ return `${tenantDid}${KEY_SEP}${remoteEndpoint}${KEY_SEP}${projectionId}${KEY_SEP}${authorizationEpoch}`;
38
43
  }
39
44
 
40
45
  // Note: compound keys use raw '^' separator. This is safe because tenantDid
41
- // (DID URI), remoteEndpoint (URL), and scopeId (base64url hash) cannot
42
- // contain '^'. If future fields can contain '^', keys must be escaped.
46
+ // (DID URI), remoteEndpoint (URL), projectionId (base64url hash), and
47
+ // authorizationEpoch (base64url hash) cannot contain '^'. If future fields
48
+ // can contain '^', keys must be escaped.
43
49
 
44
50
  // ---------------------------------------------------------------------------
45
51
  // CRUD
@@ -53,16 +59,22 @@ export class ReplicationLedger {
53
59
  tenantDid : string;
54
60
  remoteEndpoint : string;
55
61
  scope : SyncScope;
62
+ authorizationEpoch : string;
63
+ authorization : SyncAuthorization;
56
64
  delegateDid? : string;
57
- protocol? : string;
58
65
  }): Promise<ReplicationLinkState> {
59
- const scopeId = await computeScopeId(params.scope);
60
- const key = ReplicationLedger.buildKey(params.tenantDid, params.remoteEndpoint, scopeId);
66
+ const scope = canonicalizeSyncScope(params.scope);
67
+ const projectionId = await computeProjectionId(params.tenantDid, scope);
68
+ const key = ReplicationLedger.buildKey(
69
+ params.tenantDid,
70
+ params.remoteEndpoint,
71
+ projectionId,
72
+ params.authorizationEpoch,
73
+ );
61
74
 
62
75
  try {
63
76
  const raw = await this.sublevel.get(key);
64
77
  const link = JSON.parse(raw) as ReplicationLinkState;
65
- delete (link as any).push; // strip legacy push field from old persisted links
66
78
  // connectivity is runtime state — always reset to 'unknown' on load
67
79
  // so stale 'online' from a previous session doesn't give false positives.
68
80
  link.connectivity = 'unknown';
@@ -76,16 +88,17 @@ export class ReplicationLedger {
76
88
 
77
89
  // Create a new link with empty checkpoints.
78
90
  const link: ReplicationLinkState = {
79
- tenantDid : params.tenantDid,
80
- remoteEndpoint : params.remoteEndpoint,
81
- scopeId,
82
- scope : params.scope,
83
- status : 'initializing',
84
- connectivity : 'unknown',
85
- pull : {},
86
- needsReconcile : false,
87
- delegateDid : params.delegateDid,
88
- protocol : params.protocol,
91
+ tenantDid : params.tenantDid,
92
+ remoteEndpoint : params.remoteEndpoint,
93
+ projectionId,
94
+ authorizationEpoch : params.authorizationEpoch,
95
+ scope,
96
+ authorization : params.authorization,
97
+ status : 'initializing',
98
+ connectivity : 'unknown',
99
+ pull : {},
100
+ needsReconcile : false,
101
+ delegateDid : params.delegateDid,
89
102
  };
90
103
 
91
104
  await this.sublevel.put(key, JSON.stringify(link));
@@ -94,14 +107,24 @@ export class ReplicationLedger {
94
107
 
95
108
  /** Persist the current state of a link. */
96
109
  public async saveLink(link: ReplicationLinkState): Promise<void> {
97
- const key = ReplicationLedger.buildKey(link.tenantDid, link.remoteEndpoint, link.scopeId);
110
+ const key = ReplicationLedger.buildKey(
111
+ link.tenantDid,
112
+ link.remoteEndpoint,
113
+ link.projectionId,
114
+ link.authorizationEpoch,
115
+ );
98
116
  link.lastActivityAt = new Date().toISOString();
99
117
  await this.sublevel.put(key, JSON.stringify(link));
100
118
  }
101
119
 
102
120
  /** Delete a link. */
103
- public async deleteLink(tenantDid: string, remoteEndpoint: string, scopeId: string): Promise<void> {
104
- const key = ReplicationLedger.buildKey(tenantDid, remoteEndpoint, scopeId);
121
+ public async deleteLink(
122
+ tenantDid: string,
123
+ remoteEndpoint: string,
124
+ projectionId: string,
125
+ authorizationEpoch: string,
126
+ ): Promise<void> {
127
+ const key = ReplicationLedger.buildKey(tenantDid, remoteEndpoint, projectionId, authorizationEpoch);
105
128
  await this.sublevel.del(key);
106
129
  }
107
130
 
@@ -126,31 +149,6 @@ export class ReplicationLedger {
126
149
  return links;
127
150
  }
128
151
 
129
- // ---------------------------------------------------------------------------
130
- // Delegate updates
131
- // ---------------------------------------------------------------------------
132
-
133
- /**
134
- * Update the `delegateDid` on all persisted links for a tenant and persist.
135
- * This ensures that repair and reconcile paths — which read `delegateDid`
136
- * from the durable {@link ReplicationLinkState} — use the current delegate
137
- * after a hot-swap via `updateIdentityOptions()`.
138
- *
139
- * @returns the links that were updated.
140
- */
141
- public async updateDelegateDid(tenantDid: string, delegateDid: string | undefined): Promise<ReplicationLinkState[]> {
142
- const links = await this.getLinksForTenant(tenantDid);
143
- const updated: ReplicationLinkState[] = [];
144
- for (const link of links) {
145
- if (link.delegateDid !== delegateDid) {
146
- link.delegateDid = delegateDid;
147
- await this.saveLink(link);
148
- updated.push(link);
149
- }
150
- }
151
- return updated;
152
- }
153
-
154
152
  // ---------------------------------------------------------------------------
155
153
  // Status transitions
156
154
  // ---------------------------------------------------------------------------
@@ -208,6 +206,14 @@ export class ReplicationLedger {
208
206
  * are durably committed before calling this.
209
207
  */
210
208
  public static commitContiguousToken(checkpoint: DirectionCheckpoint, token: ProgressToken): void {
209
+ if (
210
+ checkpoint.contiguousAppliedToken !== undefined &&
211
+ token.streamId === checkpoint.contiguousAppliedToken.streamId &&
212
+ token.epoch === checkpoint.contiguousAppliedToken.epoch &&
213
+ ReplicationLedger.comparePosition(token, checkpoint.contiguousAppliedToken) <= 0
214
+ ) {
215
+ return;
216
+ }
211
217
  checkpoint.contiguousAppliedToken = token;
212
218
  }
213
219
 
@@ -245,4 +251,3 @@ export class ReplicationLedger {
245
251
  }
246
252
  }
247
253
  }
248
-
@@ -0,0 +1,186 @@
1
+ import type { GenericMessage, MessageEvent, ProtocolScope, RecordsWriteMessage } from '@enbox/dwn-sdk-js';
2
+
3
+ import { DwnInterfaceName, DwnMethodName, PermissionScopeMatcher, PermissionsProtocol } from '@enbox/dwn-sdk-js';
4
+
5
+ import type { SyncScope } from './types/sync.js';
6
+
7
+ /** Result of testing whether an inbound sync message belongs to a link scope. */
8
+ export type SyncScopeClassification = 'in-scope' | 'out-of-scope' | 'unknown';
9
+
10
+ type SyncMessageScopeClassificationParams = {
11
+ message: GenericMessage;
12
+ initialWrite?: RecordsWriteMessage;
13
+ scope: SyncScope;
14
+ };
15
+
16
+ type ProtocolSetSyncScope = Extract<SyncScope, { kind: 'protocolSet' }>;
17
+ type RecordsProjectionSyncScope = Extract<SyncScope, { kind: 'recordsProjection' }>;
18
+
19
+ /**
20
+ * Classifies whether a live event belongs to the link's current sync scope.
21
+ *
22
+ * Full links accept every message. Protocol-set links accept records for a
23
+ * covered protocol, ProtocolsConfigure messages that install a covered
24
+ * protocol, and permission records tagged for a covered protocol. RecordsDelete
25
+ * messages carry no protocol in their descriptor, so they must be classified
26
+ * from the event's initial write metadata.
27
+ */
28
+ export function classifySyncEventScope(event: MessageEvent, scope: SyncScope): SyncScopeClassification {
29
+ return classifySyncMessageScope({
30
+ message : event.message,
31
+ initialWrite : event.initialWrite,
32
+ scope,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Classifies whether a DWN message belongs to a sync scope before local apply.
38
+ *
39
+ * If a RecordsDelete cannot be tied to its initial write, the result is
40
+ * `unknown` so callers fail closed instead of applying an unclassified delete.
41
+ */
42
+ export function classifySyncMessageScope({
43
+ message,
44
+ initialWrite,
45
+ scope,
46
+ }: SyncMessageScopeClassificationParams): SyncScopeClassification {
47
+ if (scope.kind === 'full') { return 'in-scope'; }
48
+ if (scope.kind === 'recordsProjection') {
49
+ return classifyRecordsProjectionScope(message, initialWrite, scope);
50
+ }
51
+
52
+ return classifyProtocolSetScope(message, initialWrite, scope);
53
+ }
54
+
55
+ function classifyProtocolSetScope(
56
+ message: GenericMessage,
57
+ initialWrite: RecordsWriteMessage | undefined,
58
+ scope: ProtocolSetSyncScope,
59
+ ): SyncScopeClassification {
60
+ const descriptor = message.descriptor as Record<string, unknown>;
61
+ const scopedDescriptor = getScopedMessageDescriptor(message, initialWrite);
62
+ if (scopedDescriptor === undefined) {
63
+ return 'unknown';
64
+ }
65
+
66
+ const permissionRecordClassification = classifyTaggedPermissionRecord(scopedDescriptor, scope.protocols);
67
+ if (permissionRecordClassification !== undefined) { return permissionRecordClassification; }
68
+
69
+ const protocolClassification = classifyProtocolField(scopedDescriptor.protocol, scope.protocols);
70
+ if (protocolClassification !== undefined) { return protocolClassification; }
71
+
72
+ return classifyProtocolsConfigureDescriptor(descriptor, scope.protocols);
73
+ }
74
+
75
+ function classifyTaggedPermissionRecord(
76
+ descriptor: Record<string, unknown>,
77
+ protocols: readonly string[],
78
+ ): SyncScopeClassification | undefined {
79
+ if (descriptor.protocol !== PermissionsProtocol.uri || !isRecordObject(descriptor.tags)) {
80
+ return undefined;
81
+ }
82
+
83
+ const taggedProtocol = descriptor.tags.protocol;
84
+ return typeof taggedProtocol === 'string' && protocols.includes(taggedProtocol)
85
+ ? 'in-scope'
86
+ : 'out-of-scope';
87
+ }
88
+
89
+ function classifyProtocolField(protocol: unknown, protocols: readonly string[]): SyncScopeClassification | undefined {
90
+ if (typeof protocol !== 'string') {
91
+ return undefined;
92
+ }
93
+
94
+ return protocols.includes(protocol) ? 'in-scope' : 'out-of-scope';
95
+ }
96
+
97
+ function classifyProtocolsConfigureDescriptor(
98
+ descriptor: Record<string, unknown>,
99
+ protocols: readonly string[],
100
+ ): SyncScopeClassification {
101
+ if (
102
+ descriptor.interface === DwnInterfaceName.Protocols &&
103
+ descriptor.method === DwnMethodName.Configure &&
104
+ isRecordObject(descriptor.definition)
105
+ ) {
106
+ const definitionProtocol = descriptor.definition.protocol;
107
+ return typeof definitionProtocol === 'string' && protocols.includes(definitionProtocol)
108
+ ? 'in-scope'
109
+ : 'out-of-scope';
110
+ }
111
+
112
+ return 'out-of-scope';
113
+ }
114
+
115
+ function getScopedMessageDescriptor(
116
+ message: GenericMessage,
117
+ initialWrite: RecordsWriteMessage | undefined,
118
+ ): Record<string, unknown> | undefined {
119
+ const descriptor = message.descriptor as Record<string, unknown>;
120
+ if (
121
+ descriptor.interface === DwnInterfaceName.Records &&
122
+ descriptor.method === DwnMethodName.Delete
123
+ ) {
124
+ return initialWrite?.descriptor as Record<string, unknown> | undefined;
125
+ }
126
+
127
+ return descriptor;
128
+ }
129
+
130
+ function classifyRecordsProjectionScope(
131
+ message: GenericMessage,
132
+ initialWrite: RecordsWriteMessage | undefined,
133
+ scope: RecordsProjectionSyncScope,
134
+ ): SyncScopeClassification {
135
+ const target = getRecordsProjectionTarget(message, initialWrite);
136
+ if (target === undefined) {
137
+ return isRecordsDelete(message) ? 'unknown' : 'out-of-scope';
138
+ }
139
+
140
+ return scope.scopes.some(projectionScope => PermissionScopeMatcher.matches(projectionScope, target))
141
+ ? 'in-scope'
142
+ : 'out-of-scope';
143
+ }
144
+
145
+ function getRecordsProjectionTarget(
146
+ message: GenericMessage,
147
+ initialWrite: RecordsWriteMessage | undefined,
148
+ ): ProtocolScope | undefined {
149
+ const descriptor = message.descriptor as Record<string, unknown>;
150
+ if (descriptor.interface !== DwnInterfaceName.Records) {
151
+ return undefined;
152
+ }
153
+
154
+ if (descriptor.method === DwnMethodName.Write) {
155
+ return {
156
+ protocol : stringField(descriptor.protocol),
157
+ protocolPath : stringField(descriptor.protocolPath),
158
+ contextId : (message as RecordsWriteMessage).contextId,
159
+ };
160
+ }
161
+
162
+ if (descriptor.method === DwnMethodName.Delete) {
163
+ if (initialWrite === undefined) {
164
+ return undefined;
165
+ }
166
+ return {
167
+ protocol : initialWrite.descriptor.protocol,
168
+ protocolPath : initialWrite.descriptor.protocolPath,
169
+ contextId : initialWrite.contextId,
170
+ };
171
+ }
172
+ }
173
+
174
+ function isRecordsDelete(message: GenericMessage): boolean {
175
+ const descriptor = message.descriptor as Record<string, unknown>;
176
+ return descriptor.interface === DwnInterfaceName.Records &&
177
+ descriptor.method === DwnMethodName.Delete;
178
+ }
179
+
180
+ function isRecordObject(value: unknown): value is Record<string, unknown> {
181
+ return typeof value === 'object' && value !== null;
182
+ }
183
+
184
+ function stringField(value: unknown): string | undefined {
185
+ return typeof value === 'string' ? value : undefined;
186
+ }