@kubun/plugin-p2p 0.13.1 → 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.
Files changed (51) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/grantor-authority.d.ts +65 -0
  18. package/lib/groups/grantor-authority.js +107 -0
  19. package/lib/groups/group-handlers.js +7 -0
  20. package/lib/groups/group-peer-manager.d.ts +25 -0
  21. package/lib/groups/group-peer-manager.js +71 -0
  22. package/lib/groups/group-protocols.d.ts +47 -0
  23. package/lib/groups/group-protocols.js +28 -0
  24. package/lib/hub/wiring.d.ts +24 -0
  25. package/lib/hub/wiring.js +17 -1
  26. package/lib/index.d.ts +14 -0
  27. package/lib/index.js +157 -6
  28. package/lib/peer/blob-fetch.d.ts +2 -18
  29. package/lib/protocol.d.ts +30 -0
  30. package/lib/protocol.js +36 -0
  31. package/lib/schema.d.ts +16 -1
  32. package/lib/schema.js +113 -4
  33. package/lib/sync/group-sync-workflow.d.ts +77 -0
  34. package/lib/sync/group-sync-workflow.js +96 -0
  35. package/lib/sync/handlers.js +21 -2
  36. package/lib/sync/held-delegations.d.ts +14 -0
  37. package/lib/sync/held-delegations.js +34 -0
  38. package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
  39. package/lib/sync/hub-tunnel-service-listener.js +289 -0
  40. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  41. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  42. package/lib/sync/service-tunnel-listeners.d.ts +35 -0
  43. package/lib/sync/service-tunnel-listeners.js +165 -0
  44. package/lib/sync/sync-manager.d.ts +7 -0
  45. package/lib/sync/sync-manager.js +4 -1
  46. package/lib/sync/tunnel-topics.d.ts +19 -1
  47. package/lib/sync/tunnel-topics.js +7 -3
  48. package/lib/types.d.ts +182 -7
  49. package/lib/util/handler-error.d.ts +8 -5
  50. package/lib/util/handler-error.js +10 -23
  51. package/package.json +51 -46
@@ -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
+ }
@@ -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 never fails the catch-up, so it is logged and
101
- * swallowed. The server's scoping is not trusted — `applyCredentialReconcile`
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
- const store = await getCredentialStore(deps.stores);
109
- const result = await applyCredentialReconcile({
110
- store,
111
- selfDID: deps.identity.id,
112
- bundles: reconcile.bundles,
113
- tombstones: reconcile.tombstones,
114
- hlc: deps.hlc,
115
- ...deps.maxDriftMS == null ? {} : {
116
- maxDriftMS: deps.maxDriftMS
117
- },
118
- logger: deps.logger
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
- // No active catalog (or none resolvable to a concrete owner) → the opt-in
138
- // resting state. A clean all-zero no-op, never an error and never a peer
139
- // round-trip.
140
- if (scopes.length === 0) {
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. Independent of the doc lane:
163
- // its failure is swallowed inside `applyReconcile`.
164
- if (held != null) {
165
- await applyReconcile(peerDID, result.credentialReconcile);
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
- if (scopes.length === 0) {
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',
@@ -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 { OwnIdentity } from '@kokuin/token';
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