@enbox/agent 0.7.6 → 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.
- package/dist/browser.mjs +9 -11
- 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/hd-identity-vault.js +187 -177
- package/dist/esm/hd-identity-vault.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 +24 -7
- package/dist/esm/sync-closure-types.js.map +1 -1
- package/dist/esm/sync-engine-level.js +1961 -764
- 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/hd-identity-vault.d.ts +25 -0
- package/dist/types/hd-identity-vault.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 +127 -25
- 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/identity-vault.d.ts +9 -0
- package/dist/types/types/identity-vault.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/hd-identity-vault.ts +244 -212
- 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 +38 -9
- package/src/sync-engine-level.ts +2560 -797
- 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/identity-vault.ts +8 -1
- package/src/types/permissions.ts +2 -0
- package/src/types/sync.ts +235 -62
|
@@ -4,12 +4,14 @@ import type {
|
|
|
4
4
|
ClosureEvaluationContext,
|
|
5
5
|
ClosureResult,
|
|
6
6
|
} from './sync-closure-types.js';
|
|
7
|
-
import type { GenericMessage, MessageStore } from '@enbox/dwn-sdk-js';
|
|
8
|
-
|
|
9
|
-
import { Message, PermissionsProtocol } from '@enbox/dwn-sdk-js';
|
|
7
|
+
import type { GenericMessage, MessageStore, ProtocolDefinition, ProtocolRuleSet } from '@enbox/dwn-sdk-js';
|
|
10
8
|
|
|
9
|
+
import { classifySyncMessageScope } from './sync-scope-acceptance.js';
|
|
10
|
+
import { getInvokedPermissionGrantIds } from './sync-permission-grants.js';
|
|
11
11
|
import { isMultiPartyContext } from './protocol-utils.js';
|
|
12
|
+
import { protocolsForSyncScope } from './types/sync.js';
|
|
12
13
|
import { ClosureFailureCode, createClosureContext } from './sync-closure-types.js';
|
|
14
|
+
import { Message, PermissionScopeMatcher, PermissionsProtocol } from '@enbox/dwn-sdk-js';
|
|
13
15
|
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
15
17
|
// Dependency extraction helpers (one per dependency class)
|
|
@@ -44,111 +46,173 @@ function extractProtocolDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
|
44
46
|
* - initialWrite (for non-initial writes)
|
|
45
47
|
* - parentId chain
|
|
46
48
|
*/
|
|
47
|
-
function extractAncestryDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
49
|
+
function extractAncestryDeps(message: GenericMessage, protocolDef?: ProtocolDefinition): ClosureDependencyEdge[] {
|
|
48
50
|
const desc = message.descriptor as Record<string, unknown>;
|
|
49
|
-
const edges: ClosureDependencyEdge[] = [];
|
|
50
51
|
|
|
51
52
|
// Only Records interface messages have ancestry dependencies.
|
|
52
53
|
if (desc.interface !== 'Records') { return []; }
|
|
53
54
|
|
|
54
|
-
const
|
|
55
|
+
const context = createAncestryContext(message, desc, protocolDef);
|
|
56
|
+
return [
|
|
57
|
+
createParentRecordDependency(context),
|
|
58
|
+
createContextRootDependency(context),
|
|
59
|
+
createInitialWriteDependency(context),
|
|
60
|
+
].filter((edge): edge is ClosureDependencyEdge => edge !== undefined);
|
|
61
|
+
}
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
function expectedProtocol(protocol: string | undefined): Partial<Pick<ClosureDependencyEdge, 'expectedProtocol'>> {
|
|
64
|
+
return protocol === undefined ? {} : { expectedProtocol: protocol };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type AncestryContext = {
|
|
68
|
+
descriptor: Record<string, unknown>;
|
|
69
|
+
recordId?: string;
|
|
70
|
+
protocol?: string;
|
|
71
|
+
parentId?: string;
|
|
72
|
+
contextId?: string;
|
|
73
|
+
rootRefProtocol?: string;
|
|
74
|
+
parentResolvedByCrossProtocolRef: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function createAncestryContext(
|
|
78
|
+
message: GenericMessage,
|
|
79
|
+
descriptor: Record<string, unknown>,
|
|
80
|
+
protocolDef: ProtocolDefinition | undefined,
|
|
81
|
+
): AncestryContext {
|
|
82
|
+
const rootRefProtocol = getRootRefProtocol(descriptor, protocolDef);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
descriptor,
|
|
86
|
+
recordId : (message as any).recordId as string | undefined,
|
|
87
|
+
protocol : descriptor.protocol as string | undefined,
|
|
88
|
+
parentId : descriptor.parentId as string | undefined,
|
|
89
|
+
contextId : (message as any).contextId as string | undefined,
|
|
90
|
+
rootRefProtocol,
|
|
91
|
+
parentResolvedByCrossProtocolRef : rootRefProtocol !== undefined && isDirectChildOfRootRef(descriptor),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createParentRecordDependency(context: AncestryContext): ClosureDependencyEdge | undefined {
|
|
96
|
+
if (!context.parentId || context.parentResolvedByCrossProtocolRef) { return undefined; }
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
dependencyClass : 2,
|
|
100
|
+
label : 'parentRecord',
|
|
101
|
+
identifier : context.parentId,
|
|
102
|
+
identifierType : 'recordId',
|
|
103
|
+
...expectedProtocol(context.protocol),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createContextRootDependency(context: AncestryContext): ClosureDependencyEdge | undefined {
|
|
108
|
+
const contextRootId = getContextRootId(context);
|
|
109
|
+
if (!contextRootId || contextRootIsCrossProtocolParent(context, contextRootId)) {
|
|
110
|
+
return undefined;
|
|
65
111
|
}
|
|
66
112
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
113
|
+
return {
|
|
114
|
+
dependencyClass : 2,
|
|
115
|
+
label : 'contextRoot',
|
|
116
|
+
identifier : contextRootId,
|
|
117
|
+
identifierType : 'recordId',
|
|
118
|
+
...expectedProtocol(context.rootRefProtocol ?? context.protocol),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getContextRootId(context: AncestryContext): string | undefined {
|
|
123
|
+
if (!context.contextId || !context.recordId || context.contextId === context.recordId) {
|
|
124
|
+
return undefined;
|
|
80
125
|
}
|
|
81
126
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
return context.contextId.split('/')[0];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function contextRootIsCrossProtocolParent(context: AncestryContext, contextRootId: string): boolean {
|
|
131
|
+
return context.parentResolvedByCrossProtocolRef && contextRootId === context.parentId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createInitialWriteDependency(context: AncestryContext): ClosureDependencyEdge | undefined {
|
|
135
|
+
if (!context.recordId) { return undefined; }
|
|
136
|
+
|
|
137
|
+
if (isNonInitialWrite(context.descriptor)) {
|
|
138
|
+
return {
|
|
139
|
+
dependencyClass : 2,
|
|
140
|
+
label : 'initialWrite',
|
|
141
|
+
identifier : context.recordId,
|
|
142
|
+
identifierType : 'recordId',
|
|
143
|
+
...expectedProtocol(context.protocol),
|
|
144
|
+
};
|
|
98
145
|
}
|
|
99
146
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
edges.push({
|
|
147
|
+
if (context.descriptor.method === 'Delete') {
|
|
148
|
+
return {
|
|
103
149
|
dependencyClass : 2,
|
|
104
150
|
label : 'initialWrite',
|
|
105
|
-
identifier : recordId,
|
|
151
|
+
identifier : context.recordId,
|
|
106
152
|
identifierType : 'recordId',
|
|
107
|
-
}
|
|
153
|
+
};
|
|
108
154
|
}
|
|
109
155
|
|
|
110
|
-
return
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isNonInitialWrite(descriptor: Record<string, unknown>): boolean {
|
|
160
|
+
if (descriptor.method !== 'Write') { return false; }
|
|
161
|
+
|
|
162
|
+
const dateCreated = descriptor.dateCreated as string | undefined;
|
|
163
|
+
const messageTimestamp = descriptor.messageTimestamp as string | undefined;
|
|
164
|
+
return dateCreated !== undefined &&
|
|
165
|
+
messageTimestamp !== undefined &&
|
|
166
|
+
dateCreated !== messageTimestamp;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isDirectChildOfRootRef(descriptor: Record<string, unknown>): boolean {
|
|
170
|
+
const protocolPath = descriptor.protocolPath as string | undefined;
|
|
171
|
+
return protocolPath?.split('/').length === 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getRootRefProtocol(
|
|
175
|
+
descriptor: Record<string, unknown>,
|
|
176
|
+
protocolDef: ProtocolDefinition | undefined,
|
|
177
|
+
): string | undefined {
|
|
178
|
+
const protocolPath = descriptor.protocolPath as string | undefined;
|
|
179
|
+
if (!protocolPath) { return undefined; }
|
|
180
|
+
|
|
181
|
+
const pathSegments = protocolPath.split('/');
|
|
182
|
+
const rootRuleSet = protocolDef?.structure?.[pathSegments[0]];
|
|
183
|
+
if (typeof rootRuleSet?.$ref !== 'string') { return undefined; }
|
|
184
|
+
|
|
185
|
+
const colonIdx = rootRuleSet.$ref.indexOf(':');
|
|
186
|
+
if (colonIdx <= 0) { return undefined; }
|
|
187
|
+
|
|
188
|
+
const alias = rootRuleSet.$ref.substring(0, colonIdx);
|
|
189
|
+
const referencedProtocol = protocolDef?.uses?.[alias];
|
|
190
|
+
return typeof referencedProtocol === 'string' ? referencedProtocol : undefined;
|
|
111
191
|
}
|
|
112
192
|
|
|
113
193
|
/**
|
|
114
194
|
* Class 3: Authorization closure.
|
|
115
|
-
* If the message
|
|
195
|
+
* If the message invokes permission grant IDs, each grant record must be present.
|
|
116
196
|
*/
|
|
117
197
|
function extractAuthorizationDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
118
198
|
const edges: ClosureDependencyEdge[] = [];
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
identifierType : 'grantId',
|
|
137
|
-
});
|
|
138
|
-
// Also require the grant's revocation state to be resolvable.
|
|
139
|
-
// The revocation is a child record at protocolPath 'grant/revocation'
|
|
140
|
-
// with parentId === grantId. We add it as a separate edge so the
|
|
141
|
-
// resolver can check for its presence (or confirmed absence).
|
|
142
|
-
edges.push({
|
|
143
|
-
dependencyClass : 3,
|
|
144
|
-
label : 'grantRevocation',
|
|
145
|
-
identifier : decoded.permissionGrantId,
|
|
146
|
-
identifierType : 'grantId',
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
} catch {
|
|
150
|
-
// If we can't decode, skip — authorization will fail at apply time.
|
|
151
|
-
}
|
|
199
|
+
for (const permissionGrantId of getInvokedPermissionGrantIds(message)) {
|
|
200
|
+
edges.push({
|
|
201
|
+
dependencyClass : 3,
|
|
202
|
+
label : 'permissionGrant',
|
|
203
|
+
identifier : permissionGrantId,
|
|
204
|
+
identifierType : 'grantId',
|
|
205
|
+
});
|
|
206
|
+
// Also require the grant's revocation state to be resolvable.
|
|
207
|
+
// The revocation is a child record at protocolPath 'grant/revocation'
|
|
208
|
+
// with parentId === grantId. We add it as a separate edge so the
|
|
209
|
+
// resolver can check for its presence (or confirmed absence).
|
|
210
|
+
edges.push({
|
|
211
|
+
dependencyClass : 3,
|
|
212
|
+
label : 'grantRevocation',
|
|
213
|
+
identifier : permissionGrantId,
|
|
214
|
+
identifierType : 'grantId',
|
|
215
|
+
});
|
|
152
216
|
}
|
|
153
217
|
|
|
154
218
|
return edges;
|
|
@@ -170,24 +234,30 @@ function extractAuthorizationDeps(message: GenericMessage): ClosureDependencyEdg
|
|
|
170
234
|
* (not in public API, reimplemented here to avoid internal path coupling).
|
|
171
235
|
*/
|
|
172
236
|
function getRuleSetAtProtocolPath(
|
|
173
|
-
structure:
|
|
237
|
+
structure: ProtocolDefinition['structure'] | undefined,
|
|
174
238
|
protocolPath: string,
|
|
175
|
-
):
|
|
239
|
+
): ProtocolRuleSet | undefined {
|
|
176
240
|
if (!structure || !protocolPath) { return undefined; }
|
|
177
241
|
|
|
178
242
|
const segments = protocolPath.split('/');
|
|
179
|
-
let currentLevel: Record<string,
|
|
180
|
-
let current:
|
|
243
|
+
let currentLevel: Record<string, unknown> = structure;
|
|
244
|
+
let current: ProtocolRuleSet | undefined;
|
|
181
245
|
|
|
182
246
|
for (const segment of segments) {
|
|
183
247
|
if (!Object.hasOwn(currentLevel, segment)) { return undefined; }
|
|
184
|
-
|
|
248
|
+
const next = currentLevel[segment];
|
|
249
|
+
if (!isProtocolRuleSet(next)) { return undefined; }
|
|
250
|
+
current = next;
|
|
185
251
|
currentLevel = current;
|
|
186
252
|
}
|
|
187
253
|
|
|
188
254
|
return current;
|
|
189
255
|
}
|
|
190
256
|
|
|
257
|
+
function isProtocolRuleSet(value: unknown): value is ProtocolRuleSet {
|
|
258
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
259
|
+
}
|
|
260
|
+
|
|
191
261
|
/**
|
|
192
262
|
* Extract protocol-definition-aware dependencies for classes 4, 5, and 6.
|
|
193
263
|
* Called during BFS traversal after the ProtocolDefinition has been fetched
|
|
@@ -199,7 +269,7 @@ function getRuleSetAtProtocolPath(
|
|
|
199
269
|
*/
|
|
200
270
|
function extractProtocolAwareDeps(
|
|
201
271
|
message: GenericMessage,
|
|
202
|
-
protocolDef:
|
|
272
|
+
protocolDef: ProtocolDefinition,
|
|
203
273
|
isDelegateSession?: boolean,
|
|
204
274
|
): ClosureDependencyEdge[] {
|
|
205
275
|
const desc = message.descriptor as Record<string, unknown>;
|
|
@@ -342,10 +412,11 @@ function extractProtocolAwareDeps(
|
|
|
342
412
|
const parentId = desc.parentId as string | undefined;
|
|
343
413
|
if (pathSegments.length === 2 && parentId) {
|
|
344
414
|
edges.push({
|
|
345
|
-
dependencyClass
|
|
346
|
-
label
|
|
347
|
-
identifier
|
|
348
|
-
identifierType
|
|
415
|
+
dependencyClass : 6,
|
|
416
|
+
label : 'crossProtocolParent',
|
|
417
|
+
identifier : `${referencedProtocol}|${parentId}`,
|
|
418
|
+
identifierType : 'recordId',
|
|
419
|
+
expectedProtocol : referencedProtocol,
|
|
349
420
|
});
|
|
350
421
|
}
|
|
351
422
|
}
|
|
@@ -421,11 +492,12 @@ function extractProtocolAwareDeps(
|
|
|
421
492
|
const contextPrefix = roleFilter.contextId
|
|
422
493
|
? JSON.stringify(roleFilter.contextId) : 'no-context';
|
|
423
494
|
edges.push({
|
|
424
|
-
dependencyClass
|
|
425
|
-
label
|
|
426
|
-
identifier
|
|
427
|
-
identifierType
|
|
428
|
-
|
|
495
|
+
dependencyClass : 6,
|
|
496
|
+
label : 'crossProtocolRoleRecord',
|
|
497
|
+
identifier : `${roleProtocol}|${roleProtocolPath}|${messageAuthor}|${contextPrefix}`,
|
|
498
|
+
identifierType : 'filter',
|
|
499
|
+
expectedProtocol : roleProtocol,
|
|
500
|
+
filter : roleFilter,
|
|
429
501
|
});
|
|
430
502
|
}
|
|
431
503
|
}
|
|
@@ -498,38 +570,41 @@ export async function evaluateClosure(
|
|
|
498
570
|
for (let i = 0; i < batchSize; i++) {
|
|
499
571
|
const current = queue.shift()!;
|
|
500
572
|
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
...extractAncestryDeps(current),
|
|
507
|
-
...extractAuthorizationDeps(current),
|
|
508
|
-
];
|
|
509
|
-
|
|
510
|
-
const resolveResult = await resolveEdges(
|
|
511
|
-
staticEdges, allEdges, messageStore, context, visited, queue, rootCid, currentDepth
|
|
573
|
+
// Resolve protocol metadata first. This populates the protocol
|
|
574
|
+
// cache when a ProtocolsConfigure dependency is resolved, which is
|
|
575
|
+
// needed to distinguish normal parent records from $ref parents.
|
|
576
|
+
const protocolResult = await resolveEdges(
|
|
577
|
+
extractProtocolDeps(current), allEdges, messageStore, scope, context, visited, queue, rootCid, currentDepth
|
|
512
578
|
);
|
|
513
|
-
if (
|
|
579
|
+
if (protocolResult) { return protocolResult; } // Early failure.
|
|
514
580
|
|
|
515
|
-
// Phase 2: Extract protocol-definition-aware edges (classes 4-6).
|
|
516
|
-
// Runs AFTER static resolution so the ProtocolDefinition is in the cache.
|
|
517
581
|
const currentDesc = current.descriptor as Record<string, unknown>;
|
|
518
582
|
const currentProtocol = currentDesc.protocol as string | undefined;
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
583
|
+
const cachedProtocolMsg = currentProtocol ? context.protocolCache.get(currentProtocol) : undefined;
|
|
584
|
+
const protocolDef = cachedProtocolMsg?.descriptor?.definition as ProtocolDefinition | undefined;
|
|
585
|
+
|
|
586
|
+
const structuralEdges = [
|
|
587
|
+
...extractAncestryDeps(current, protocolDef),
|
|
588
|
+
...extractAuthorizationDeps(current),
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
const structuralResult = await resolveEdges(
|
|
592
|
+
structuralEdges, allEdges, messageStore, scope, context, visited, queue, rootCid, currentDepth
|
|
593
|
+
);
|
|
594
|
+
if (structuralResult) { return structuralResult; } // Early failure.
|
|
595
|
+
|
|
596
|
+
// Resolve protocol-definition-aware edges after static dependencies so
|
|
597
|
+
// the ProtocolDefinition is in the cache.
|
|
598
|
+
if (protocolDef) {
|
|
599
|
+
const protoAwareEdges = extractProtocolAwareDeps(current, protocolDef, context.isDelegateSession);
|
|
600
|
+
const protoResult = await resolveEdges(
|
|
601
|
+
protoAwareEdges, allEdges, messageStore, scope, context, visited, queue, rootCid, currentDepth
|
|
602
|
+
);
|
|
603
|
+
if (protoResult) { return protoResult; }
|
|
529
604
|
}
|
|
530
605
|
|
|
531
|
-
//
|
|
532
|
-
//
|
|
606
|
+
// Validate grant temporal ordering after all dependency edges resolve.
|
|
607
|
+
// If this message uses a grant, verify
|
|
533
608
|
// that the grant is temporally valid at the message's commit point:
|
|
534
609
|
// - grant.dateGranted <= message.messageTimestamp < grant.dateExpires
|
|
535
610
|
// - no revocation exists with revocation.messageTimestamp <= message.messageTimestamp
|
|
@@ -684,6 +759,7 @@ async function resolveEdges(
|
|
|
684
759
|
edges: ClosureDependencyEdge[],
|
|
685
760
|
allEdges: ClosureDependencyEdge[],
|
|
686
761
|
messageStore: MessageStore,
|
|
762
|
+
scope: SyncScope,
|
|
687
763
|
context: ClosureEvaluationContext,
|
|
688
764
|
visited: Set<string>,
|
|
689
765
|
queue: GenericMessage[],
|
|
@@ -696,7 +772,7 @@ async function resolveEdges(
|
|
|
696
772
|
// Include label in dep key to distinguish edges with the same identifier
|
|
697
773
|
// but different semantics (e.g., permissionGrant vs grantRevocation both
|
|
698
774
|
// use identifierType:'grantId' with the same grantId).
|
|
699
|
-
const depKey =
|
|
775
|
+
const depKey = dependencyCacheKey(edge, scope);
|
|
700
776
|
if (context.satisfiedDeps.has(depKey)) { continue; }
|
|
701
777
|
if (context.missingDeps.has(depKey)) {
|
|
702
778
|
return {
|
|
@@ -728,6 +804,20 @@ async function resolveEdges(
|
|
|
728
804
|
};
|
|
729
805
|
}
|
|
730
806
|
|
|
807
|
+
if (!isResolvedDependencyAllowed(edge, resolved, scope)) {
|
|
808
|
+
return {
|
|
809
|
+
complete : false,
|
|
810
|
+
rootMessageCid : rootCid,
|
|
811
|
+
edges : allEdges,
|
|
812
|
+
failure : {
|
|
813
|
+
code : ClosureFailureCode.DependencyForbidden,
|
|
814
|
+
edge,
|
|
815
|
+
detail : `dependency '${edge.label}' (${edge.identifier}) is outside the current sync scope`,
|
|
816
|
+
},
|
|
817
|
+
depth: currentDepth,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
731
821
|
context.satisfiedDeps.add(depKey);
|
|
732
822
|
|
|
733
823
|
// Add resolved record to the BFS queue for transitive dependency evaluation,
|
|
@@ -750,6 +840,96 @@ async function resolveEdges(
|
|
|
750
840
|
return null; // All edges resolved successfully.
|
|
751
841
|
}
|
|
752
842
|
|
|
843
|
+
function dependencyCacheKey(edge: ClosureDependencyEdge, scope: SyncScope): string {
|
|
844
|
+
const expectedProtocolSegment = edge.expectedProtocol === undefined
|
|
845
|
+
? ''
|
|
846
|
+
: `expectedProtocol:${edge.expectedProtocol}:`;
|
|
847
|
+
return `${scopeCacheKey(scope)}:${expectedProtocolSegment}${edge.label}:${edge.identifierType}:${edge.identifier}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function scopeCacheKey(scope: SyncScope): string {
|
|
851
|
+
if (scope.kind === 'full') {
|
|
852
|
+
return 'full';
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (scope.kind === 'protocolSet') {
|
|
856
|
+
return `protocolSet:${scope.protocols.join('\u001f')}`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return `recordsProjection:${scope.scopes.map(scopeEntry => JSON.stringify(scopeEntry)).join('\u001f')}`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function isResolvedDependencyAllowed(
|
|
863
|
+
edge: ClosureDependencyEdge,
|
|
864
|
+
resolved: GenericMessage,
|
|
865
|
+
scope: SyncScope,
|
|
866
|
+
): boolean {
|
|
867
|
+
if (isSyntheticDependency(resolved)) {
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Protocol metadata is re-derived from an accepted primary and is safe to
|
|
872
|
+
// use as closure metadata even when it is not itself a primary in the scope.
|
|
873
|
+
if (edge.identifierType === 'protocol') {
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (edge.expectedProtocol !== undefined) {
|
|
878
|
+
return getMessageProtocol(resolved) === edge.expectedProtocol;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (edge.label === 'contextKeyRecord') {
|
|
882
|
+
return isContextKeyDependencyAllowed(edge, resolved, scope);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return classifySyncMessageScope({ message: resolved, scope }) === 'in-scope';
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function isSyntheticDependency(message: GenericMessage): boolean {
|
|
889
|
+
return (message.descriptor as Record<string, unknown>).interface === 'Synthetic';
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function getMessageProtocol(message: GenericMessage): string | undefined {
|
|
893
|
+
const descriptor = message.descriptor as Record<string, unknown>;
|
|
894
|
+
return typeof descriptor.protocol === 'string' ? descriptor.protocol : undefined;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function isContextKeyDependencyAllowed(
|
|
898
|
+
edge: ClosureDependencyEdge,
|
|
899
|
+
message: GenericMessage,
|
|
900
|
+
scope: SyncScope,
|
|
901
|
+
): boolean {
|
|
902
|
+
if (scope.kind === 'full') { return true; }
|
|
903
|
+
|
|
904
|
+
const separatorIdx = edge.identifier.indexOf('|');
|
|
905
|
+
if (separatorIdx <= 0) { return false; }
|
|
906
|
+
|
|
907
|
+
const sourceProtocol = edge.identifier.substring(0, separatorIdx);
|
|
908
|
+
const rootContextId = edge.identifier.substring(separatorIdx + 1);
|
|
909
|
+
const descriptor = message.descriptor as Record<string, unknown>;
|
|
910
|
+
const tags = descriptor.tags as Record<string, unknown> | undefined;
|
|
911
|
+
|
|
912
|
+
const isContextKeyRecord = descriptor.interface === 'Records' &&
|
|
913
|
+
descriptor.method === 'Write' &&
|
|
914
|
+
descriptor.protocol === 'https://identity.foundation/protocols/key-delivery' &&
|
|
915
|
+
descriptor.protocolPath === 'contextKey' &&
|
|
916
|
+
tags?.protocol === sourceProtocol &&
|
|
917
|
+
tags?.contextId === rootContextId;
|
|
918
|
+
if (!isContextKeyRecord) {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (scope.kind === 'recordsProjection') {
|
|
923
|
+
return scope.scopes.some(scopeEntry => PermissionScopeMatcher.matches(scopeEntry, {
|
|
924
|
+
protocol : sourceProtocol,
|
|
925
|
+
contextId : rootContextId,
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const coveredProtocols = protocolsForSyncScope(scope);
|
|
930
|
+
return coveredProtocols?.includes(sourceProtocol) === true;
|
|
931
|
+
}
|
|
932
|
+
|
|
753
933
|
/**
|
|
754
934
|
* Resolve a dependency edge by querying the local MessageStore.
|
|
755
935
|
* Returns the resolved message or null if not found.
|
|
@@ -17,7 +17,7 @@ export enum ClosureFailureCode {
|
|
|
17
17
|
ParentChainMissing = 'ClosureParentChainMissing',
|
|
18
18
|
/** Class 2: A context ancestor record is missing. */
|
|
19
19
|
ContextChainMissing = 'ClosureContextChainMissing',
|
|
20
|
-
/** Class 3: A permission grant referenced by
|
|
20
|
+
/** Class 3: A permission grant referenced by a grant invocation is missing. */
|
|
21
21
|
GrantMissing = 'ClosureGrantMissing',
|
|
22
22
|
/** Class 3: The grant exists but is not yet active at the message's timestamp. */
|
|
23
23
|
GrantNotYetActive = 'ClosureGrantNotYetActive',
|
|
@@ -37,6 +37,18 @@ export enum ClosureFailureCode {
|
|
|
37
37
|
DepthExceeded = 'ClosureDepthExceeded',
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const TERMINAL_CLOSURE_FAILURE_CODES = new Set<string>([
|
|
41
|
+
ClosureFailureCode.DependencyForbidden,
|
|
42
|
+
ClosureFailureCode.GrantExpired,
|
|
43
|
+
ClosureFailureCode.GrantNotYetActive,
|
|
44
|
+
ClosureFailureCode.GrantRevocationMissing,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** Returns true when repair cannot make the closure failure complete. */
|
|
48
|
+
export function isTerminalClosureFailureCode(failureCode: string): boolean {
|
|
49
|
+
return TERMINAL_CLOSURE_FAILURE_CODES.has(failureCode);
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
// ---------------------------------------------------------------------------
|
|
41
53
|
// Closure dependency edge
|
|
42
54
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +69,13 @@ export type ClosureDependencyEdge = {
|
|
|
57
69
|
identifier: string;
|
|
58
70
|
/** The type of identifier — determines the fetch strategy. */
|
|
59
71
|
identifierType: 'messageCid' | 'recordId' | 'protocol' | 'grantId' | 'filter';
|
|
72
|
+
/**
|
|
73
|
+
* Protocol the resolved dependency must belong to when the dependency is
|
|
74
|
+
* expected to resolve outside the current primary sync scope. This is used
|
|
75
|
+
* for protocol-derived support records such as cross-protocol `$ref` parents
|
|
76
|
+
* and role records.
|
|
77
|
+
*/
|
|
78
|
+
expectedProtocol?: string;
|
|
60
79
|
/**
|
|
61
80
|
* When `identifierType` is `'filter'`, this carries the full query filter
|
|
62
81
|
* as a structured object. Used for dependencies that require multi-field
|
|
@@ -111,8 +130,10 @@ export type ClosureEvaluationContext = {
|
|
|
111
130
|
grantCache: Map<string, GenericMessage | null>;
|
|
112
131
|
/**
|
|
113
132
|
* Set of dependency identifiers already known to be locally present.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
133
|
+
* Keys include the sync scope, expected dependency protocol when one is
|
|
134
|
+
* known, and `${label}:${identifierType}:${identifier}` so dependencies
|
|
135
|
+
* proven under one scoped link cannot satisfy another link's closure check
|
|
136
|
+
* without being revalidated for that scope and dependency policy.
|
|
116
137
|
*/
|
|
117
138
|
satisfiedDeps: Set<string>;
|
|
118
139
|
/**
|
|
@@ -196,8 +217,8 @@ export function invalidateClosureCache(
|
|
|
196
217
|
if (protocolPath === 'grant' && recordId) {
|
|
197
218
|
// Grant write → invalidate the grant cache and dep keys.
|
|
198
219
|
context.grantCache.delete(recordId);
|
|
199
|
-
context.satisfiedDeps
|
|
200
|
-
context.missingDeps
|
|
220
|
+
deleteDependencyCacheEntries(context.satisfiedDeps, `permissionGrant:grantId:${recordId}`);
|
|
221
|
+
deleteDependencyCacheEntries(context.missingDeps, `permissionGrant:grantId:${recordId}`);
|
|
201
222
|
}
|
|
202
223
|
|
|
203
224
|
if (protocolPath === 'grant/revocation') {
|
|
@@ -205,11 +226,11 @@ export function invalidateClosureCache(
|
|
|
205
226
|
const parentId = desc.parentId as string | undefined;
|
|
206
227
|
if (parentId) {
|
|
207
228
|
context.grantCache.delete(`revocation:${parentId}`);
|
|
208
|
-
context.satisfiedDeps
|
|
209
|
-
context.missingDeps
|
|
229
|
+
deleteDependencyCacheEntries(context.satisfiedDeps, `grantRevocation:grantId:${parentId}`);
|
|
230
|
+
deleteDependencyCacheEntries(context.missingDeps, `grantRevocation:grantId:${parentId}`);
|
|
210
231
|
// Also invalidate the grant itself since its revocation state changed.
|
|
211
|
-
context.satisfiedDeps
|
|
212
|
-
context.missingDeps
|
|
232
|
+
deleteDependencyCacheEntries(context.satisfiedDeps, `permissionGrant:grantId:${parentId}`);
|
|
233
|
+
deleteDependencyCacheEntries(context.missingDeps, `permissionGrant:grantId:${parentId}`);
|
|
213
234
|
}
|
|
214
235
|
}
|
|
215
236
|
}
|
|
@@ -291,3 +312,11 @@ export function invalidateClosureCache(
|
|
|
291
312
|
}
|
|
292
313
|
}
|
|
293
314
|
}
|
|
315
|
+
|
|
316
|
+
function deleteDependencyCacheEntries(cache: Set<string>, dependencyKeySuffix: string): void {
|
|
317
|
+
for (const key of cache) {
|
|
318
|
+
if (key.endsWith(dependencyKeySuffix)) {
|
|
319
|
+
cache.delete(key);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|