@kubun/plugin-p2p 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/peer.js +250 -3
- package/lib/context/sync.js +134 -25
- package/lib/context/types.d.ts +7 -0
- package/lib/groups/broadcast-message.d.ts +29 -0
- package/lib/groups/broadcast.d.ts +11 -1
- package/lib/groups/broadcast.js +44 -2
- package/lib/groups/credential-apply.d.ts +64 -2
- package/lib/groups/credential-apply.js +215 -30
- package/lib/groups/credential-grant.d.ts +22 -0
- package/lib/groups/credential-grant.js +76 -2
- package/lib/groups/credential-manifest-token.d.ts +31 -0
- package/lib/groups/credential-manifest-token.js +49 -0
- package/lib/groups/credential-readiness.d.ts +69 -0
- package/lib/groups/credential-readiness.js +172 -0
- package/lib/groups/credential-wrapping-deps.d.ts +23 -0
- package/lib/groups/credential-wrapping-deps.js +25 -0
- package/lib/groups/grantor-authority.d.ts +65 -0
- package/lib/groups/grantor-authority.js +107 -0
- package/lib/groups/group-handlers.js +7 -0
- package/lib/groups/group-peer-manager.d.ts +25 -0
- package/lib/groups/group-peer-manager.js +71 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/wiring.d.ts +24 -0
- package/lib/hub/wiring.js +17 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +57 -1
- package/lib/protocol.d.ts +30 -0
- package/lib/protocol.js +36 -0
- package/lib/schema.js +78 -1
- package/lib/sync/handlers.js +21 -2
- package/lib/sync/held-delegations.d.ts +14 -0
- package/lib/sync/held-delegations.js +34 -0
- package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
- package/lib/sync/hub-tunnel-service-listener.js +289 -0
- package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
- package/lib/sync/hub-tunnel-service-provider.js +100 -0
- package/lib/sync/service-tunnel-listeners.d.ts +35 -0
- package/lib/sync/service-tunnel-listeners.js +165 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- package/lib/sync/tunnel-topics.d.ts +19 -1
- package/lib/sync/tunnel-topics.js +7 -3
- package/lib/types.d.ts +172 -0
- package/package.json +48 -47
package/lib/context/peer.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Client } from '@enkaku/client';
|
|
2
2
|
import { ClientTransport } from '@enkaku/http-fetch';
|
|
3
3
|
import { normalizeDID } from '@kokuin/token';
|
|
4
|
-
import { createDeviceAuthority } from '@kubun/credential';
|
|
4
|
+
import { CredentialKeyLocked, CredentialUnopenable, createCredentialManager, createDeviceAuthority } from '@kubun/credential';
|
|
5
|
+
import { HLC } from '@kubun/hlc';
|
|
6
|
+
import { getCredentialStore } from '@kubun/store-credential';
|
|
5
7
|
import { getGraphStore } from '@kubun/store-graph';
|
|
6
8
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
7
9
|
import { readGroupAnchor } from '@kumiai/mls';
|
|
@@ -11,6 +13,10 @@ import { applyAccessDefaultSetFrame } from '../groups/access-default-apply.js';
|
|
|
11
13
|
import { signAccessDefaultSet } from '../groups/access-default-token.js';
|
|
12
14
|
import { foldGroupSettings } from '../groups/circle-projection.js';
|
|
13
15
|
import { awaitControlRequestSettled } from '../groups/control-request.js';
|
|
16
|
+
import { grantCredentialKeyToMember } from '../groups/credential-grant.js';
|
|
17
|
+
import { signCredentialManifest } from '../groups/credential-manifest-token.js';
|
|
18
|
+
import { computeCredentialProvisioningStatus } from '../groups/credential-readiness.js';
|
|
19
|
+
import { createCredentialWrappingDeps } from '../groups/credential-wrapping-deps.js';
|
|
14
20
|
import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
|
|
15
21
|
import { SyncAccessDeniedError } from '../sync/errors.js';
|
|
16
22
|
import { createGroupContext } from './group.js';
|
|
@@ -802,7 +808,7 @@ export function createPeerContext(ctx, deps) {
|
|
|
802
808
|
return designated;
|
|
803
809
|
},
|
|
804
810
|
admitJoinRequest: async (params)=>{
|
|
805
|
-
const { groupID, joinRequest, sendModels, receiveActivate } = params;
|
|
811
|
+
const { groupID, joinRequest, grants, sendModels, receiveActivate } = params;
|
|
806
812
|
// Resolve the joiner BEFORE anything is written. A designation minted for a
|
|
807
813
|
// DID the invite then refuses would leave a share circle and a catalog
|
|
808
814
|
// behind for a member that never joins.
|
|
@@ -852,7 +858,10 @@ export function createPeerContext(ctx, deps) {
|
|
|
852
858
|
// snapshots exactly what the designation above wrote.
|
|
853
859
|
const inviteResult = await groupCtx.requestInvite({
|
|
854
860
|
groupID: designated.group.id,
|
|
855
|
-
joinRequest
|
|
861
|
+
joinRequest,
|
|
862
|
+
...grants != null && grants.length > 0 ? {
|
|
863
|
+
grants
|
|
864
|
+
} : {}
|
|
856
865
|
});
|
|
857
866
|
if (inviteResult.invitePayload == null) {
|
|
858
867
|
// The Add commit is still parked, so there is no Welcome to hand back.
|
|
@@ -1216,6 +1225,244 @@ export function createPeerContext(ctx, deps) {
|
|
|
1216
1225
|
states.push(await circleSyncState(circle, ownDefaults, catalogs.load));
|
|
1217
1226
|
}
|
|
1218
1227
|
return states;
|
|
1228
|
+
},
|
|
1229
|
+
grantDeviceCredentials: async (params)=>{
|
|
1230
|
+
const { groupID, recipientDID } = params;
|
|
1231
|
+
// `recipientDID` stays RAW for the per-key grant loop's roster lookup
|
|
1232
|
+
// (`findMemberLongForm` needs the caller's original form); the manifest
|
|
1233
|
+
// sequence/claim use `normalizedRecipient`/`normalizedGrantorDID` to match
|
|
1234
|
+
// `putManifest`'s normalized (owner, recipient, grantor) triple.
|
|
1235
|
+
const owner = normalizeDID(params.ownerDID);
|
|
1236
|
+
const normalizedRecipient = normalizeDID(recipientDID);
|
|
1237
|
+
const normalizedGrantorDID = normalizeDID(ctx.viewerDID);
|
|
1238
|
+
const store = await getCredentialStore(deps.stores);
|
|
1239
|
+
// Key grants and the manifest persist must land atomically, or the
|
|
1240
|
+
// grantor's own PULL-origin copy could disagree with what it wrote.
|
|
1241
|
+
// `store.transaction` is nest-safe: rides the caller's mutation tx
|
|
1242
|
+
// inline when `deps.stores` is already transactional, else opens its
|
|
1243
|
+
// own. All writes below go through `txStore`, not the outer `store`.
|
|
1244
|
+
const { results, manifest, epoch } = await store.transaction(async (txStore)=>{
|
|
1245
|
+
// Bound to `txStore` so its writes ride the same transaction as the
|
|
1246
|
+
// manifest persist below.
|
|
1247
|
+
const credentials = createCredentialManager({
|
|
1248
|
+
store: txStore,
|
|
1249
|
+
identity: deps.identity,
|
|
1250
|
+
hlc: deps.hlc,
|
|
1251
|
+
runtime: deps.runtime
|
|
1252
|
+
});
|
|
1253
|
+
// Allocated before the loop: the epoch names a provisioning attempt,
|
|
1254
|
+
// not a count of keys granted.
|
|
1255
|
+
const epoch = await txStore.nextProvisioningEpoch({
|
|
1256
|
+
ownerDID: owner,
|
|
1257
|
+
recipientDID: normalizedRecipient,
|
|
1258
|
+
grantorDID: normalizedGrantorDID,
|
|
1259
|
+
minEpoch: params.minEpoch ?? 0
|
|
1260
|
+
});
|
|
1261
|
+
// Only active keys: a batch grant hands over what the owner
|
|
1262
|
+
// currently uses, not retired history.
|
|
1263
|
+
const keys = (await txStore.listKeys(owner)).filter((key)=>key.state === 'active');
|
|
1264
|
+
const results = [];
|
|
1265
|
+
for (const key of keys){
|
|
1266
|
+
try {
|
|
1267
|
+
const granted = await grantCredentialKeyToMember({
|
|
1268
|
+
registry: deps.registry,
|
|
1269
|
+
credentials,
|
|
1270
|
+
identity: deps.identity,
|
|
1271
|
+
store: txStore,
|
|
1272
|
+
groupID,
|
|
1273
|
+
keyID: key.key_id,
|
|
1274
|
+
recipientDID,
|
|
1275
|
+
scheduleBroadcast: deps.scheduleBroadcast,
|
|
1276
|
+
stores: deps.stores,
|
|
1277
|
+
authority: ctx.credentialAuthority,
|
|
1278
|
+
ownerDID: owner,
|
|
1279
|
+
grantorDID: ctx.viewerDID,
|
|
1280
|
+
delegationTokens: ctx.delegationTokens
|
|
1281
|
+
});
|
|
1282
|
+
results.push({
|
|
1283
|
+
keyID: key.key_id,
|
|
1284
|
+
version: granted.version,
|
|
1285
|
+
outcome: granted.outcome
|
|
1286
|
+
});
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
// Per-key failure is captured as its own outcome, not thrown, so
|
|
1289
|
+
// one key's trouble doesn't stall the rest of the batch.
|
|
1290
|
+
// `CredentialUnopenable`/`CredentialKeyLocked` (no wrapping held /
|
|
1291
|
+
// needs a secret this non-interactive call can't supply) map to
|
|
1292
|
+
// `not-held`; anything else (roster miss, store fault) is `error`.
|
|
1293
|
+
if (error instanceof CredentialUnopenable || error instanceof CredentialKeyLocked) {
|
|
1294
|
+
results.push({
|
|
1295
|
+
keyID: key.key_id,
|
|
1296
|
+
version: null,
|
|
1297
|
+
outcome: 'not-held',
|
|
1298
|
+
reason: error.message
|
|
1299
|
+
});
|
|
1300
|
+
} else {
|
|
1301
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1302
|
+
results.push({
|
|
1303
|
+
keyID: key.key_id,
|
|
1304
|
+
version: null,
|
|
1305
|
+
outcome: 'error',
|
|
1306
|
+
reason
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
// Manifest names the structural target (every active key this grantor
|
|
1312
|
+
// can distribute), not the per-key outcomes above — a partially
|
|
1313
|
+
// failed batch still produces a manifest a later retry can reconcile
|
|
1314
|
+
// against.
|
|
1315
|
+
const targetKeys = await txStore.listDistributableKeys({
|
|
1316
|
+
ownerDID: owner,
|
|
1317
|
+
grantorDID: normalizedGrantorDID
|
|
1318
|
+
});
|
|
1319
|
+
// Shares the same per-(owner,recipient,grantor) counter as
|
|
1320
|
+
// `mintGrantorAuthorityIfDelegated`, so both draw from one monotonic
|
|
1321
|
+
// source.
|
|
1322
|
+
const sequence = await txStore.nextGrantorSequence({
|
|
1323
|
+
ownerDID: owner,
|
|
1324
|
+
recipientDID: normalizedRecipient,
|
|
1325
|
+
grantorDID: normalizedGrantorDID
|
|
1326
|
+
});
|
|
1327
|
+
const manifest = await signCredentialManifest(deps.identity, {
|
|
1328
|
+
ownerDID: owner,
|
|
1329
|
+
recipientDID: normalizedRecipient,
|
|
1330
|
+
epoch,
|
|
1331
|
+
sequence,
|
|
1332
|
+
keys: targetKeys,
|
|
1333
|
+
issuedAt: HLC.serialize(deps.hlc.now())
|
|
1334
|
+
});
|
|
1335
|
+
// Persist the grantor's own copy — the PULL origin a status check or
|
|
1336
|
+
// re-broadcast reads back from, independent of the live send below.
|
|
1337
|
+
await txStore.putManifest({
|
|
1338
|
+
ownerDID: owner,
|
|
1339
|
+
recipientDID: normalizedRecipient,
|
|
1340
|
+
grantorDID: normalizedGrantorDID,
|
|
1341
|
+
epoch,
|
|
1342
|
+
sequence,
|
|
1343
|
+
keys: targetKeys
|
|
1344
|
+
}, manifest, ctx.delegationTokens ?? []);
|
|
1345
|
+
return {
|
|
1346
|
+
results,
|
|
1347
|
+
manifest,
|
|
1348
|
+
epoch
|
|
1349
|
+
};
|
|
1350
|
+
});
|
|
1351
|
+
// Scheduled on commit, over the same broadcast lane the per-key grants
|
|
1352
|
+
// use, so the recipient's readiness query has an expected set to check
|
|
1353
|
+
// against. `delegationTokens` rides beside the manifest for the
|
|
1354
|
+
// receiver's present-time chain check (empty when grantor is owner).
|
|
1355
|
+
const manifestFrame = {
|
|
1356
|
+
type: 'credential:key-manifest',
|
|
1357
|
+
manifest,
|
|
1358
|
+
delegationTokens: ctx.delegationTokens ?? []
|
|
1359
|
+
};
|
|
1360
|
+
deps.stores.onCommit(()=>deps.scheduleBroadcast(groupID, manifestFrame));
|
|
1361
|
+
return {
|
|
1362
|
+
results,
|
|
1363
|
+
manifest,
|
|
1364
|
+
epoch
|
|
1365
|
+
};
|
|
1366
|
+
},
|
|
1367
|
+
credentialProvisioningStatus: async (params)=>{
|
|
1368
|
+
const self = normalizeDID(ctx.viewerDID);
|
|
1369
|
+
const owner = normalizeDID(params.ownerDID);
|
|
1370
|
+
const store = await getCredentialStore(deps.stores);
|
|
1371
|
+
const credentials = createCredentialManager({
|
|
1372
|
+
store,
|
|
1373
|
+
identity: deps.identity,
|
|
1374
|
+
hlc: deps.hlc,
|
|
1375
|
+
runtime: deps.runtime
|
|
1376
|
+
});
|
|
1377
|
+
// Same seam `context/sync.ts`'s `applyReconcile` uses for delegated-wrapping
|
|
1378
|
+
// admission deps, so readiness agrees with the live apply path. Fail-closed:
|
|
1379
|
+
// an absent `controllerResolverFor` yields empty deps, so a delegated
|
|
1380
|
+
// manifest's proof never revalidates.
|
|
1381
|
+
const wrappingDeps = await createCredentialWrappingDeps(deps.stores, deps.controllerResolverFor);
|
|
1382
|
+
return await computeCredentialProvisioningStatus({
|
|
1383
|
+
store,
|
|
1384
|
+
credentials,
|
|
1385
|
+
selfDID: self,
|
|
1386
|
+
ownerDID: owner,
|
|
1387
|
+
deps: wrappingDeps
|
|
1388
|
+
});
|
|
1389
|
+
},
|
|
1390
|
+
beginProvisioningExpectation: async (params)=>{
|
|
1391
|
+
const store = await getCredentialStore(deps.stores);
|
|
1392
|
+
const attemptId = crypto.randomUUID();
|
|
1393
|
+
await store.beginProvisioningAttempt({
|
|
1394
|
+
ownerDID: normalizeDID(params.ownerDID),
|
|
1395
|
+
grantorDID: normalizeDID(params.grantorDID),
|
|
1396
|
+
attemptId
|
|
1397
|
+
});
|
|
1398
|
+
return {
|
|
1399
|
+
attemptId
|
|
1400
|
+
};
|
|
1401
|
+
},
|
|
1402
|
+
recordProvisioningExpectation: async (params)=>{
|
|
1403
|
+
const store = await getCredentialStore(deps.stores);
|
|
1404
|
+
const owner = normalizeDID(params.ownerDID);
|
|
1405
|
+
const grantor = normalizeDID(params.grantorDID);
|
|
1406
|
+
const self = normalizeDID(ctx.viewerDID);
|
|
1407
|
+
// Held-epoch read and attempt record run in one transaction, with the
|
|
1408
|
+
// manifest row locked first, so a concurrent manifest apply can't slip
|
|
1409
|
+
// between them on any adapter (not just single-writer SQLite).
|
|
1410
|
+
return await store.transaction(async (txStore)=>{
|
|
1411
|
+
await txStore.lockManifestRow({
|
|
1412
|
+
ownerDID: owner,
|
|
1413
|
+
recipientDID: self,
|
|
1414
|
+
grantorDID: grantor
|
|
1415
|
+
});
|
|
1416
|
+
const heldEpoch = await provisioningHeldEpochFor({
|
|
1417
|
+
store: txStore,
|
|
1418
|
+
ownerDID: owner,
|
|
1419
|
+
selfDID: self,
|
|
1420
|
+
grantorDID: grantor
|
|
1421
|
+
});
|
|
1422
|
+
return await txStore.recordProvisioningAttempt({
|
|
1423
|
+
ownerDID: owner,
|
|
1424
|
+
grantorDID: grantor,
|
|
1425
|
+
attemptId: params.attemptId,
|
|
1426
|
+
epoch: params.epoch,
|
|
1427
|
+
heldEpoch
|
|
1428
|
+
});
|
|
1429
|
+
});
|
|
1430
|
+
},
|
|
1431
|
+
abandonProvisioningExpectation: async (params)=>{
|
|
1432
|
+
const store = await getCredentialStore(deps.stores);
|
|
1433
|
+
await store.abandonProvisioningAttempt({
|
|
1434
|
+
ownerDID: normalizeDID(params.ownerDID),
|
|
1435
|
+
grantorDID: normalizeDID(params.grantorDID),
|
|
1436
|
+
attemptId: params.attemptId
|
|
1437
|
+
});
|
|
1438
|
+
},
|
|
1439
|
+
provisioningHeldEpoch: async (params)=>{
|
|
1440
|
+
const store = await getCredentialStore(deps.stores);
|
|
1441
|
+
return await provisioningHeldEpochFor({
|
|
1442
|
+
store,
|
|
1443
|
+
ownerDID: normalizeDID(params.ownerDID),
|
|
1444
|
+
selfDID: normalizeDID(ctx.viewerDID),
|
|
1445
|
+
grantorDID: normalizeDID(params.grantorDID)
|
|
1446
|
+
});
|
|
1219
1447
|
}
|
|
1220
1448
|
};
|
|
1221
1449
|
}
|
|
1450
|
+
/**
|
|
1451
|
+
* This device's currently-held epoch for `(ownerDID, self, grantorDID)`: the
|
|
1452
|
+
* max epoch among its held manifests from that grantor, or `0` when none.
|
|
1453
|
+
* Shared by `recordProvisioningExpectation` and `provisioningHeldEpoch` so
|
|
1454
|
+
* both compute it identically.
|
|
1455
|
+
*/ async function provisioningHeldEpochFor(params) {
|
|
1456
|
+
const { store, ownerDID, selfDID, grantorDID } = params;
|
|
1457
|
+
const manifests = await store.listManifests({
|
|
1458
|
+
ownerDID,
|
|
1459
|
+
recipientDID: selfDID
|
|
1460
|
+
});
|
|
1461
|
+
let heldEpoch = 0;
|
|
1462
|
+
for (const { record } of manifests){
|
|
1463
|
+
if (normalizeDID(record.grantorDID) === grantorDID && record.epoch > heldEpoch) {
|
|
1464
|
+
heldEpoch = record.epoch;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return heldEpoch;
|
|
1468
|
+
}
|
package/lib/context/sync.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { createCredentialManager } from '@kubun/credential';
|
|
1
3
|
import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
|
|
2
4
|
import { getGraphStore } from '@kubun/store-graph';
|
|
3
5
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
4
6
|
import { applyCredentialReconcile } from '../groups/credential-apply.js';
|
|
7
|
+
import { unmetCredentialOwners } from '../groups/credential-readiness.js';
|
|
8
|
+
import { createCredentialWrappingDeps } from '../groups/credential-wrapping-deps.js';
|
|
5
9
|
import { answersCredentialSync, rankSyncPeers } from '../groups/peer-selection.js';
|
|
6
10
|
import { resolveCatalogSyncScopes } from '../sync/catalog-scope.js';
|
|
11
|
+
import { resolveHeldDelegationTokens } from '../sync/held-delegations.js';
|
|
7
12
|
const NO_OP = {
|
|
8
13
|
messagesReceived: 0,
|
|
9
14
|
messagesSent: 0,
|
|
@@ -96,36 +101,112 @@ export function createSyncContext(_ctx, deps) {
|
|
|
96
101
|
return await (await getCredentialStore(deps.stores)).listHeldSubjects();
|
|
97
102
|
};
|
|
98
103
|
/**
|
|
104
|
+
* Owner DIDs to pull a fresh durable manifest for — the durable-repair
|
|
105
|
+
* trigger, independent of `scopes`, so a device with no active document
|
|
106
|
+
* catalog can still reconcile a provisioning expectation a live broadcast
|
|
107
|
+
* never delivered.
|
|
108
|
+
*
|
|
109
|
+
* Candidates are `store.listProvisioningExpectationOwners()` (every owner
|
|
110
|
+
* with an expectation row anywhere) union `ownerCandidates` (self, scope
|
|
111
|
+
* owners), each filtered to those with an actual expectation row before
|
|
112
|
+
* being handed to {@link unmetCredentialOwners}.
|
|
113
|
+
*/ const unmetCredentialOwnersFor = async (ownerCandidates)=>{
|
|
114
|
+
if (!deps.stores.hasStore(CREDENTIAL_STORE)) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const store = await getCredentialStore(deps.stores);
|
|
118
|
+
const allCandidates = [
|
|
119
|
+
...await store.listProvisioningExpectationOwners(),
|
|
120
|
+
...ownerCandidates
|
|
121
|
+
];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const uniqueOwners = [];
|
|
124
|
+
for (const candidate of allCandidates){
|
|
125
|
+
const owner = normalizeDID(candidate);
|
|
126
|
+
if (!seen.has(owner)) {
|
|
127
|
+
seen.add(owner);
|
|
128
|
+
uniqueOwners.push(owner);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Independent per-owner reads — query concurrently once deduped.
|
|
132
|
+
const counts = await Promise.all(uniqueOwners.map((owner)=>store.listProvisioningExpectations({
|
|
133
|
+
ownerDID: owner
|
|
134
|
+
})));
|
|
135
|
+
const withExpectation = uniqueOwners.filter((_owner, index)=>counts[index].length > 0);
|
|
136
|
+
if (withExpectation.length === 0) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const credentials = createCredentialManager({
|
|
140
|
+
store,
|
|
141
|
+
identity: deps.identity,
|
|
142
|
+
hlc: deps.hlc,
|
|
143
|
+
runtime: deps.runtime
|
|
144
|
+
});
|
|
145
|
+
const wrappingDeps = await createCredentialWrappingDeps(deps.stores, deps.controllerResolverFor);
|
|
146
|
+
return await unmetCredentialOwners({
|
|
147
|
+
store,
|
|
148
|
+
credentials,
|
|
149
|
+
selfDID: deps.identity.id,
|
|
150
|
+
ownerDIDs: withExpectation,
|
|
151
|
+
deps: wrappingDeps
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
99
155
|
* Materialise the credential catch-up the doc session pulled back. Independent
|
|
100
|
-
* of the doc lane: a failure here
|
|
101
|
-
*
|
|
156
|
+
* of the doc lane: a failure here is logged and swallowed (returns `undefined`,
|
|
157
|
+
* never throws). The server's scoping is not trusted — `applyCredentialReconcile`
|
|
102
158
|
* re-verifies every row.
|
|
159
|
+
*
|
|
160
|
+
* The returned count is observability only — the manifest-vs-store readiness
|
|
161
|
+
* query is the completeness authority, not this.
|
|
103
162
|
*/ const applyReconcile = async (peerDID, reconcile)=>{
|
|
104
163
|
if (reconcile == null) {
|
|
105
|
-
return;
|
|
164
|
+
return undefined;
|
|
106
165
|
}
|
|
107
166
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
167
|
+
// Everything below shares one `withTransaction` scope (store writes AND
|
|
168
|
+
// the controller/delegation-store reads `createCredentialWrappingDeps`
|
|
169
|
+
// needs). Building `wrappingDeps` over the top-level `deps.stores`
|
|
170
|
+
// while `applyCredentialReconcile` opens its own write transaction on
|
|
171
|
+
// the same-connection credential store would deadlock: the write tx's
|
|
172
|
+
// callback would await a second, non-transactional handle on the same
|
|
173
|
+
// connection it already holds. Routing everything through the same
|
|
174
|
+
// nesting-safe `tx` avoids that — one connection, one owner.
|
|
175
|
+
const result = await deps.stores.withTransaction(async (tx)=>{
|
|
176
|
+
const store = await getCredentialStore(tx);
|
|
177
|
+
// Fail-closed: an absent controller-resolver seam yields empty deps,
|
|
178
|
+
// so a delegate-signed wrapping in the catch-up stays unadmitted.
|
|
179
|
+
const wrappingDeps = await createCredentialWrappingDeps(tx, deps.controllerResolverFor);
|
|
180
|
+
return await applyCredentialReconcile({
|
|
181
|
+
store,
|
|
182
|
+
selfDID: deps.identity.id,
|
|
183
|
+
bundles: reconcile.bundles,
|
|
184
|
+
tombstones: reconcile.tombstones,
|
|
185
|
+
// Owners-driven PULL repair payload, applied before bundles/tombstones
|
|
186
|
+
// so readiness never transiently under-declares. Omitted (not `[]`)
|
|
187
|
+
// when the piggyback-only lane never requested owners.
|
|
188
|
+
...reconcile.manifests == null ? {} : {
|
|
189
|
+
manifests: reconcile.manifests
|
|
190
|
+
},
|
|
191
|
+
hlc: deps.hlc,
|
|
192
|
+
...deps.maxDriftMS == null ? {} : {
|
|
193
|
+
maxDriftMS: deps.maxDriftMS
|
|
194
|
+
},
|
|
195
|
+
logger: deps.logger,
|
|
196
|
+
deps: wrappingDeps
|
|
197
|
+
});
|
|
119
198
|
});
|
|
120
199
|
deps.logger.debug('credential reconcile piggybacked on catch-up', {
|
|
121
200
|
peerDID,
|
|
122
201
|
...result
|
|
123
202
|
});
|
|
203
|
+
return result;
|
|
124
204
|
} catch (error) {
|
|
125
205
|
deps.logger.warn('credential reconcile apply failed; doc catch-up unaffected', {
|
|
126
206
|
peerDID,
|
|
127
207
|
error
|
|
128
208
|
});
|
|
209
|
+
return undefined;
|
|
129
210
|
}
|
|
130
211
|
};
|
|
131
212
|
// Bidirectional: each device pushes the docs it owns (its own owner-scope
|
|
@@ -134,10 +215,17 @@ export function createSyncContext(_ctx, deps) {
|
|
|
134
215
|
// persisted, so this works on the first call after a restart.
|
|
135
216
|
const runCatchUp = async (groupID, peerDID)=>{
|
|
136
217
|
const { scopes, knownModelIDs } = await resolveActiveScopes(deps.stores);
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
218
|
+
// The DURABLE REPAIR trigger: an owner this device has an outstanding
|
|
219
|
+
// provisioning expectation for still needs a session even with zero
|
|
220
|
+
// document scopes — LIVE broadcast is not the only path.
|
|
221
|
+
const credentialOwners = await unmetCredentialOwnersFor([
|
|
222
|
+
deps.identity.id,
|
|
223
|
+
...scopes.map((scope)=>scope.ownerDID)
|
|
224
|
+
]);
|
|
225
|
+
// No active catalog (or none resolvable to a concrete owner) AND no unmet
|
|
226
|
+
// credential expectation → the opt-in resting state. A clean all-zero
|
|
227
|
+
// no-op, never an error and never a peer round-trip.
|
|
228
|
+
if (scopes.length === 0 && credentialOwners.length === 0) {
|
|
141
229
|
return NO_OP;
|
|
142
230
|
}
|
|
143
231
|
const announcement = await resolveAnnouncement(groupID, peerDID);
|
|
@@ -146,6 +234,10 @@ export function createSyncContext(_ctx, deps) {
|
|
|
146
234
|
// lane must reuse the doc session, since a second dial to the same peer hangs
|
|
147
235
|
// on a responder still locked to the first.
|
|
148
236
|
const held = await credentialHeld(announcement);
|
|
237
|
+
// This device's own held read delegations for the owners it's about to pull —
|
|
238
|
+
// without these, `checkSyncDelegation` can never fire and a cross-DID
|
|
239
|
+
// `only_owner` document is reachable only via a circle grant.
|
|
240
|
+
const delegationTokens = await resolveHeldDelegationTokens(deps.stores, deps.identity.id, scopes.map((scope)=>scope.ownerDID));
|
|
149
241
|
const result = await deps.syncManager.merkleSyncWithPeer({
|
|
150
242
|
peerDID,
|
|
151
243
|
scopes,
|
|
@@ -157,17 +249,26 @@ export function createSyncContext(_ctx, deps) {
|
|
|
157
249
|
},
|
|
158
250
|
...held == null ? {} : {
|
|
159
251
|
reconcileCredentialsHeld: held
|
|
252
|
+
},
|
|
253
|
+
...credentialOwners.length === 0 ? {} : {
|
|
254
|
+
reconcileCredentialOwners: credentialOwners
|
|
255
|
+
},
|
|
256
|
+
...delegationTokens.length === 0 ? {} : {
|
|
257
|
+
delegationTokens
|
|
160
258
|
}
|
|
161
259
|
});
|
|
162
|
-
// Materialise whatever the session pulled back
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
260
|
+
// Materialise whatever the session pulled back (failure swallowed inside
|
|
261
|
+
// `applyReconcile`). Attempted whenever either lane could have populated
|
|
262
|
+
// `result.credentialReconcile` — `held` or `credentialOwners` — not just
|
|
263
|
+
// the first, or a credential-only reply would go unread.
|
|
264
|
+
const credentialReconcileApplied = held == null && credentialOwners.length === 0 ? undefined : await applyReconcile(peerDID, result.credentialReconcile);
|
|
167
265
|
return {
|
|
168
266
|
messagesReceived: result.messagesReceived,
|
|
169
267
|
messagesSent: result.messagesSent,
|
|
170
|
-
divergentBuckets: result.divergentBuckets
|
|
268
|
+
divergentBuckets: result.divergentBuckets,
|
|
269
|
+
...credentialReconcileApplied == null ? {} : {
|
|
270
|
+
credentialReconcileApplied
|
|
271
|
+
}
|
|
171
272
|
};
|
|
172
273
|
};
|
|
173
274
|
return {
|
|
@@ -234,7 +335,15 @@ export function createSyncContext(_ctx, deps) {
|
|
|
234
335
|
},
|
|
235
336
|
catchUpWithBestPeer: async (groupID)=>{
|
|
236
337
|
const { scopes } = await resolveActiveScopes(deps.stores);
|
|
237
|
-
|
|
338
|
+
// The autonomous counterpart of `runCatchUp`'s own DURABLE REPAIR check:
|
|
339
|
+
// an unmet credential expectation must let periodic/automatic catch-up
|
|
340
|
+
// proceed too, not only an explicit `syncPeer` call — otherwise a device
|
|
341
|
+
// that never opens the app to name a peer never self-heals.
|
|
342
|
+
const credentialOwners = scopes.length === 0 ? await unmetCredentialOwnersFor([
|
|
343
|
+
deps.identity.id,
|
|
344
|
+
...scopes.map((scope)=>scope.ownerDID)
|
|
345
|
+
]) : [];
|
|
346
|
+
if (scopes.length === 0 && credentialOwners.length === 0) {
|
|
238
347
|
return {
|
|
239
348
|
peerDID: null,
|
|
240
349
|
outcome: 'no-scopes',
|
package/lib/context/types.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Circle } from '@kubun/store-p2p';
|
|
|
7
7
|
import type { LaneResult, PendingCommit } from '@kumiai/rpc';
|
|
8
8
|
import type { Runtime } from '@sozai/runtime';
|
|
9
9
|
import type { GroupBroadcastMessage } from '../groups/broadcast-message.js';
|
|
10
|
+
import type { ControllerResolverFor } from '../groups/credential-wrapping-deps.js';
|
|
10
11
|
import type { P2PEventEmitter } from '../groups/events.js';
|
|
11
12
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
12
13
|
import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
|
|
@@ -40,6 +41,12 @@ export type ContextDeps = {
|
|
|
40
41
|
* adoption a join performs uses the configured value rather than the default.
|
|
41
42
|
*/
|
|
42
43
|
maxDriftMS?: number;
|
|
44
|
+
/**
|
|
45
|
+
* The engine's `did:kokuin:` controller-resolver seam, used by the credential
|
|
46
|
+
* reconcile-apply lane to source the delegate-wrapping admission deps. Optional:
|
|
47
|
+
* absent, a delegate-signed wrapping pulled on catch-up stays fail-closed.
|
|
48
|
+
*/
|
|
49
|
+
controllerResolverFor?: ControllerResolverFor;
|
|
43
50
|
emitter: P2PEventEmitter;
|
|
44
51
|
runtime: Runtime;
|
|
45
52
|
autoAcceptPeers?: Array<string>;
|
|
@@ -74,6 +74,12 @@ export type CredentialKeyBundle = {
|
|
|
74
74
|
keyBranches: Array<string>;
|
|
75
75
|
wrapping: CredentialKeyGrantWrapping;
|
|
76
76
|
entries: Array<CredentialKeyGrantEntry>;
|
|
77
|
+
/**
|
|
78
|
+
* The persisted grantor-authority envelope, when the granter delegated the
|
|
79
|
+
* wrap. A string token, carried as-is (not base64url — it's not a byte
|
|
80
|
+
* value). Absent for an owner-signed grant.
|
|
81
|
+
*/
|
|
82
|
+
grantorAuthority?: string;
|
|
77
83
|
};
|
|
78
84
|
export type GroupBroadcastMessage = {
|
|
79
85
|
type: 'catalog:create';
|
|
@@ -214,12 +220,35 @@ export type GroupBroadcastMessage = {
|
|
|
214
220
|
keyBranches: Array<string>;
|
|
215
221
|
wrapping: CredentialKeyGrantWrapping;
|
|
216
222
|
entries: Array<CredentialKeyGrantEntry>;
|
|
223
|
+
/** See {@link CredentialKeyBundle.grantorAuthority}. */
|
|
224
|
+
grantorAuthority?: string;
|
|
217
225
|
/**
|
|
218
226
|
* Signs identifiers and digests, not the ciphertext — see
|
|
219
227
|
* {@link CredentialKeyGrantClaim}. The receiver recomputes both digests
|
|
220
228
|
* from the plaintext frame before writing anything.
|
|
221
229
|
*/
|
|
222
230
|
auth: ControlAuth;
|
|
231
|
+
} | {
|
|
232
|
+
/**
|
|
233
|
+
* A grantor's advisory record of the key set it granted a recipient — the
|
|
234
|
+
* projection a recipient checks its own store against. ADVISORY: it
|
|
235
|
+
* authorizes nothing (a recipient decrypt-verifies its own keys); the
|
|
236
|
+
* receiver's gate only keeps a bogus manifest out of the store, admitting
|
|
237
|
+
* one only when the grantor holds a present-time `credential/administer`
|
|
238
|
+
* chain over the owner.
|
|
239
|
+
*
|
|
240
|
+
* Group-wide like `credential:key-grant`: the receiver keys the stored
|
|
241
|
+
* row on the manifest's own `(owner, recipient, grantor)`.
|
|
242
|
+
*/
|
|
243
|
+
type: 'credential:key-manifest';
|
|
244
|
+
/** The grantor's signed manifest token — its issuer is the authoritative grantor. */
|
|
245
|
+
manifest: string;
|
|
246
|
+
/**
|
|
247
|
+
* The grantor's delegation tokens proving its `credential/administer`
|
|
248
|
+
* chain, closest-to-grantor first. Empty when the grantor IS the owner
|
|
249
|
+
* (self-issued).
|
|
250
|
+
*/
|
|
251
|
+
delegationTokens: Array<string>;
|
|
223
252
|
} | {
|
|
224
253
|
/**
|
|
225
254
|
* A device advertises itself to its co-members. Everything here except
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { VerifyTokenHook } from '@kokuin/capability';
|
|
2
|
+
import type { DIDMethodResolver, OwnIdentity } from '@kokuin/token';
|
|
2
3
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
3
4
|
import type { HLC } from '@kubun/hlc';
|
|
4
5
|
import type { Logger } from '@kubun/logger';
|
|
@@ -66,6 +67,15 @@ export type ProcessBroadcastParams = {
|
|
|
66
67
|
* than migrating a store nothing else on the device uses.
|
|
67
68
|
*/
|
|
68
69
|
credentialStore?: CredentialStoreAPI;
|
|
70
|
+
/**
|
|
71
|
+
* Controller resolver + revocation checker for a delegate-signed
|
|
72
|
+
* `credential:key-grant` wrapping, forwarded to
|
|
73
|
+
* {@link applyCredentialKeyGrantFrame} as its admission `deps`. Consumed
|
|
74
|
+
* together: a device that cannot source both (a light client) leaves a
|
|
75
|
+
* delegated wrapping fail-closed.
|
|
76
|
+
*/
|
|
77
|
+
credentialControllerResolver?: DIDMethodResolver;
|
|
78
|
+
credentialRevocationChecker?: VerifyTokenHook;
|
|
69
79
|
/**
|
|
70
80
|
* The engine's future-drift bound, applied to a `credential:key-grant`'s
|
|
71
81
|
* stamps. Optional: absence falls back to {@link DEFAULT_MAX_DRIFT_MS}, the
|