@kubun/plugin-p2p 0.10.1 → 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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -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
- import { type VerifyTokenHook } from '@enkaku/capability';
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 '@enkaku/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>;
@@ -1 +1,114 @@
1
- import{checkCapability as e}from"@enkaku/capability";export async function checkSyncDelegation(r,t,n,o){if(!n||0===n.length)return!1;let c=["*","urn:kubun:user:*",`urn:kubun:user:${t}`],a="document/read";for(let u of c)try{return await e({act:a,res:u},{iss:r,sub:t,cap:n},{verifyToken:o}),!0}catch{}for(let u of n)for(let n of c)try{return await e({act:a,res:n},{iss:r,sub:t,cap:u},{verifyToken:o}),!0}catch{}return!1}export async function authorizeScope(e){return e.viewerDID===e.ownerDID||checkSyncDelegation(e.viewerDID,e.ownerDID,e.delegationTokens,e.revocationChecker)}
1
+ import { checkCapability } from '@kokuin/capability';
2
+ /**
3
+ * Check if delegation tokens grant the viewer read access to a user's documents.
4
+ *
5
+ * @param viewerDID - The DID of the requesting viewer
6
+ * @param ownerDID - The DID of the document owner
7
+ * @param delegationTokens - Array of delegation JWT tokens
8
+ * @param revocationChecker - Optional per-leaf-capability verification hook.
9
+ * When set, capabilities whose `jti` has been revoked cause the check to
10
+ * fail closed.
11
+ * @returns true if any token grants read access
12
+ */ export async function checkSyncDelegation(viewerDID, ownerDID, delegationTokens, revocationChecker) {
13
+ if (!delegationTokens || delegationTokens.length === 0) {
14
+ return false;
15
+ }
16
+ // Resource patterns to check (from most general to specific)
17
+ // We need at least read access to the user's documents
18
+ const resources = [
19
+ '*',
20
+ 'urn:kubun:user:*',
21
+ `urn:kubun:user:${ownerDID}`
22
+ ];
23
+ const action = 'document/read';
24
+ // Try delegation chain first (tokens are a chain: A->B->C)
25
+ for (const res of resources){
26
+ try {
27
+ await checkCapability({
28
+ act: action,
29
+ res
30
+ }, {
31
+ iss: viewerDID,
32
+ sub: ownerDID,
33
+ cap: delegationTokens
34
+ }, {
35
+ verifyToken: revocationChecker
36
+ });
37
+ return true;
38
+ } catch {
39
+ // Continue checking other resources
40
+ }
41
+ }
42
+ // Try each token independently (multiple independent grants)
43
+ for (const token of delegationTokens){
44
+ for (const res of resources){
45
+ try {
46
+ await checkCapability({
47
+ act: action,
48
+ res
49
+ }, {
50
+ iss: viewerDID,
51
+ sub: ownerDID,
52
+ cap: token
53
+ }, {
54
+ verifyToken: revocationChecker
55
+ });
56
+ return true;
57
+ } catch {
58
+ // Continue checking
59
+ }
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ /**
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.
82
+ */ export async function authorizeScope(params) {
83
+ if (params.viewerDID === params.ownerDID) {
84
+ return true;
85
+ }
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;
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;
@@ -1 +1,182 @@
1
- export const DEFAULT_BROADCAST_BATCH_CONFIG={enabled:!0,windowMs:500,maxCount:20,maxBytes:65536};export function createBroadcastQueue(e){let{config:t,scheduleBroadcast:r,logger:n}=e,l=new Map,o=!1;function u(e){let t=l.get(e);if(null==t||0===t.entries.length)return;null!=t.timer&&(clearTimeout(t.timer),t.timer=null);let o=t.entries,u=t.senderPeerDID;l.delete(e);try{r(e,{type:"mutation:apply",entries:o,senderPeerDID:u})}catch(t){n?.warn("broadcast-queue flush failed",{targetGroupID:e,entryCount:o.length,error:String(t)})}}return{enqueue:function(e,i,a){if(o)return void n?.warn("broadcast-queue enqueue after dispose, dropping entry",{targetGroupID:e,docID:i.docID});if(!t.enabled){try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue pass-through scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let s=JSON.stringify(i).length;if(s>=t.maxBytes){let t=l.get(e);null!=t&&t.entries.length>0&&u(e);try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue oversize-entry scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let d=l.get(e);if(null!=d&&d.senderPeerDID!==a&&(u(e),d=void 0),null!=d&&d.bytes+s>t.maxBytes&&(u(e),d=void 0),null==d){l.set(e,{entries:[i],bytes:s,senderPeerDID:a,timer:null});let r=l.get(e);return null!=r&&r.entries.length>=t.maxCount?void u(e):void!function(e){let r=l.get(e);if(null==r||null!=r.timer)return;let n=setTimeout(()=>{u(e)},t.windowMs),o=n.unref;"function"==typeof o&&o.call(n),r.timer=n}(e)}d.entries.push(i),d.bytes+=s,d.entries.length>=t.maxCount&&u(e)},dispose:function(){if(!o)for(let e of(o=!0,Array.from(l.keys())))u(e)}}}
1
+ export const DEFAULT_BROADCAST_BATCH_CONFIG = {
2
+ enabled: true,
3
+ windowMs: 500,
4
+ maxCount: 20,
5
+ maxBytes: 64 * 1024
6
+ };
7
+ /**
8
+ * Build a per-target-group batch queue around a `scheduleBroadcast` hook.
9
+ *
10
+ * The `mutation:apply` broadcast variant carries `entries: Array<...>` —
11
+ * a queue flush emits ONE broadcast with the accumulated entries, not one
12
+ * broadcast per entry.
13
+ *
14
+ * Each enqueued entry's wire size is approximated via `JSON.stringify(entry).length`.
15
+ * The accumulated byte budget tracks per-batch payload size only — message
16
+ * envelope (`type`, `senderPeerDID`) overhead is intentionally ignored to keep
17
+ * accounting cheap and predictable.
18
+ *
19
+ * Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
20
+ * entry in each batch. If a later enqueue arrives with a different sender
21
+ * (defensive — in practice senderPeerDID is constant per peer), the prior
22
+ * batch is flushed and a fresh batch starts, so every flushed message carries
23
+ * a single coherent `senderPeerDID`.
24
+ */ export function createBroadcastQueue(params) {
25
+ const { config, scheduleBroadcast, logger } = params;
26
+ const queues = new Map();
27
+ let disposed = false;
28
+ function clearTimer(state) {
29
+ if (state.timer != null) {
30
+ clearTimeout(state.timer);
31
+ state.timer = null;
32
+ }
33
+ }
34
+ function flush(targetGroupID) {
35
+ const state = queues.get(targetGroupID);
36
+ if (state == null || state.entries.length === 0) {
37
+ return;
38
+ }
39
+ clearTimer(state);
40
+ const entries = state.entries;
41
+ const senderPeerDID = state.senderPeerDID;
42
+ queues.delete(targetGroupID);
43
+ try {
44
+ scheduleBroadcast(targetGroupID, {
45
+ type: 'mutation:apply',
46
+ entries,
47
+ senderPeerDID
48
+ });
49
+ } catch (error) {
50
+ logger?.warn('broadcast-queue flush failed', {
51
+ targetGroupID,
52
+ entryCount: entries.length,
53
+ error: String(error)
54
+ });
55
+ }
56
+ }
57
+ function startTimer(targetGroupID) {
58
+ const state = queues.get(targetGroupID);
59
+ if (state == null || state.timer != null) {
60
+ return;
61
+ }
62
+ const timer = setTimeout(()=>{
63
+ flush(targetGroupID);
64
+ }, config.windowMs);
65
+ // Don't hold the process open just for a pending broadcast batch.
66
+ // Node returns a Timeout object with `unref()`; browsers return a number.
67
+ const maybeUnref = timer.unref;
68
+ if (typeof maybeUnref === 'function') {
69
+ maybeUnref.call(timer);
70
+ }
71
+ state.timer = timer;
72
+ }
73
+ function appendToFreshBatch(targetGroupID, entry, entryBytes, senderPeerDID) {
74
+ const state = {
75
+ entries: [
76
+ entry
77
+ ],
78
+ bytes: entryBytes,
79
+ senderPeerDID,
80
+ timer: null
81
+ };
82
+ queues.set(targetGroupID, state);
83
+ }
84
+ function enqueue(targetGroupID, entry, senderPeerDID) {
85
+ if (disposed) {
86
+ logger?.warn('broadcast-queue enqueue after dispose, dropping entry', {
87
+ targetGroupID,
88
+ docID: entry.docID
89
+ });
90
+ return;
91
+ }
92
+ // Pass-through mode: emit each entry as its own single-entry broadcast.
93
+ if (!config.enabled) {
94
+ try {
95
+ scheduleBroadcast(targetGroupID, {
96
+ type: 'mutation:apply',
97
+ entries: [
98
+ entry
99
+ ],
100
+ senderPeerDID
101
+ });
102
+ } catch (error) {
103
+ logger?.warn('broadcast-queue pass-through scheduleBroadcast failed', {
104
+ targetGroupID,
105
+ docID: entry.docID,
106
+ error: String(error)
107
+ });
108
+ }
109
+ return;
110
+ }
111
+ const entryBytes = JSON.stringify(entry).length;
112
+ // Oversize lone entry: skip the queue and ship as its own batch immediately.
113
+ // If a batch is already queued for this group, flush it first to preserve
114
+ // ordering (older entries ship before this lone giant).
115
+ if (entryBytes >= config.maxBytes) {
116
+ const existing = queues.get(targetGroupID);
117
+ if (existing != null && existing.entries.length > 0) {
118
+ flush(targetGroupID);
119
+ }
120
+ try {
121
+ scheduleBroadcast(targetGroupID, {
122
+ type: 'mutation:apply',
123
+ entries: [
124
+ entry
125
+ ],
126
+ senderPeerDID
127
+ });
128
+ } catch (error) {
129
+ logger?.warn('broadcast-queue oversize-entry scheduleBroadcast failed', {
130
+ targetGroupID,
131
+ docID: entry.docID,
132
+ error: String(error)
133
+ });
134
+ }
135
+ return;
136
+ }
137
+ let state = queues.get(targetGroupID);
138
+ // Sender mismatch: flush prior batch, start fresh.
139
+ if (state != null && state.senderPeerDID !== senderPeerDID) {
140
+ flush(targetGroupID);
141
+ state = undefined;
142
+ }
143
+ // Byte threshold: would adding this entry overflow? Flush first, start new batch.
144
+ if (state != null && state.bytes + entryBytes > config.maxBytes) {
145
+ flush(targetGroupID);
146
+ state = undefined;
147
+ }
148
+ if (state == null) {
149
+ appendToFreshBatch(targetGroupID, entry, entryBytes, senderPeerDID);
150
+ // Start the window timer for this new batch — only if we won't flush
151
+ // synchronously below for count reasons.
152
+ const fresh = queues.get(targetGroupID);
153
+ if (fresh != null && fresh.entries.length >= config.maxCount) {
154
+ flush(targetGroupID);
155
+ return;
156
+ }
157
+ startTimer(targetGroupID);
158
+ return;
159
+ }
160
+ state.entries.push(entry);
161
+ state.bytes += entryBytes;
162
+ // Count threshold: flush synchronously without waiting for the timer.
163
+ if (state.entries.length >= config.maxCount) {
164
+ flush(targetGroupID);
165
+ }
166
+ }
167
+ function dispose() {
168
+ if (disposed) {
169
+ return;
170
+ }
171
+ disposed = true;
172
+ // Snapshot keys — flush mutates the map.
173
+ const targets = Array.from(queues.keys());
174
+ for (const targetGroupID of targets){
175
+ flush(targetGroupID);
176
+ }
177
+ }
178
+ return {
179
+ enqueue,
180
+ dispose
181
+ };
182
+ }