@enbox/agent 0.7.7 → 0.7.9
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.
- package/dist/browser.mjs +9 -9
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/dwn-api.js +3 -2
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +5 -5
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/permissions-api.js +7 -34
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +229 -110
- package/dist/esm/sync-closure-resolver.js.map +1 -1
- package/dist/esm/sync-closure-types.js +39 -7
- package/dist/esm/sync-closure-types.js.map +1 -1
- package/dist/esm/sync-engine-level.js +2242 -797
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-link-id.js +4 -13
- package/dist/esm/sync-link-id.js.map +1 -1
- package/dist/esm/sync-link-reconciler.js +26 -8
- package/dist/esm/sync-link-reconciler.js.map +1 -1
- package/dist/esm/sync-messages.js +218 -154
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/esm/sync-permission-grants.js +208 -0
- package/dist/esm/sync-permission-grants.js.map +1 -0
- package/dist/esm/sync-replication-ledger.js +23 -40
- package/dist/esm/sync-replication-ledger.js.map +1 -1
- package/dist/esm/sync-scope-acceptance.js +126 -0
- package/dist/esm/sync-scope-acceptance.js.map +1 -0
- package/dist/esm/sync-topological-sort.js +57 -15
- package/dist/esm/sync-topological-sort.js.map +1 -1
- package/dist/esm/types/sync.js +130 -22
- package/dist/esm/types/sync.js.map +1 -1
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/permissions-api.d.ts +1 -2
- package/dist/types/permissions-api.d.ts.map +1 -1
- package/dist/types/sync-closure-resolver.d.ts.map +1 -1
- package/dist/types/sync-closure-types.d.ts +14 -3
- package/dist/types/sync-closure-types.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +144 -31
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-link-id.d.ts +3 -9
- package/dist/types/sync-link-id.d.ts.map +1 -1
- package/dist/types/sync-link-reconciler.d.ts +12 -2
- package/dist/types/sync-link-reconciler.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +16 -13
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/sync-permission-grants.d.ts +52 -0
- package/dist/types/sync-permission-grants.d.ts.map +1 -0
- package/dist/types/sync-replication-ledger.d.ts +5 -13
- package/dist/types/sync-replication-ledger.d.ts.map +1 -1
- package/dist/types/sync-scope-acceptance.d.ts +28 -0
- package/dist/types/sync-scope-acceptance.d.ts.map +1 -0
- package/dist/types/sync-topological-sort.d.ts +2 -1
- package/dist/types/sync-topological-sort.d.ts.map +1 -1
- package/dist/types/types/permissions.d.ts +2 -0
- package/dist/types/types/permissions.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +137 -75
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/dwn-api.ts +3 -2
- package/src/enbox-connect-protocol.ts +5 -5
- package/src/index.ts +10 -1
- package/src/permissions-api.ts +11 -42
- package/src/sync-closure-resolver.ts +306 -126
- package/src/sync-closure-types.ts +54 -9
- package/src/sync-engine-level.ts +3051 -967
- package/src/sync-link-id.ts +9 -14
- package/src/sync-link-reconciler.ts +43 -10
- package/src/sync-messages.ts +263 -159
- package/src/sync-permission-grants.ts +297 -0
- package/src/sync-replication-ledger.ts +55 -50
- package/src/sync-scope-acceptance.ts +186 -0
- package/src/sync-topological-sort.ts +89 -21
- package/src/types/permissions.ts +2 -0
- 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 {
|
|
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}^{
|
|
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(
|
|
37
|
-
|
|
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),
|
|
42
|
-
// contain '^'. If future fields
|
|
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
|
|
60
|
-
const
|
|
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
|
|
80
|
-
remoteEndpoint
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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(
|
|
104
|
-
|
|
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
|
+
}
|