@kubun/plugin-p2p 0.11.0 → 0.12.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 (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +56 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -0,0 +1,23 @@
1
+ import type { OwnIdentity } from '@kokuin/token';
2
+ import type { KubunDB } from '@kubun/db';
3
+ import type { EngineEventBus, EngineEvents } from '@kubun/engine';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
+ export type AccessDefaultSenderParams = {
7
+ db: KubunDB;
8
+ eventBus: EngineEventBus<EngineEvents>;
9
+ identity: OwnIdentity;
10
+ /** Fire-and-forget hub broadcast trigger, one call per target group. */
11
+ scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
12
+ logger?: Logger;
13
+ };
14
+ /**
15
+ * Subscribe to the engine's access-default events and replicate each rule to
16
+ * every MLS group the local peer belongs to. A model default is owner-global,
17
+ * not group-scoped: any co-member evaluating the owner's documents needs the
18
+ * rule, and the receiver's sender-bound apply keeps a forged owner out. The
19
+ * fan-out is a single tiny frame per group, so no scope resolution is needed.
20
+ *
21
+ * Returns a combined unsubscribe function for both listeners.
22
+ */
23
+ export declare function wireAccessDefaultSender(params: AccessDefaultSenderParams): () => void;
@@ -0,0 +1,80 @@
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ import { signAccessDefaultRemove, signAccessDefaultSet } from '../groups/access-default-token.js';
3
+ /**
4
+ * Subscribe to the engine's access-default events and replicate each rule to
5
+ * every MLS group the local peer belongs to. A model default is owner-global,
6
+ * not group-scoped: any co-member evaluating the owner's documents needs the
7
+ * rule, and the receiver's sender-bound apply keeps a forged owner out. The
8
+ * fan-out is a single tiny frame per group, so no scope resolution is needed.
9
+ *
10
+ * Returns a combined unsubscribe function for both listeners.
11
+ */ export function wireAccessDefaultSender(params) {
12
+ const broadcastToOwnGroups = async (message)=>{
13
+ const p2pStore = await getP2PStore(params.db);
14
+ const groups = await p2pStore.getGroupsForMember(params.identity.id);
15
+ // Distinct groups over distinct hub lanes with nothing shared between them,
16
+ // so the fan-out runs concurrently: in sequence one slow lane delays every
17
+ // group behind it.
18
+ await Promise.all(groups.map((group)=>params.scheduleBroadcast(group.id, message)));
19
+ };
20
+ const unsubscribeSet = params.eventBus.on('engine:access-default:set', async (event)=>{
21
+ try {
22
+ const auth = await signAccessDefaultSet(params.identity, {
23
+ modelID: event.modelID,
24
+ permissionType: event.permissionType,
25
+ rule: {
26
+ level: event.accessLevel,
27
+ allowedDIDs: event.allowedDIDs,
28
+ allowedCircles: event.allowedCircles,
29
+ allowedGroups: event.allowedGroups
30
+ },
31
+ hlc: event.hlc
32
+ });
33
+ await broadcastToOwnGroups({
34
+ type: 'access-default:set',
35
+ modelID: event.modelID,
36
+ permissionType: event.permissionType,
37
+ rule: {
38
+ level: event.accessLevel,
39
+ allowedDIDs: event.allowedDIDs,
40
+ allowedCircles: event.allowedCircles,
41
+ allowedGroups: event.allowedGroups
42
+ },
43
+ ownerDID: event.ownerDID,
44
+ hlc: event.hlc,
45
+ auth
46
+ });
47
+ } catch (error) {
48
+ params.logger?.warn('access-default-sender set handler failed', {
49
+ modelID: event.modelID,
50
+ error: String(error)
51
+ });
52
+ }
53
+ });
54
+ const unsubscribeRemove = params.eventBus.on('engine:access-default:removed', async (event)=>{
55
+ try {
56
+ const auth = await signAccessDefaultRemove(params.identity, {
57
+ modelID: event.modelID,
58
+ permissionTypes: event.permissionTypes,
59
+ hlc: event.hlc
60
+ });
61
+ await broadcastToOwnGroups({
62
+ type: 'access-default:remove',
63
+ modelID: event.modelID,
64
+ permissionTypes: event.permissionTypes,
65
+ ownerDID: event.ownerDID,
66
+ hlc: event.hlc,
67
+ auth
68
+ });
69
+ } catch (error) {
70
+ params.logger?.warn('access-default-sender remove handler failed', {
71
+ modelID: event.modelID,
72
+ error: String(error)
73
+ });
74
+ }
75
+ });
76
+ return ()=>{
77
+ unsubscribeSet();
78
+ unsubscribeRemove();
79
+ };
80
+ }
@@ -1,4 +1,5 @@
1
1
  import { type VerifyTokenHook } from '@kokuin/capability';
2
+ import type { StoredAccessRule } from '@kubun/store-graph';
2
3
  /**
3
4
  * Check if delegation tokens grant the viewer read access to a user's documents.
4
5
  *
@@ -11,15 +12,68 @@ import { type VerifyTokenHook } from '@kokuin/capability';
11
12
  * @returns true if any token grants read access
12
13
  */
13
14
  export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>, revocationChecker?: VerifyTokenHook): Promise<boolean>;
