@enbox/agent 0.5.9 → 0.5.11
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.map +1 -1
- package/dist/esm/dwn-record-upgrade.js +1 -1
- package/dist/esm/dwn-record-upgrade.js.map +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +855 -0
- package/dist/esm/sync-closure-resolver.js.map +1 -0
- package/dist/esm/sync-closure-types.js +189 -0
- package/dist/esm/sync-closure-types.js.map +1 -0
- package/dist/esm/sync-engine-level.js +977 -224
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-messages.js +19 -5
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/esm/sync-replication-ledger.js +220 -0
- package/dist/esm/sync-replication-ledger.js.map +1 -0
- package/dist/esm/types/sync.js +54 -1
- 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 +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/sync-closure-resolver.d.ts +19 -0
- package/dist/types/sync-closure-resolver.d.ts.map +1 -0
- package/dist/types/sync-closure-types.d.ts +122 -0
- package/dist/types/sync-closure-types.d.ts.map +1 -0
- package/dist/types/sync-engine-level.d.ts +137 -11
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +6 -1
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/sync-replication-ledger.d.ts +72 -0
- package/dist/types/sync-replication-ledger.d.ts.map +1 -0
- package/dist/types/types/sync.d.ts +188 -0
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/dwn-api.ts +2 -1
- package/src/dwn-record-upgrade.ts +1 -1
- package/src/index.ts +5 -0
- package/src/sync-closure-resolver.ts +919 -0
- package/src/sync-closure-types.ts +270 -0
- package/src/sync-engine-level.ts +1062 -255
- package/src/sync-messages.ts +21 -6
- package/src/sync-replication-ledger.ts +197 -0
- package/src/types/sync.ts +202 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import type { SyncScope } from './types/sync.js';
|
|
2
|
+
import type {
|
|
3
|
+
ClosureDependencyEdge,
|
|
4
|
+
ClosureEvaluationContext,
|
|
5
|
+
ClosureResult,
|
|
6
|
+
} from './sync-closure-types.js';
|
|
7
|
+
import type { GenericMessage, MessageStore } from '@enbox/dwn-sdk-js';
|
|
8
|
+
|
|
9
|
+
import { Message } from '@enbox/dwn-sdk-js';
|
|
10
|
+
|
|
11
|
+
import { isMultiPartyContext } from './protocol-utils.js';
|
|
12
|
+
import { ClosureFailureCode, createClosureContext } from './sync-closure-types.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Dependency extraction helpers (one per dependency class)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Class 1: Protocol metadata closure.
|
|
20
|
+
* Extract the protocol URI from the message descriptor. The ProtocolsConfigure
|
|
21
|
+
* for that protocol must be present locally.
|
|
22
|
+
*/
|
|
23
|
+
function extractProtocolDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
24
|
+
const desc = message.descriptor as Record<string, unknown>;
|
|
25
|
+
const protocol = desc.protocol as string | undefined;
|
|
26
|
+
if (!protocol) { return []; }
|
|
27
|
+
|
|
28
|
+
return [{
|
|
29
|
+
dependencyClass : 1,
|
|
30
|
+
label : 'protocolsConfigure',
|
|
31
|
+
identifier : protocol,
|
|
32
|
+
identifierType : 'protocol',
|
|
33
|
+
}];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Class 2: Record ancestry closure.
|
|
38
|
+
* - initialWrite (for non-initial writes)
|
|
39
|
+
* - parentId chain
|
|
40
|
+
*/
|
|
41
|
+
function extractAncestryDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
42
|
+
const desc = message.descriptor as Record<string, unknown>;
|
|
43
|
+
const edges: ClosureDependencyEdge[] = [];
|
|
44
|
+
|
|
45
|
+
// Only Records interface messages have ancestry dependencies.
|
|
46
|
+
if (desc.interface !== 'Records') { return []; }
|
|
47
|
+
|
|
48
|
+
const recordId = (message as any).recordId as string | undefined;
|
|
49
|
+
|
|
50
|
+
// parentId dependency — the parent record must be present.
|
|
51
|
+
const parentId = desc.parentId as string | undefined;
|
|
52
|
+
if (parentId) {
|
|
53
|
+
edges.push({
|
|
54
|
+
dependencyClass : 2,
|
|
55
|
+
label : 'parentRecord',
|
|
56
|
+
identifier : parentId,
|
|
57
|
+
identifierType : 'recordId',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// contextId dependency — if the record has a contextId that differs from
|
|
62
|
+
// its own recordId, the context root record must be present. The contextId
|
|
63
|
+
// is a hierarchical path of recordIds (e.g., "rootId/childId/grandchildId").
|
|
64
|
+
// The context root is the first segment.
|
|
65
|
+
const contextId = (message as any).contextId as string | undefined;
|
|
66
|
+
if (contextId && recordId && contextId !== recordId) {
|
|
67
|
+
const contextRootId = contextId.split('/')[0];
|
|
68
|
+
edges.push({
|
|
69
|
+
dependencyClass : 2,
|
|
70
|
+
label : 'contextRoot',
|
|
71
|
+
identifier : contextRootId,
|
|
72
|
+
identifierType : 'recordId',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// initialWrite dependency — non-initial writes need their initialWrite.
|
|
77
|
+
// An initial write has entryId === recordId, but we can't compute entryId here
|
|
78
|
+
// without the full CID computation. Instead, check dateCreated vs messageTimestamp
|
|
79
|
+
// as a heuristic, then the resolver will verify via the message store.
|
|
80
|
+
if (recordId && desc.method === 'Write') {
|
|
81
|
+
const dateCreated = desc.dateCreated as string | undefined;
|
|
82
|
+
const messageTimestamp = desc.messageTimestamp as string | undefined;
|
|
83
|
+
if (dateCreated && messageTimestamp && dateCreated !== messageTimestamp) {
|
|
84
|
+
// Non-initial write — needs the initialWrite.
|
|
85
|
+
edges.push({
|
|
86
|
+
dependencyClass : 2,
|
|
87
|
+
label : 'initialWrite',
|
|
88
|
+
identifier : recordId,
|
|
89
|
+
identifierType : 'recordId',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// RecordsDelete also needs the initialWrite for authorization and index construction.
|
|
95
|
+
if (recordId && desc.method === 'Delete') {
|
|
96
|
+
edges.push({
|
|
97
|
+
dependencyClass : 2,
|
|
98
|
+
label : 'initialWrite',
|
|
99
|
+
identifier : recordId,
|
|
100
|
+
identifierType : 'recordId',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return edges;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Class 3: Authorization closure.
|
|
109
|
+
* If the message uses a permissionGrantId, the grant record must be present.
|
|
110
|
+
*/
|
|
111
|
+
function extractAuthorizationDeps(message: GenericMessage): ClosureDependencyEdge[] {
|
|
112
|
+
const edges: ClosureDependencyEdge[] = [];
|
|
113
|
+
const auth = (message as any).authorization;
|
|
114
|
+
if (!auth) { return []; }
|
|
115
|
+
|
|
116
|
+
// The signature payload is at authorization.signature.payload (GeneralJws).
|
|
117
|
+
// This is the base64url-encoded JSON containing permissionGrantId, protocolRole, etc.
|
|
118
|
+
const payload = auth.signature?.payload;
|
|
119
|
+
if (payload) {
|
|
120
|
+
try {
|
|
121
|
+
// Payload is base64url-encoded JSON.
|
|
122
|
+
const decoded = JSON.parse(
|
|
123
|
+
Buffer.from(payload, 'base64url').toString('utf-8')
|
|
124
|
+
);
|
|
125
|
+
if (decoded.permissionGrantId) {
|
|
126
|
+
edges.push({
|
|
127
|
+
dependencyClass : 3,
|
|
128
|
+
label : 'permissionGrant',
|
|
129
|
+
identifier : decoded.permissionGrantId,
|
|
130
|
+
identifierType : 'grantId',
|
|
131
|
+
});
|
|
132
|
+
// Also require the grant's revocation state to be resolvable.
|
|
133
|
+
// The revocation is a child record at protocolPath 'grant/revocation'
|
|
134
|
+
// with parentId === grantId. We add it as a separate edge so the
|
|
135
|
+
// resolver can check for its presence (or confirmed absence).
|
|
136
|
+
edges.push({
|
|
137
|
+
dependencyClass : 3,
|
|
138
|
+
label : 'grantRevocation',
|
|
139
|
+
identifier : decoded.permissionGrantId,
|
|
140
|
+
identifierType : 'grantId',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// If we can't decode, skip — authorization will fail at apply time.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return edges;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Protocol-definition-aware dependency extraction (classes 4, 5, 6)
|
|
153
|
+
//
|
|
154
|
+
// These classes require the ProtocolDefinition to be available (fetched by
|
|
155
|
+
// class 1 resolution). They are called during BFS traversal, not as static
|
|
156
|
+
// extractors from the message alone.
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Look up the ProtocolRuleSet for a given protocolPath within a protocol
|
|
161
|
+
* structure tree. Returns undefined if the path doesn't exist.
|
|
162
|
+
*
|
|
163
|
+
* Mirrors `getRuleSetAtPath` from `@enbox/dwn-sdk-js/src/utils/protocols.ts`
|
|
164
|
+
* (not in public API, reimplemented here to avoid internal path coupling).
|
|
165
|
+
*/
|
|
166
|
+
function getRuleSetAtProtocolPath(
|
|
167
|
+
structure: Record<string, any> | undefined,
|
|
168
|
+
protocolPath: string,
|
|
169
|
+
): any | undefined {
|
|
170
|
+
if (!structure || !protocolPath) { return undefined; }
|
|
171
|
+
|
|
172
|
+
const segments = protocolPath.split('/');
|
|
173
|
+
let currentLevel: Record<string, any> = structure;
|
|
174
|
+
let current: any;
|
|
175
|
+
|
|
176
|
+
for (const segment of segments) {
|
|
177
|
+
if (!Object.hasOwn(currentLevel, segment)) { return undefined; }
|
|
178
|
+
current = currentLevel[segment];
|
|
179
|
+
currentLevel = current;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract protocol-definition-aware dependencies for classes 4, 5, and 6.
|
|
187
|
+
* Called during BFS traversal after the ProtocolDefinition has been fetched
|
|
188
|
+
* and cached (class 1 resolution ensures this).
|
|
189
|
+
*
|
|
190
|
+
* @param message - The message being evaluated.
|
|
191
|
+
* @param protocolDef - The cached ProtocolDefinition (from class 1 resolution).
|
|
192
|
+
* @param context - The evaluation context (for accessing `uses` map resolution).
|
|
193
|
+
*/
|
|
194
|
+
function extractProtocolAwareDeps(
|
|
195
|
+
message: GenericMessage,
|
|
196
|
+
protocolDef: any,
|
|
197
|
+
): ClosureDependencyEdge[] {
|
|
198
|
+
const desc = message.descriptor as Record<string, unknown>;
|
|
199
|
+
if (desc.interface !== 'Records') { return []; }
|
|
200
|
+
|
|
201
|
+
const protocolPath = desc.protocolPath as string | undefined;
|
|
202
|
+
if (!protocolPath) { return []; }
|
|
203
|
+
|
|
204
|
+
const edges: ClosureDependencyEdge[] = [];
|
|
205
|
+
const ruleSet = getRuleSetAtProtocolPath(protocolDef?.structure, protocolPath);
|
|
206
|
+
|
|
207
|
+
// --- Class 4: Squash / visibility floor ---
|
|
208
|
+
// The runtime's squash backstop derives scope entirely from the incoming
|
|
209
|
+
// message's own contextId (via Records.getParentContextFromOfContextId).
|
|
210
|
+
// It does NOT fetch any additional record to determine scope — the contextId
|
|
211
|
+
// is part of the message, and the $squash rule is in the ProtocolsConfigure
|
|
212
|
+
// (already a class 1 dependency). So no additional closure dependency is
|
|
213
|
+
// needed for squash. processRawMessage triggers the DWN's built-in squash
|
|
214
|
+
// resumable task which handles purging internally.
|
|
215
|
+
//
|
|
216
|
+
// Future work: if subset-specific squash side-effects are needed (where the
|
|
217
|
+
// consumer has records that the source purged), that belongs in the engine's
|
|
218
|
+
// post-apply logic, not in closure dependency extraction.
|
|
219
|
+
|
|
220
|
+
// --- Class 5: Encryption / key-delivery closure ---
|
|
221
|
+
// Two levels of dependency:
|
|
222
|
+
//
|
|
223
|
+
// Level 1 (protocol-definition): The ProtocolsConfigure with injected $encryption
|
|
224
|
+
// key material must be present. This is satisfied by class 1.
|
|
225
|
+
//
|
|
226
|
+
// Level 2 (key-delivery records): For multi-party contexts (where records use
|
|
227
|
+
// ProtocolContext encryption), the context key record in the key-delivery protocol
|
|
228
|
+
// must be locally present for the consumer to decrypt the record. Context keys are
|
|
229
|
+
// stored at protocolPath 'contextKey' in the key-delivery protocol, tagged with
|
|
230
|
+
// the source protocol URI and the root contextId.
|
|
231
|
+
if (ruleSet?.$encryption) {
|
|
232
|
+
const typeName = protocolPath.split('/').pop();
|
|
233
|
+
const typeDef = protocolDef?.types?.[typeName ?? ''];
|
|
234
|
+
if (typeDef?.encryptionRequired === true) {
|
|
235
|
+
// Level 1: protocol definition edge (already satisfied by class 1 ProtocolsConfigure).
|
|
236
|
+
edges.push({
|
|
237
|
+
dependencyClass : 5,
|
|
238
|
+
label : 'encryptionKeyMaterial',
|
|
239
|
+
identifier : desc.protocol as string,
|
|
240
|
+
identifierType : 'protocol',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Level 2: key-delivery protocol record.
|
|
244
|
+
// The key-delivery protocol must be installed locally.
|
|
245
|
+
const keyDeliveryProtocol = 'https://identity.foundation/protocols/key-delivery';
|
|
246
|
+
edges.push({
|
|
247
|
+
dependencyClass : 5,
|
|
248
|
+
label : 'keyDeliveryProtocol',
|
|
249
|
+
identifier : keyDeliveryProtocol,
|
|
250
|
+
identifierType : 'protocol',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Level 3: Context key enforcement for multi-party contexts.
|
|
254
|
+
// Uses isMultiPartyContext() from protocol-utils to determine whether the
|
|
255
|
+
// record's root protocol path has $role descendants or relational who/of
|
|
256
|
+
// read rules — these indicate multi-party access where ProtocolContext
|
|
257
|
+
// encryption is used and a contextKey record is required for decryption.
|
|
258
|
+
//
|
|
259
|
+
// Single-party contexts use ProtocolPath encryption (owner-only) and do
|
|
260
|
+
// NOT need a context key — the edge is skipped entirely.
|
|
261
|
+
const contextId = (message as any).contextId as string | undefined;
|
|
262
|
+
if (contextId) {
|
|
263
|
+
const rootProtocolPath = protocolPath.split('/')[0];
|
|
264
|
+
const multiParty = isMultiPartyContext(protocolDef, rootProtocolPath);
|
|
265
|
+
if (multiParty) {
|
|
266
|
+
const rootContextId = contextId.split('/')[0];
|
|
267
|
+
// Separator is '|' (not ':') because protocol URIs contain '://'
|
|
268
|
+
// which would break indexOf(':') parsing in resolveDependency.
|
|
269
|
+
edges.push({
|
|
270
|
+
dependencyClass : 5,
|
|
271
|
+
label : 'contextKeyRecord',
|
|
272
|
+
identifier : `${desc.protocol}|${rootContextId}`,
|
|
273
|
+
identifierType : 'messageCid',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- Class 6: Cross-protocol $ref closure ---
|
|
281
|
+
// Three levels of dependency when a record is at or below a $ref node:
|
|
282
|
+
//
|
|
283
|
+
// Level 1 (protocol config): The referenced protocol's ProtocolsConfigure must be present.
|
|
284
|
+
// Level 2 (parent record): If the record's path has exactly 2 segments (e.g., thread/comment),
|
|
285
|
+
// the parent record lives in the REFERENCED protocol, not the composing protocol.
|
|
286
|
+
// Level 3 (cross-protocol role): If the message's authorization invokes a cross-protocol role
|
|
287
|
+
// (format "alias:path"), the role record in the referenced protocol must be locally present.
|
|
288
|
+
const firstSegment = protocolPath.split('/')[0];
|
|
289
|
+
const rootRuleSet = protocolDef?.structure?.[firstSegment];
|
|
290
|
+
if (rootRuleSet?.$ref) {
|
|
291
|
+
const colonIdx = rootRuleSet.$ref.indexOf(':');
|
|
292
|
+
if (colonIdx > 0) {
|
|
293
|
+
const alias = rootRuleSet.$ref.substring(0, colonIdx);
|
|
294
|
+
const usesMap = protocolDef?.uses;
|
|
295
|
+
const referencedProtocol = usesMap?.[alias];
|
|
296
|
+
if (referencedProtocol) {
|
|
297
|
+
// Level 1: referenced protocol ProtocolsConfigure.
|
|
298
|
+
edges.push({
|
|
299
|
+
dependencyClass : 6,
|
|
300
|
+
label : 'crossProtocolConfig',
|
|
301
|
+
identifier : referencedProtocol,
|
|
302
|
+
identifierType : 'protocol',
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Level 2: $ref parent record in the referenced protocol.
|
|
306
|
+
// When path depth is 2 (e.g., "thread/comment"), the parent is the $ref node
|
|
307
|
+
// itself, which lives in the referenced protocol. The parent's recordId is
|
|
308
|
+
// the message's parentId. The query MUST be scoped to the referenced
|
|
309
|
+
// protocol URI to prevent false matches from the composing protocol.
|
|
310
|
+
// Identifier format: "referencedProtocol|parentId"
|
|
311
|
+
const pathSegments = protocolPath.split('/');
|
|
312
|
+
const parentId = desc.parentId as string | undefined;
|
|
313
|
+
if (pathSegments.length === 2 && parentId) {
|
|
314
|
+
edges.push({
|
|
315
|
+
dependencyClass : 6,
|
|
316
|
+
label : 'crossProtocolParent',
|
|
317
|
+
identifier : `${referencedProtocol}|${parentId}`,
|
|
318
|
+
identifierType : 'recordId',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Level 3: Cross-protocol role records.
|
|
326
|
+
// If the message's authorization invokes a role in "alias:path" format,
|
|
327
|
+
// the role record must be locally present in the referenced protocol.
|
|
328
|
+
// This is extracted from the authorization payload regardless of $ref presence.
|
|
329
|
+
const auth = (message as any).authorization;
|
|
330
|
+
if (auth && protocolDef?.uses) {
|
|
331
|
+
// Same payload path as class 3: authorization.signature.payload
|
|
332
|
+
const payload = auth.signature?.payload;
|
|
333
|
+
if (payload) {
|
|
334
|
+
try {
|
|
335
|
+
const decoded = JSON.parse(
|
|
336
|
+
Buffer.from(payload, 'base64url').toString('utf-8')
|
|
337
|
+
);
|
|
338
|
+
const protocolRole = decoded.protocolRole as string | undefined;
|
|
339
|
+
if (protocolRole && protocolRole.includes(':')) {
|
|
340
|
+
// Cross-protocol role: "alias:protocolPath"
|
|
341
|
+
const roleColonIdx = protocolRole.indexOf(':');
|
|
342
|
+
const roleAlias = protocolRole.substring(0, roleColonIdx);
|
|
343
|
+
const roleProtocol = protocolDef.uses[roleAlias];
|
|
344
|
+
if (roleProtocol) {
|
|
345
|
+
// The referenced protocol's ProtocolsConfigure (may already be added above).
|
|
346
|
+
edges.push({
|
|
347
|
+
dependencyClass : 6,
|
|
348
|
+
label : 'crossProtocolRoleConfig',
|
|
349
|
+
identifier : roleProtocol,
|
|
350
|
+
identifierType : 'protocol',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// The actual role record in the referenced protocol.
|
|
354
|
+
// Mirrors verifyInvokedRole() from protocol-authorization-action.ts:
|
|
355
|
+
// queries by protocol + protocolPath + recipient + contextId prefix.
|
|
356
|
+
const roleProtocolPath = protocolRole.substring(roleColonIdx + 1);
|
|
357
|
+
// Derive author from the message's JWS signature, not from the
|
|
358
|
+
// payload. The payload does NOT contain an author field — the
|
|
359
|
+
// DWN SDK extracts it from the signature's `kid` header via
|
|
360
|
+
// Message.getAuthor().
|
|
361
|
+
const messageAuthor = Message.getAuthor(message);
|
|
362
|
+
const messageContextId = (message as any).contextId as string | undefined;
|
|
363
|
+
|
|
364
|
+
if (messageAuthor) {
|
|
365
|
+
// Build the role record query filter.
|
|
366
|
+
const roleFilter: Record<string, unknown> = {
|
|
367
|
+
interface : 'Records',
|
|
368
|
+
method : 'Write',
|
|
369
|
+
protocol : roleProtocol,
|
|
370
|
+
protocolPath : roleProtocolPath,
|
|
371
|
+
recipient : messageAuthor,
|
|
372
|
+
isLatestBaseState : true,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Context scoping: the role record must be within the same context.
|
|
376
|
+
// The ancestor segment count determines how many contextId segments
|
|
377
|
+
// to use as the prefix filter (matching verifyInvokedRole logic).
|
|
378
|
+
if (messageContextId) {
|
|
379
|
+
const ancestorCount = roleProtocolPath.split('/').length - 1;
|
|
380
|
+
if (ancestorCount > 0) {
|
|
381
|
+
const contextSegments = messageContextId.split('/');
|
|
382
|
+
const contextPrefix = contextSegments.slice(0, ancestorCount).join('/');
|
|
383
|
+
if (contextPrefix) {
|
|
384
|
+
roleFilter.contextId = { gte: contextPrefix, lt: contextPrefix + '\uffff' };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Cache key must include the context prefix to prevent false
|
|
390
|
+
// positives across different contexts (e.g., different threads).
|
|
391
|
+
const contextPrefix = roleFilter.contextId
|
|
392
|
+
? JSON.stringify(roleFilter.contextId) : 'no-context';
|
|
393
|
+
edges.push({
|
|
394
|
+
dependencyClass : 6,
|
|
395
|
+
label : 'crossProtocolRoleRecord',
|
|
396
|
+
identifier : `${roleProtocol}|${roleProtocolPath}|${messageAuthor}|${contextPrefix}`,
|
|
397
|
+
identifierType : 'filter',
|
|
398
|
+
filter : roleFilter,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
// If we can't decode the payload, skip.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return edges;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Closure resolver
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Evaluates closure completeness for a single operation (closure root).
|
|
418
|
+
*
|
|
419
|
+
* Uses BFS traversal with deduplication and depth limiting. Shared caching
|
|
420
|
+
* across evaluation batches via {@link ClosureEvaluationContext}.
|
|
421
|
+
*
|
|
422
|
+
* The resolver queries the local MessageStore directly (bypassing auth)
|
|
423
|
+
* because it needs to verify dependency presence, not access-control the
|
|
424
|
+
* syncing agent's own local store.
|
|
425
|
+
*/
|
|
426
|
+
export async function evaluateClosure(
|
|
427
|
+
message: GenericMessage,
|
|
428
|
+
messageStore: MessageStore,
|
|
429
|
+
scope: SyncScope,
|
|
430
|
+
context: ClosureEvaluationContext,
|
|
431
|
+
): Promise<ClosureResult> {
|
|
432
|
+
// Full-tenant scope bypasses closure evaluation entirely.
|
|
433
|
+
if (scope.kind === 'full') {
|
|
434
|
+
const cid = await Message.getCid(message);
|
|
435
|
+
return {
|
|
436
|
+
complete : true,
|
|
437
|
+
rootMessageCid : cid,
|
|
438
|
+
edges : [],
|
|
439
|
+
depth : 0,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const rootCid = await Message.getCid(message);
|
|
444
|
+
const allEdges: ClosureDependencyEdge[] = [];
|
|
445
|
+
const visited = new Set<string>();
|
|
446
|
+
let currentDepth = 0;
|
|
447
|
+
|
|
448
|
+
// BFS queue: each item is a message to evaluate for dependencies.
|
|
449
|
+
const queue: GenericMessage[] = [message];
|
|
450
|
+
visited.add(rootCid);
|
|
451
|
+
|
|
452
|
+
while (queue.length > 0) {
|
|
453
|
+
if (currentDepth >= context.maxDepth) {
|
|
454
|
+
return {
|
|
455
|
+
complete : false,
|
|
456
|
+
rootMessageCid : rootCid,
|
|
457
|
+
edges : allEdges,
|
|
458
|
+
failure : {
|
|
459
|
+
code : ClosureFailureCode.DepthExceeded,
|
|
460
|
+
edge : { dependencyClass: 1, label: 'depth', identifier: String(currentDepth), identifierType: 'messageCid' },
|
|
461
|
+
detail : `closure traversal exceeded max depth of ${context.maxDepth}`,
|
|
462
|
+
},
|
|
463
|
+
depth: currentDepth,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const batchSize = queue.length;
|
|
468
|
+
for (let i = 0; i < batchSize; i++) {
|
|
469
|
+
const current = queue.shift()!;
|
|
470
|
+
|
|
471
|
+
// Phase 1: Extract and resolve static dependency edges (classes 1-3).
|
|
472
|
+
// This populates the protocolCache when class 1 (ProtocolsConfigure)
|
|
473
|
+
// is resolved, which is needed by classes 4-6.
|
|
474
|
+
const staticEdges = [
|
|
475
|
+
...extractProtocolDeps(current),
|
|
476
|
+
...extractAncestryDeps(current),
|
|
477
|
+
...extractAuthorizationDeps(current),
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
const resolveResult = await resolveEdges(
|
|
481
|
+
staticEdges, allEdges, messageStore, context, visited, queue, rootCid, currentDepth
|
|
482
|
+
);
|
|
483
|
+
if (resolveResult) { return resolveResult; } // Early failure.
|
|
484
|
+
|
|
485
|
+
// Phase 2: Extract protocol-definition-aware edges (classes 4-6).
|
|
486
|
+
// Runs AFTER static resolution so the ProtocolDefinition is in the cache.
|
|
487
|
+
const currentDesc = current.descriptor as Record<string, unknown>;
|
|
488
|
+
const currentProtocol = currentDesc.protocol as string | undefined;
|
|
489
|
+
if (currentProtocol) {
|
|
490
|
+
const cachedProtocolMsg = context.protocolCache.get(currentProtocol);
|
|
491
|
+
const protocolDef = (cachedProtocolMsg?.descriptor as any)?.definition;
|
|
492
|
+
if (protocolDef) {
|
|
493
|
+
const protoAwareEdges = extractProtocolAwareDeps(current, protocolDef);
|
|
494
|
+
const protoResult = await resolveEdges(
|
|
495
|
+
protoAwareEdges, allEdges, messageStore, context, visited, queue, rootCid, currentDepth
|
|
496
|
+
);
|
|
497
|
+
if (protoResult) { return protoResult; }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Phase 3: Validate grant temporal ordering (causal grant check).
|
|
502
|
+
// After all edges are resolved, if this message uses a grant, verify
|
|
503
|
+
// that the grant is temporally valid at the message's commit point:
|
|
504
|
+
// - grant.dateGranted <= message.messageTimestamp < grant.dateExpires
|
|
505
|
+
// - no revocation exists with revocation.messageTimestamp <= message.messageTimestamp
|
|
506
|
+
// This runs only for the root message (currentDepth === 0) to avoid
|
|
507
|
+
// redundant checks on transitive dependencies.
|
|
508
|
+
if (currentDepth === 0) {
|
|
509
|
+
const grantCheckResult = validateGrantTemporal(
|
|
510
|
+
current, allEdges, context, rootCid, currentDepth
|
|
511
|
+
);
|
|
512
|
+
if (grantCheckResult) { return grantCheckResult; }
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
currentDepth++;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
complete : true,
|
|
521
|
+
rootMessageCid : rootCid,
|
|
522
|
+
edges : allEdges,
|
|
523
|
+
depth : currentDepth,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Evaluate closure for a batch of messages. Shares caching across all roots.
|
|
529
|
+
*/
|
|
530
|
+
export async function evaluateClosureBatch(
|
|
531
|
+
messages: GenericMessage[],
|
|
532
|
+
messageStore: MessageStore,
|
|
533
|
+
scope: SyncScope,
|
|
534
|
+
tenantDid: string,
|
|
535
|
+
maxDepth?: number,
|
|
536
|
+
): Promise<ClosureResult[]> {
|
|
537
|
+
const context = createClosureContext(tenantDid, maxDepth);
|
|
538
|
+
const results: ClosureResult[] = [];
|
|
539
|
+
|
|
540
|
+
for (const msg of messages) {
|
|
541
|
+
const result = await evaluateClosure(msg, messageStore, scope, context);
|
|
542
|
+
results.push(result);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return results;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Causal grant ordering validation
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Validates that any permission grant used by the message is temporally valid
|
|
554
|
+
* at the message's commit point. Returns a failure result if the grant is
|
|
555
|
+
* expired or revoked at the message's timestamp, null if valid or no grant used.
|
|
556
|
+
*
|
|
557
|
+
* Temporal model (from grant-authorization.ts):
|
|
558
|
+
* grant.dateGranted <= message.messageTimestamp < grant.dateExpires
|
|
559
|
+
* AND (no revocation OR revocation.messageTimestamp > message.messageTimestamp)
|
|
560
|
+
*/
|
|
561
|
+
function validateGrantTemporal(
|
|
562
|
+
message: GenericMessage,
|
|
563
|
+
allEdges: ClosureDependencyEdge[],
|
|
564
|
+
context: ClosureEvaluationContext,
|
|
565
|
+
rootCid: string,
|
|
566
|
+
currentDepth: number,
|
|
567
|
+
): ClosureResult | null {
|
|
568
|
+
// Check if this message has a grant dependency.
|
|
569
|
+
const grantEdge = allEdges.find(e => e.label === 'permissionGrant');
|
|
570
|
+
if (!grantEdge) { return null; } // No grant used.
|
|
571
|
+
|
|
572
|
+
const messageTimestamp = (message.descriptor as any).messageTimestamp as string;
|
|
573
|
+
if (!messageTimestamp) { return null; }
|
|
574
|
+
|
|
575
|
+
// Look up the resolved grant record from the cache.
|
|
576
|
+
const grantRecord = context.grantCache.get(grantEdge.identifier);
|
|
577
|
+
if (!grantRecord) { return null; } // Grant wasn't resolved (would have failed earlier).
|
|
578
|
+
|
|
579
|
+
// Extract grant temporal bounds.
|
|
580
|
+
// dateGranted = descriptor.dateCreated (set by PermissionGrant constructor)
|
|
581
|
+
const dateGranted = (grantRecord.descriptor as any).dateCreated as string | undefined;
|
|
582
|
+
// dateExpires is in the encoded data payload (base64url JSON).
|
|
583
|
+
let dateExpires: string | undefined;
|
|
584
|
+
const encodedData = (grantRecord as any).encodedData as string | undefined;
|
|
585
|
+
if (encodedData) {
|
|
586
|
+
try {
|
|
587
|
+
const grantData = JSON.parse(Buffer.from(encodedData, 'base64url').toString('utf-8'));
|
|
588
|
+
dateExpires = grantData.dateExpires;
|
|
589
|
+
} catch { /* ignore parse errors */ }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Check: grant not yet active.
|
|
593
|
+
if (dateGranted && messageTimestamp < dateGranted) {
|
|
594
|
+
return {
|
|
595
|
+
complete : false,
|
|
596
|
+
rootMessageCid : rootCid,
|
|
597
|
+
edges : allEdges,
|
|
598
|
+
failure : {
|
|
599
|
+
code : ClosureFailureCode.GrantNotYetActive,
|
|
600
|
+
edge : grantEdge,
|
|
601
|
+
detail : `grant '${grantEdge.identifier}' is not yet active at message timestamp ${messageTimestamp} (dateGranted: ${dateGranted})`,
|
|
602
|
+
},
|
|
603
|
+
depth: currentDepth,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Check: grant expired.
|
|
608
|
+
if (dateExpires && messageTimestamp >= dateExpires) {
|
|
609
|
+
return {
|
|
610
|
+
complete : false,
|
|
611
|
+
rootMessageCid : rootCid,
|
|
612
|
+
edges : allEdges,
|
|
613
|
+
failure : {
|
|
614
|
+
code : ClosureFailureCode.GrantExpired,
|
|
615
|
+
edge : grantEdge,
|
|
616
|
+
detail : `grant '${grantEdge.identifier}' expired at ${dateExpires}, message timestamp is ${messageTimestamp}`,
|
|
617
|
+
},
|
|
618
|
+
depth: currentDepth,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Check: grant revoked at or before message timestamp.
|
|
623
|
+
const revocationCacheKey = `revocation:${grantEdge.identifier}`;
|
|
624
|
+
const revocationRecord = context.grantCache.get(revocationCacheKey);
|
|
625
|
+
if (revocationRecord && (revocationRecord.descriptor as any).interface !== 'Synthetic') {
|
|
626
|
+
const revocationTimestamp = (revocationRecord.descriptor as any).messageTimestamp as string;
|
|
627
|
+
if (revocationTimestamp && revocationTimestamp <= messageTimestamp) {
|
|
628
|
+
return {
|
|
629
|
+
complete : false,
|
|
630
|
+
rootMessageCid : rootCid,
|
|
631
|
+
edges : allEdges,
|
|
632
|
+
failure : {
|
|
633
|
+
code : ClosureFailureCode.GrantRevocationMissing,
|
|
634
|
+
edge : allEdges.find(e => e.label === 'grantRevocation') ?? grantEdge,
|
|
635
|
+
detail : `grant '${grantEdge.identifier}' was revoked at ${revocationTimestamp}, before message timestamp ${messageTimestamp}`,
|
|
636
|
+
},
|
|
637
|
+
depth: currentDepth,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return null; // Grant is temporally valid.
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
// Internal helpers
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Resolve a list of dependency edges. Returns a ClosureResult on failure,
|
|
651
|
+
* or null if all edges were resolved successfully.
|
|
652
|
+
*/
|
|
653
|
+
async function resolveEdges(
|
|
654
|
+
edges: ClosureDependencyEdge[],
|
|
655
|
+
allEdges: ClosureDependencyEdge[],
|
|
656
|
+
messageStore: MessageStore,
|
|
657
|
+
context: ClosureEvaluationContext,
|
|
658
|
+
visited: Set<string>,
|
|
659
|
+
queue: GenericMessage[],
|
|
660
|
+
rootCid: string,
|
|
661
|
+
currentDepth: number,
|
|
662
|
+
): Promise<ClosureResult | null> {
|
|
663
|
+
for (const edge of edges) {
|
|
664
|
+
allEdges.push(edge);
|
|
665
|
+
|
|
666
|
+
// Include label in dep key to distinguish edges with the same identifier
|
|
667
|
+
// but different semantics (e.g., permissionGrant vs grantRevocation both
|
|
668
|
+
// use identifierType:'grantId' with the same grantId).
|
|
669
|
+
const depKey = `${edge.label}:${edge.identifierType}:${edge.identifier}`;
|
|
670
|
+
if (context.satisfiedDeps.has(depKey)) { continue; }
|
|
671
|
+
if (context.missingDeps.has(depKey)) {
|
|
672
|
+
return {
|
|
673
|
+
complete : false,
|
|
674
|
+
rootMessageCid : rootCid,
|
|
675
|
+
edges : allEdges,
|
|
676
|
+
failure : {
|
|
677
|
+
code : mapEdgeToFailureCode(edge),
|
|
678
|
+
edge,
|
|
679
|
+
detail : `dependency '${edge.label}' (${edge.identifier}) is missing`,
|
|
680
|
+
},
|
|
681
|
+
depth: currentDepth,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const resolved = await resolveDependency(edge, messageStore, context);
|
|
686
|
+
if (!resolved) {
|
|
687
|
+
context.missingDeps.add(depKey);
|
|
688
|
+
return {
|
|
689
|
+
complete : false,
|
|
690
|
+
rootMessageCid : rootCid,
|
|
691
|
+
edges : allEdges,
|
|
692
|
+
failure : {
|
|
693
|
+
code : mapEdgeToFailureCode(edge),
|
|
694
|
+
edge,
|
|
695
|
+
detail : `dependency '${edge.label}' (${edge.identifier}) not found locally`,
|
|
696
|
+
},
|
|
697
|
+
depth: currentDepth,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
context.satisfiedDeps.add(depKey);
|
|
702
|
+
|
|
703
|
+
// Add resolved record to the BFS queue for transitive dependency evaluation,
|
|
704
|
+
// but skip leaf dependency types that don't have their own closure requirements:
|
|
705
|
+
// - grantId: grant/revocation records are leaf nodes
|
|
706
|
+
// - filter: filter-resolved records (e.g., role records) are leaf nodes
|
|
707
|
+
// - messageCid with contextKeyRecord label: key-delivery records are leaf nodes
|
|
708
|
+
const isLeafDep = edge.identifierType === 'grantId' ||
|
|
709
|
+
edge.identifierType === 'filter' ||
|
|
710
|
+
edge.label === 'contextKeyRecord';
|
|
711
|
+
if (!isLeafDep) {
|
|
712
|
+
const resolvedCid = await Message.getCid(resolved);
|
|
713
|
+
if (!visited.has(resolvedCid)) {
|
|
714
|
+
visited.add(resolvedCid);
|
|
715
|
+
queue.push(resolved);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return null; // All edges resolved successfully.
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Resolve a dependency edge by querying the local MessageStore.
|
|
725
|
+
* Returns the resolved message or null if not found.
|
|
726
|
+
*/
|
|
727
|
+
async function resolveDependency(
|
|
728
|
+
edge: ClosureDependencyEdge,
|
|
729
|
+
messageStore: MessageStore,
|
|
730
|
+
context: ClosureEvaluationContext,
|
|
731
|
+
): Promise<GenericMessage | null> {
|
|
732
|
+
switch (edge.identifierType) {
|
|
733
|
+
case 'protocol': {
|
|
734
|
+
// Check cache first.
|
|
735
|
+
if (context.protocolCache.has(edge.identifier)) {
|
|
736
|
+
return context.protocolCache.get(edge.identifier) ?? null;
|
|
737
|
+
}
|
|
738
|
+
// Query for ProtocolsConfigure.
|
|
739
|
+
const { messages } = await messageStore.query(context.tenantDid, [{
|
|
740
|
+
interface : 'Protocols',
|
|
741
|
+
method : 'Configure',
|
|
742
|
+
protocol : edge.identifier,
|
|
743
|
+
isLatestBaseState : true,
|
|
744
|
+
}]);
|
|
745
|
+
const found = messages.length > 0 ? messages[0] : null;
|
|
746
|
+
context.protocolCache.set(edge.identifier, found);
|
|
747
|
+
return found;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
case 'recordId': {
|
|
751
|
+
if (edge.label === 'initialWrite') {
|
|
752
|
+
// Query specifically for the initial write: entryId === recordId,
|
|
753
|
+
// stored with isLatestBaseState: false after subsequent writes.
|
|
754
|
+
// Try entryId first (canonical), fall back to recordId + non-latest.
|
|
755
|
+
const { messages: byEntryId } = await messageStore.query(context.tenantDid, [{
|
|
756
|
+
entryId: edge.identifier,
|
|
757
|
+
}]);
|
|
758
|
+
if (byEntryId.length > 0) { return byEntryId[0]; }
|
|
759
|
+
|
|
760
|
+
// Fallback: query by recordId with isLatestBaseState: false
|
|
761
|
+
// (initial writes are re-stored with this flag after updates).
|
|
762
|
+
const { messages: byRecordId } = await messageStore.query(context.tenantDid, [{
|
|
763
|
+
interface : 'Records',
|
|
764
|
+
method : 'Write',
|
|
765
|
+
recordId : edge.identifier,
|
|
766
|
+
isLatestBaseState : false,
|
|
767
|
+
}]);
|
|
768
|
+
return byRecordId.length > 0 ? byRecordId[0] : null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// crossProtocolParent: identifier is "referencedProtocol|parentId"
|
|
772
|
+
// Query MUST be scoped to the referenced protocol URI.
|
|
773
|
+
if (edge.label === 'crossProtocolParent') {
|
|
774
|
+
const pipeIdx = edge.identifier.indexOf('|');
|
|
775
|
+
if (pipeIdx > 0) {
|
|
776
|
+
const refProtocol = edge.identifier.substring(0, pipeIdx);
|
|
777
|
+
const refParentId = edge.identifier.substring(pipeIdx + 1);
|
|
778
|
+
const { messages: refParents } = await messageStore.query(context.tenantDid, [{
|
|
779
|
+
interface : 'Records',
|
|
780
|
+
method : 'Write',
|
|
781
|
+
protocol : refProtocol,
|
|
782
|
+
recordId : refParentId,
|
|
783
|
+
isLatestBaseState : true,
|
|
784
|
+
}]);
|
|
785
|
+
return refParents.length > 0 ? refParents[0] : null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// For parentRecord, contextRoot, or other recordId lookups, query latest state.
|
|
790
|
+
const { messages } = await messageStore.query(context.tenantDid, [{
|
|
791
|
+
interface : 'Records',
|
|
792
|
+
recordId : edge.identifier,
|
|
793
|
+
isLatestBaseState : true,
|
|
794
|
+
}]);
|
|
795
|
+
return messages.length > 0 ? messages[0] : null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
case 'grantId': {
|
|
799
|
+
if (edge.label === 'grantRevocation') {
|
|
800
|
+
// Query for revocation records: child records at 'grant/revocation'
|
|
801
|
+
// protocolPath with parentId === grantId. The absence of a revocation
|
|
802
|
+
// is a valid result (grant is not revoked) — return a synthetic
|
|
803
|
+
// "no revocation" marker so the dependency is considered satisfied.
|
|
804
|
+
const cacheKey = `revocation:${edge.identifier}`;
|
|
805
|
+
if (context.grantCache.has(cacheKey)) {
|
|
806
|
+
return context.grantCache.get(cacheKey) ?? null;
|
|
807
|
+
}
|
|
808
|
+
const { messages: revocations } = await messageStore.query(context.tenantDid, [{
|
|
809
|
+
interface : 'Records',
|
|
810
|
+
method : 'Write',
|
|
811
|
+
protocol : 'https://identity.foundation/dwn/permissions',
|
|
812
|
+
parentId : edge.identifier,
|
|
813
|
+
protocolPath : 'grant/revocation',
|
|
814
|
+
isLatestBaseState : true,
|
|
815
|
+
}]);
|
|
816
|
+
// Store the result. If no revocation exists, store an explicit synthetic
|
|
817
|
+
// sentinel — never use the grant record as a sentinel, because
|
|
818
|
+
// validateGrantTemporal checks descriptor.interface !== 'Synthetic' to
|
|
819
|
+
// distinguish real revocations from "no revocation" markers.
|
|
820
|
+
// Select the oldest revocation (matching SDK's Message.getOldestMessage logic).
|
|
821
|
+
// The oldest revocation determines the temporal boundary — messages at or after
|
|
822
|
+
// its timestamp are rejected.
|
|
823
|
+
let oldestRevocation: GenericMessage | undefined;
|
|
824
|
+
for (const rev of revocations) {
|
|
825
|
+
const revTs = (rev.descriptor as any).messageTimestamp as string;
|
|
826
|
+
if (!oldestRevocation || revTs < (oldestRevocation.descriptor as any).messageTimestamp) {
|
|
827
|
+
oldestRevocation = rev;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const noRevocationSentinel = { descriptor: { interface: 'Synthetic', method: 'NoRevocation' } } as any;
|
|
832
|
+
const result = oldestRevocation ?? noRevocationSentinel;
|
|
833
|
+
context.grantCache.set(cacheKey, result);
|
|
834
|
+
return result;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Grant record lookup.
|
|
838
|
+
if (context.grantCache.has(edge.identifier)) {
|
|
839
|
+
return context.grantCache.get(edge.identifier) ?? null;
|
|
840
|
+
}
|
|
841
|
+
const { messages } = await messageStore.query(context.tenantDid, [{
|
|
842
|
+
interface : 'Records',
|
|
843
|
+
method : 'Write',
|
|
844
|
+
protocol : 'https://identity.foundation/dwn/permissions',
|
|
845
|
+
protocolPath : 'grant',
|
|
846
|
+
recordId : edge.identifier,
|
|
847
|
+
isLatestBaseState : true,
|
|
848
|
+
}]);
|
|
849
|
+
const found = messages.length > 0 ? messages[0] : null;
|
|
850
|
+
context.grantCache.set(edge.identifier, found);
|
|
851
|
+
return found;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
case 'messageCid': {
|
|
855
|
+
// Special case: contextKeyRecord uses messageCid type but requires a
|
|
856
|
+
// tag-based query against the key-delivery protocol, not a CID lookup.
|
|
857
|
+
// Identifier format is "sourceProtocol|rootContextId".
|
|
858
|
+
if (edge.label === 'contextKeyRecord') {
|
|
859
|
+
const separatorIdx = edge.identifier.indexOf('|');
|
|
860
|
+
if (separatorIdx > 0) {
|
|
861
|
+
const sourceProtocol = edge.identifier.substring(0, separatorIdx);
|
|
862
|
+
const rootContextId = edge.identifier.substring(separatorIdx + 1);
|
|
863
|
+
const { messages: contextKeys } = await messageStore.query(context.tenantDid, [{
|
|
864
|
+
interface : 'Records',
|
|
865
|
+
method : 'Write',
|
|
866
|
+
protocol : 'https://identity.foundation/protocols/key-delivery',
|
|
867
|
+
protocolPath : 'contextKey',
|
|
868
|
+
'tag.protocol' : sourceProtocol,
|
|
869
|
+
'tag.contextId' : rootContextId,
|
|
870
|
+
} as any]);
|
|
871
|
+
if (contextKeys.length > 0) { return contextKeys[0]; }
|
|
872
|
+
// No context key found. Since the edge is only emitted for multi-party
|
|
873
|
+
// contexts (isMultiPartyContext returned true), the absence of a context
|
|
874
|
+
// key means the consumer cannot decrypt this record. Return null to
|
|
875
|
+
// fail closure — the link will transition to repairing.
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return await messageStore.get(context.tenantDid, edge.identifier) ?? null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case 'filter': {
|
|
883
|
+
// Filter-based resolution: query the MessageStore with the structured
|
|
884
|
+
// filter carried in edge.filter. Used for dependencies that require
|
|
885
|
+
// multi-field queries (e.g., cross-protocol role records).
|
|
886
|
+
if (!edge.filter) { return null; }
|
|
887
|
+
const { messages: filterResults } = await messageStore.query(
|
|
888
|
+
context.tenantDid, [edge.filter as any]
|
|
889
|
+
);
|
|
890
|
+
return filterResults.length > 0 ? filterResults[0] : null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
default:
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Map a dependency edge to the appropriate failure code.
|
|
900
|
+
*/
|
|
901
|
+
function mapEdgeToFailureCode(edge: ClosureDependencyEdge): ClosureFailureCode {
|
|
902
|
+
switch (edge.dependencyClass) {
|
|
903
|
+
case 1: return ClosureFailureCode.ProtocolMetadataMissing;
|
|
904
|
+
case 2:
|
|
905
|
+
if (edge.label === 'initialWrite') { return ClosureFailureCode.InitialWriteMissing; }
|
|
906
|
+
if (edge.label === 'parentRecord') { return ClosureFailureCode.ParentChainMissing; }
|
|
907
|
+
if (edge.label === 'contextRoot') { return ClosureFailureCode.ContextChainMissing; }
|
|
908
|
+
return ClosureFailureCode.ParentChainMissing;
|
|
909
|
+
case 3:
|
|
910
|
+
if (edge.label === 'permissionGrant') { return ClosureFailureCode.GrantMissing; }
|
|
911
|
+
return ClosureFailureCode.GrantRevocationMissing;
|
|
912
|
+
case 4:
|
|
913
|
+
if (edge.label === 'squashContextRoot') { return ClosureFailureCode.VisibilityFloorMissing; }
|
|
914
|
+
return ClosureFailureCode.VisibilityFloorMissing;
|
|
915
|
+
case 5: return ClosureFailureCode.EncryptionDependencyMissing;
|
|
916
|
+
case 6: return ClosureFailureCode.CrossProtocolReferenceMissing;
|
|
917
|
+
default: return ClosureFailureCode.DependencyForbidden;
|
|
918
|
+
}
|
|
919
|
+
}
|