15
+ /**
16
+ * Resolver for circle-granted sync scopes. All reads run against the SERVING
17
+ * device's own state: the owner's tier-2 read default, the requester's circle
18
+ * membership, and the requester's group membership. The methods are dumb store
19
+ * reads — the authorization rule combining them lives in {@link authorizeScope}
20
+ * so implementations cannot drift on the security-critical conjunction.
21
+ */
22
+ export type CircleReadGrantResolver = {
23
+ /** The owner's tier-2 read default for the model, or `null` if none exists. */
24
+ getUserModelReadDefault(ownerDID: string, modelID: string): Promise<StoredAccessRule | null>;
25
+ /**
26
+ * Which of `circleIDs` the viewer reaches, each with the group it belongs to.
27
+ * An unknown or tombstoned circle is absent, so the gate fails closed on it.
28
+ *
29
+ * Batched rather than per-circle so the rule below costs a fixed number of
30
+ * reads instead of one per named circle — and the group arrives with the id,
31
+ * which keeps the tombstone filter that decides "unknown circle" in one place
32
+ * instead of two reads that could disagree.
33
+ */
34
+ listAccessibleCircles(viewerDID: string, circleIDs: Array<string>): Promise<Array<{
35
+ id: string;
36
+ groupID: string;
37
+ }>>;
38
+ /**
39
+ * Which of `groupIDs` the viewer is a member of, per the serving device's own
40
+ * membership view (rows grown only from local ops and verified MLS commits).
41
+ */
42
+ listMemberGroupIDs(viewerDID: string, groupIDs: Array<string>): Promise<Array<string>>;
43
+ };
14
44
  export type AuthorizeScopeParams = {
15
45
  viewerDID: string;
16
46
  ownerDID: string;
47
+ modelID: string;
17
48
  delegationTokens: Array<string>;
18
49
  revocationChecker?: VerifyTokenHook;
50
+ /**
51
+ * Optional resolver enabling owner-granted read. When set, a scope the owner
52
+ * and delegation arms reject is still accepted if the owner's tier-2 read
53
+ * default for the model is `anyone`, or is `restricted` and names a circle the
54
+ * viewer belongs to. Only an explicit owner grant widens: the server default is
55
+ * never consulted, so a model with no tier-2 read default stays owner/
56
+ * delegation only. Omitted — owner + delegation only, unchanged.
57
+ */
58
+ circleReadGrant?: CircleReadGrantResolver;
19
59
  };
20
60
  /**
21
- * Authorize a single sync scope: the viewer must be the owner, or hold a
22
- * delegation token granting read access to the owner's documents. Shared by
23
- * negotiate and merkle-sync so the two access gates cannot drift apart.
61
+ * Authorize a single sync scope: the viewer must be the owner, hold a delegation
62
+ * token granting read access to the owner's documents, or — when a
63
+ * {@link CircleReadGrantResolver} is supplied be reached by the owner's tier-2
64
+ * read default for the model, either because that default is `anyone` or because
65
+ * the viewer is a member of a circle it names AND a member of that circle's
66
+ * group per the serving device's own view. Shared by negotiate and merkle-sync
67
+ * so the two access gates cannot drift apart. Fail-closed: any arm that is not
68
+ * satisfiable denies.
69
+ *
70
+ * The group-membership conjunct is that half of the open-access definition
71
+ * (`explicitMember OR (open AND groupMember)`): for an open circle
72
+ * `isMemberOfAnyCircle` already implies it, so the outer check is
73
+ * redundant-but-consistent; for the explicit arm it is a data-plane backstop.
74
+ * The circle roster is a projection of admin-signed ledger entries, while the
75
+ * serving device's group-membership rows grow only from its own ops and verified
76
+ * MLS commits — so even a polluted circle projection cannot open the data plane
77
+ * without a matching local row. Unknown circle or unresolvable group → deny.
24
78
  */
25
79
  export declare function authorizeScope(params: AuthorizeScopeParams): Promise<boolean>;
@@ -62,12 +62,53 @@ import { checkCapability } from '@kokuin/capability';
62
62
  return false;
63
63
  }
64
64
  /**
65
- * Authorize a single sync scope: the viewer must be the owner, or hold a
66
- * delegation token granting read access to the owner's documents. Shared by
67
- * negotiate and merkle-sync so the two access gates cannot drift apart.
65
+ * Authorize a single sync scope: the viewer must be the owner, hold a delegation
66
+ * token granting read access to the owner's documents, or — when a
67
+ * {@link CircleReadGrantResolver} is supplied be reached by the owner's tier-2
68
+ * read default for the model, either because that default is `anyone` or because
69
+ * the viewer is a member of a circle it names AND a member of that circle's
70
+ * group per the serving device's own view. Shared by negotiate and merkle-sync
71
+ * so the two access gates cannot drift apart. Fail-closed: any arm that is not
72
+ * satisfiable denies.
73
+ *
74
+ * The group-membership conjunct is that half of the open-access definition
75
+ * (`explicitMember OR (open AND groupMember)`): for an open circle
76
+ * `isMemberOfAnyCircle` already implies it, so the outer check is
77
+ * redundant-but-consistent; for the explicit arm it is a data-plane backstop.
78
+ * The circle roster is a projection of admin-signed ledger entries, while the
79
+ * serving device's group-membership rows grow only from its own ops and verified
80
+ * MLS commits — so even a polluted circle projection cannot open the data plane
81
+ * without a matching local row. Unknown circle or unresolvable group → deny.
68
82
  */ export async function authorizeScope(params) {
69
83
  if (params.viewerDID === params.ownerDID) {
70
84
  return true;
71
85
  }
72
- return checkSyncDelegation(params.viewerDID, params.ownerDID, params.delegationTokens, params.revocationChecker);
86
+ if (await checkSyncDelegation(params.viewerDID, params.ownerDID, params.delegationTokens, params.revocationChecker)) {
87
+ return true;
88
+ }
89
+ if (params.circleReadGrant != null) {
90
+ const grant = params.circleReadGrant;
91
+ const rule = await grant.getUserModelReadDefault(params.ownerDID, params.modelID);
92
+ // Reached only through a rule the owner actually stored: a `null` rule means
93
+ // the owner declared nothing, and the server default must never stand in for
94
+ // a declaration — a device whose default flipped would otherwise open every
95
+ // model on it.
96
+ if (rule != null && rule.level === 'anyone') {
97
+ return true;
98
+ }
99
+ if (rule != null && rule.level === 'restricted' && rule.allowedCircles != null && rule.allowedCircles.length > 0) {
100
+ // Per-circle conjunction: both checks must hold for the SAME circle, so a
101
+ // membership in one granted circle can never combine with membership in an
102
+ // unrelated circle's group. The reads are batched but the conjunction is
103
+ // evaluated here, per circle, so no store implementation decides it.
104
+ const accessible = await grant.listAccessibleCircles(params.viewerDID, rule.allowedCircles);
105
+ if (accessible.length > 0) {
106
+ const memberGroupIDs = new Set(await grant.listMemberGroupIDs(params.viewerDID, Array.from(new Set(accessible.map((circle)=>circle.groupID)))));
107
+ if (accessible.some((circle)=>memberGroupIDs.has(circle.groupID))) {
108
+ return true;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return false;
73
114
  }
@@ -30,7 +30,7 @@ export type BroadcastQueue = {
30
30
  * Enqueue a single mutation entry for the given target group, attributed to
31
31
  * `senderPeerDID`. May trigger an immediate flush if a threshold is reached.
32
32
  */
33
- enqueue(targetGroupID: string, entry: MutationApplyEntry, senderPeerDID: string): void;
33
+ enqueue(targetGroupID: string, entry: MutationApplyEntry, senderPeerDID: string | undefined): void;
34
34
  /**
35
35
  * Cancel all pending timers and synchronously flush remaining entries
36
36
  * via `scheduleBroadcast`. Safe to call multiple times.
@@ -52,8 +52,7 @@ export type BroadcastQueue = {
52
52
  * Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
53
53
  * entry in each batch. If a later enqueue arrives with a different sender
54
54
  * (defensive — in practice senderPeerDID is constant per peer), the prior
55
- * batch is flushed and a fresh batch starts. This preserves the invariant
56
- * that every flushed `mutation:apply` message carries a single coherent
57
- * `senderPeerDID`.
55
+ * batch is flushed and a fresh batch starts, so every flushed message carries
56
+ * a single coherent `senderPeerDID`.
58
57
  */
59
58
  export declare function createBroadcastQueue(params: BroadcastQueueParams): BroadcastQueue;
@@ -19,9 +19,8 @@ export const DEFAULT_BROADCAST_BATCH_CONFIG = {
19
19
  * Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
20
20
  * entry in each batch. If a later enqueue arrives with a different sender
21
21
  * (defensive — in practice senderPeerDID is constant per peer), the prior
22
- * batch is flushed and a fresh batch starts. This preserves the invariant
23
- * that every flushed `mutation:apply` message carries a single coherent
24
- * `senderPeerDID`.
22
+ * batch is flushed and a fresh batch starts, so every flushed message carries
23
+ * a single coherent `senderPeerDID`.
25
24
  */ export function createBroadcastQueue(params) {
26
25
  const { config, scheduleBroadcast, logger } = params;
27
26
  const queues = new Map();
@@ -1,17 +1,18 @@
1
- import type { KubunDB } from '@kubun/db';
1
+ import type { StoreProvider } from '@kubun/db';
2
2
  export type CatalogSyncScope = {
3
3
  modelIDs: Array<string>;
4
4
  owners: Array<string> | undefined;
5
- requiredClusterIDs: Array<string>;
6
- missingClusterIDs: Array<string>;
7
5
  };
8
6
  /**
9
7
  * Resolve catalog criteria to sync-compatible scopes.
10
8
  *
11
9
  * Takes catalog IDs, resolves their criteria, and returns:
12
- * - modelIDs: union of all model filters from all catalogs
10
+ * - modelIDs: union of all model filters from all catalogs, with an omitted
11
+ * filter expanded to every model this device has deployed
13
12
  * - owners: union of all owner filters (explicit + circle members)
14
- * - requiredClusterIDs: cluster IDs for all required models
15
- * - missingClusterIDs: cluster IDs for models not in knownModelIDs
13
+ *
14
+ * Which of those models the requester lacks is NOT decided here: the negotiate
15
+ * handler answers that over the scopes it accepted, which is the only set that
16
+ * production actually sends.
16
17
  */
17
- export declare function resolveCatalogSyncScopes(db: KubunDB, catalogIDs: Array<string>, knownModelIDs?: Array<string>): Promise<CatalogSyncScope>;
18
+ export declare function resolveCatalogSyncScopes(db: StoreProvider, catalogIDs: Array<string>): Promise<CatalogSyncScope>;
@@ -4,11 +4,14 @@ import { getP2PStore } from '@kubun/store-p2p';
4
4
  * Resolve catalog criteria to sync-compatible scopes.
5
5
  *
6
6
  * Takes catalog IDs, resolves their criteria, and returns:
7
- * - modelIDs: union of all model filters from all catalogs
7
+ * - modelIDs: union of all model filters from all catalogs, with an omitted
8
+ * filter expanded to every model this device has deployed
8
9
  * - owners: union of all owner filters (explicit + circle members)
9
- * - requiredClusterIDs: cluster IDs for all required models
10
- * - missingClusterIDs: cluster IDs for models not in knownModelIDs
11
- */ export async function resolveCatalogSyncScopes(db, catalogIDs, knownModelIDs) {
10
+ *
11
+ * Which of those models the requester lacks is NOT decided here: the negotiate
12
+ * handler answers that over the scopes it accepted, which is the only set that
13
+ * production actually sends.
14
+ */ export async function resolveCatalogSyncScopes(db, catalogIDs) {
12
15
  const [graphStore, p2pStore] = await Promise.all([
13
16
  getGraphStore(db),
14
17
  getP2PStore(db)
@@ -16,53 +19,66 @@ import { getP2PStore } from '@kubun/store-p2p';
16
19
  const allModelIDs = new Set();
17
20
  const allOwners = new Set();
18
21
  let hasOwnerFilter = false;
22
+ // Catalogs are advisory: only a locally-activated catalog scopes sync. A known
23
+ // (not-activated) or unknown catalog contributes nothing. Read as one batch —
24
+ // the loop below needs every record anyway, and `resolveCatalogScope` reads the
25
+ // row again for itself, so a per-id read here would be the second of three.
26
+ const catalogs = await graphStore.getCatalogs(catalogIDs);
27
+ const circleIDs = new Set();
28
+ // Read once, and only if some catalog omits its model filter.
29
+ let deployedModelIDs;
30
+ const getDeployedModelIDs = async ()=>{
31
+ deployedModelIDs ??= await graphStore.listDocumentModelIDs();
32
+ return deployedModelIDs;
33
+ };
19
34
  for (const catalogID of catalogIDs){
20
- const scope = await graphStore.resolveCatalogScope(catalogID);
21
- if (scope.models != null) {
22
- for (const m of scope.models){
23
- allModelIDs.add(m);
24
- }
35
+ const catalog = catalogs.get(catalogID);
36
+ if (catalog == null || catalog.active !== 1) {
37
+ continue;
25
38
  }
39
+ const scope = await graphStore.resolveCatalogScope(catalogID);
40
+ const criteria = catalog.filter_criteria;
41
+ const catalogCircles = criteria.circles != null && criteria.circles.length > 0 ? criteria.circles : undefined;
26
42
  if (scope.owners != null) {
27
43
  hasOwnerFilter = true;
28
44
  for (const o of scope.owners){
29
45
  allOwners.add(o);
30
46
  }
31
47
  }
32
- // Resolve circle-based owners via p2p store
33
- const catalog = await graphStore.getCatalog(catalogID);
34
- if (catalog != null) {
35
- const criteria = catalog.filter_criteria;
36
- if (criteria.circles != null && criteria.circles.length > 0) {
37
- hasOwnerFilter = true;
38
- for (const circleID of criteria.circles){
39
- const members = await p2pStore.listCircleMembers(circleID);
40
- for (const member of members){
41
- allOwners.add(member.member_did);
42
- }
43
- }
48
+ if (catalogCircles != null) {
49
+ hasOwnerFilter = true;
50
+ for (const circleID of catalogCircles){
51
+ circleIDs.add(circleID);
52
+ }
53
+ }
54
+ if (scope.models != null) {
55
+ for (const m of scope.models){
56
+ allModelIDs.add(m);
57
+ }
58
+ } else if (scope.owners != null || catalogCircles != null) {
59
+ // "Omitted means no constraint" is only true where a dimension can be
60
+ // enumerated: expanding models at resolution time also covers a model
61
+ // deployed after the catalog was written. Owners stay anchorless on
62
+ // purpose — "everyone" is not a set — so the expansion is gated on the
63
+ // catalog naming owners or circles. Without that gate a criteria-less
64
+ // catalog would widen the SERVING side, where an absent owner filter
65
+ // means every distinct owner of every model.
66
+ for (const m of (await getDeployedModelIDs())){
67
+ allModelIDs.add(m);
44
68
  }
45
69
  }
46
70
  }
47
- const modelIDs = Array.from(allModelIDs);
48
- const owners = hasOwnerFilter ? Array.from(allOwners) : undefined;
49
- // Resolve clusters for all models in a single pass
50
- const knownSet = new Set(knownModelIDs ?? []);
51
- const requiredClusterIDs = new Set();
52
- const missingClusterIDs = new Set();
53
- await Promise.all(modelIDs.map(async (modelID)=>{
54
- const clusterID = await graphStore.getClusterForModel(modelID);
55
- if (clusterID != null) {
56
- requiredClusterIDs.add(clusterID);
57
- if (!knownSet.has(modelID)) {
58
- missingClusterIDs.add(clusterID);
59
- }
71
+ // Resolve circle-based owners via the p2p store, every catalog's circles at
72
+ // once. `listOwnersForCircles` unions `listCircleOwners` over the set: explicit
73
+ // members plus, for an open circle, the whole group's members — the pull-owner
74
+ // set mirrors the open-access predicate.
75
+ if (circleIDs.size > 0) {
76
+ for (const owner of (await p2pStore.listOwnersForCircles(Array.from(circleIDs)))){
77
+ allOwners.add(owner);
60
78
  }
61
- }));
79
+ }
62
80
  return {
63
- modelIDs,
64
- owners,
65
- requiredClusterIDs: Array.from(requiredClusterIDs),
66
- missingClusterIDs: Array.from(missingClusterIDs)
81
+ modelIDs: Array.from(allModelIDs),
82
+ owners: hasOwnerFilter ? Array.from(allOwners) : undefined
67
83
  };
68
84
  }
@@ -9,4 +9,13 @@ export declare class SyncAccessDeniedError extends Error {
9
9
  constructor(reason: string, message?: string);
10
10
  get code(): 'KB08';
11
11
  get reason(): string;
12
+ /**
13
+ * Carried alongside the code when the handler boundary translates this into a
14
+ * wire error, so a caller can tell the denials apart without parsing the
15
+ * message. The reasons are a fixed categorical set and disclose nothing the
16
+ * caller did not supply.
17
+ */
18
+ get data(): {
19
+ reason: string;
20
+ };
12
21
  }
@@ -16,4 +16,14 @@
16
16
  get reason() {
17
17
  return this.#reason;
18
18
  }
19
+ /**
20
+ * Carried alongside the code when the handler boundary translates this into a
21
+ * wire error, so a caller can tell the denials apart without parsing the
22
+ * message. The reasons are a fixed categorical set and disclose nothing the
23
+ * caller did not supply.
24
+ */ get data() {
25
+ return {
26
+ reason: this.#reason
27
+ };
28
+ }
19
29
  }
@@ -71,27 +71,21 @@ export type EvaluateAndForwardResult = {
71
71
  skipped: number;
72
72
  };
73
73
  /**
74
- * Evaluate forwarding candidates for a single mutation:apply entry the local
75
- * peer has just applied, and forward to each surviving candidate group via
76
- * `scheduleBroadcast`.
74
+ * Evaluate forwarding candidates for one just-applied `mutation:apply` entry and
75
+ * forward to each survivor via `scheduleBroadcast`.
77
76
  *
78
- * Pipeline (per candidate group):
79
- * 1. Source group exclusion `sourceGroupID` is filtered out before any check.
80
- * 2. Access gate `computeBroadcastScopes` against the post-apply rule.
81
- * 3. Catalog gate at least one circle the local peer belongs to in the
82
- * candidate group must have a catalog whose `filter_criteria` matches the
83
- * document.
84
- * 4. Filter (when `forwarding` is a function) — narrows further.
85
- * 5. Dedup — `recordBroadcast` BEFORE schedule. False return → skip silently.
86
- * 6. Schedule — single-entry `mutation:apply` broadcast with
87
- * `senderPeerDID = selfDID`.
77
+ * Gates per candidate group, in order: the source group is excluded first;
78
+ * `computeBroadcastScopes` against the post-apply rule; a circle the local peer
79
+ * belongs to in that group must hold a catalog whose `filter_criteria` matches
80
+ * the document; then the caller's `forwarding` filter when it is a function.
81
+ * `recordBroadcast` runs BEFORE the schedule and a false return skips silently.
88
82
  *
89
- * Per-candidate errors are isolated: one bad candidate logs + continues; the
90
- * rest still get evaluated. The function never throws.
83
+ * Per-candidate errors are isolated one bad candidate logs and the rest are
84
+ * still evaluated and the function never throws.
91
85
  *
92
- * Routing decision: each candidate gets its OWN single-entry broadcast (no
93
- * batch queue). The queue's `senderPeerDID`-mismatch flush conflicts
94
- * with mixing locally-authored and forwarded entries in one batch — deferring
95
- * batched-forwarding keeps the receive path simple.
86
+ * Each candidate gets its OWN single-entry broadcast, `senderPeerDID = selfDID`,
87
+ * rather than going through the batch queue, whose `senderPeerDID`-mismatch
88
+ * flush conflicts with mixing locally-authored and forwarded entries in one
89
+ * batch.
96
90
  */
97
91
  export declare function evaluateAndForward(params: EvaluateAndForwardParams): Promise<EvaluateAndForwardResult>;
@@ -10,28 +10,22 @@ export function rethrowIfProgrammerError(error) {
10
10
  }
11
11
  }
12
12
  /**
13
- * Evaluate forwarding candidates for a single mutation:apply entry the local
14
- * peer has just applied, and forward to each surviving candidate group via
15
- * `scheduleBroadcast`.
13
+ * Evaluate forwarding candidates for one just-applied `mutation:apply` entry and
14
+ * forward to each survivor via `scheduleBroadcast`.
16
15
  *
17
- * Pipeline (per candidate group):
18
- * 1. Source group exclusion `sourceGroupID` is filtered out before any check.
19
- * 2. Access gate `computeBroadcastScopes` against the post-apply rule.
20
- * 3. Catalog gate at least one circle the local peer belongs to in the
21
- * candidate group must have a catalog whose `filter_criteria` matches the
22
- * document.
23
- * 4. Filter (when `forwarding` is a function) — narrows further.
24
- * 5. Dedup — `recordBroadcast` BEFORE schedule. False return → skip silently.
25
- * 6. Schedule — single-entry `mutation:apply` broadcast with
26
- * `senderPeerDID = selfDID`.
16
+ * Gates per candidate group, in order: the source group is excluded first;
17
+ * `computeBroadcastScopes` against the post-apply rule; a circle the local peer
18
+ * belongs to in that group must hold a catalog whose `filter_criteria` matches
19
+ * the document; then the caller's `forwarding` filter when it is a function.
20
+ * `recordBroadcast` runs BEFORE the schedule and a false return skips silently.
27
21
  *
28
- * Per-candidate errors are isolated: one bad candidate logs + continues; the
29
- * rest still get evaluated. The function never throws.
22
+ * Per-candidate errors are isolated one bad candidate logs and the rest are
23
+ * still evaluated and the function never throws.
30
24
  *
31
- * Routing decision: each candidate gets its OWN single-entry broadcast (no
32
- * batch queue). The queue's `senderPeerDID`-mismatch flush conflicts
33
- * with mixing locally-authored and forwarded entries in one batch — deferring
34
- * batched-forwarding keeps the receive path simple.
25
+ * Each candidate gets its OWN single-entry broadcast, `senderPeerDID = selfDID`,
26
+ * rather than going through the batch queue, whose `senderPeerDID`-mismatch
27
+ * flush conflicts with mixing locally-authored and forwarded entries in one
28
+ * batch.
35
29
  */ export async function evaluateAndForward(params) {
36
30
  // 1. Zero-overhead short-circuit when forwarding is disabled.
37
31
  if (params.forwarding === false) {