@kubun/plugin-p2p 0.11.0 → 0.12.1

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 +59 -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,20 @@
1
+ import type { HubStore } from '@kumiai/hub-protocol';
2
+ import type { LogHub } from '@kumiai/hub-tunnel';
3
+ /**
4
+ * An in-process `LogHub` over an in-memory `HubStore` that DELIVERS: a frame
5
+ * published on a topic is pushed to every OTHER subscriber's `receive` drain, so
6
+ * a peer bound to this hub wakes on a co-member's publish and pulls the log. The
7
+ * store owns the log semantics (append, compare-and-set on the head,
8
+ * `fetchTopic`); this wrapper owns the per-subscriber push fan-out the store has
9
+ * no notion of.
10
+ *
11
+ * The sender is excluded from its own fan-out (a publisher has already adopted
12
+ * what it published), matching the store's own recipient rule.
13
+ *
14
+ * The log is EPHEMERAL. It backs a group with no hub bound, where the device is
15
+ * the only member and so the only writer: the lane needs something to
16
+ * compare-and-set a head against, not a durable record. The MLS handle and the
17
+ * `CommitJournal` survive a restart, persisted elsewhere; these frames are never
18
+ * replayed, migrated, or served to another member.
19
+ */
20
+ export declare function createLoopbackLogHub(store?: HubStore): LogHub;
@@ -0,0 +1,178 @@
1
+ import { createMemoryStore } from '@kumiai/hub-server';
2
+ /**
3
+ * An in-process `LogHub` over an in-memory `HubStore` that DELIVERS: a frame
4
+ * published on a topic is pushed to every OTHER subscriber's `receive` drain, so
5
+ * a peer bound to this hub wakes on a co-member's publish and pulls the log. The
6
+ * store owns the log semantics (append, compare-and-set on the head,
7
+ * `fetchTopic`); this wrapper owns the per-subscriber push fan-out the store has
8
+ * no notion of.
9
+ *
10
+ * The sender is excluded from its own fan-out (a publisher has already adopted
11
+ * what it published), matching the store's own recipient rule.
12
+ *
13
+ * The log is EPHEMERAL. It backs a group with no hub bound, where the device is
14
+ * the only member and so the only writer: the lane needs something to
15
+ * compare-and-set a head against, not a durable record. The MLS handle and the
16
+ * `CommitJournal` survive a restart, persisted elsewhere; these frames are never
17
+ * replayed, migrated, or served to another member.
18
+ */ export function createLoopbackLogHub(store = createMemoryStore()) {
19
+ // topicID -> the DIDs subscribed to it.
20
+ const topicSubscribers = new Map();
21
+ // subscriberDID -> its live receive sinks (one per open `receive` drain).
22
+ const sinks = new Map();
23
+ const deliver = (message)=>{
24
+ const subscribers = topicSubscribers.get(message.topicID);
25
+ if (subscribers == null) {
26
+ return;
27
+ }
28
+ for (const subscriberDID of subscribers){
29
+ if (subscriberDID === message.senderDID) {
30
+ continue;
31
+ }
32
+ for (const push of sinks.get(subscriberDID) ?? []){
33
+ push(message);
34
+ }
35
+ }
36
+ };
37
+ return {
38
+ async subscribe (subscriberDID, topicID, options) {
39
+ await store.subscribe({
40
+ subscriberDID,
41
+ topicID,
42
+ ...options?.retention != null ? {
43
+ retention: options.retention
44
+ } : {}
45
+ });
46
+ let set = topicSubscribers.get(topicID);
47
+ if (set == null) {
48
+ set = new Set();
49
+ topicSubscribers.set(topicID, set);
50
+ }
51
+ set.add(subscriberDID);
52
+ },
53
+ async unsubscribe (subscriberDID, topicID) {
54
+ await store.unsubscribe(subscriberDID, topicID);
55
+ topicSubscribers.get(topicID)?.delete(subscriberDID);
56
+ },
57
+ receive (subscriberDID) {
58
+ const queue = [];
59
+ let resolveNext;
60
+ let closed = false;
61
+ const push = (message)=>{
62
+ if (closed) {
63
+ return;
64
+ }
65
+ if (resolveNext != null) {
66
+ const resolve = resolveNext;
67
+ resolveNext = undefined;
68
+ resolve({
69
+ value: message,
70
+ done: false
71
+ });
72
+ } else {
73
+ queue.push(message);
74
+ }
75
+ };
76
+ let set = sinks.get(subscriberDID);
77
+ if (set == null) {
78
+ set = new Set();
79
+ sinks.set(subscriberDID, set);
80
+ }
81
+ set.add(push);
82
+ const remove = ()=>{
83
+ closed = true;
84
+ sinks.get(subscriberDID)?.delete(push);
85
+ if (resolveNext != null) {
86
+ const resolve = resolveNext;
87
+ resolveNext = undefined;
88
+ resolve({
89
+ value: undefined,
90
+ done: true
91
+ });
92
+ }
93
+ };
94
+ const iterator = {
95
+ next () {
96
+ if (queue.length > 0) {
97
+ return Promise.resolve({
98
+ value: queue.shift(),
99
+ done: false
100
+ });
101
+ }
102
+ if (closed) {
103
+ return Promise.resolve({
104
+ value: undefined,
105
+ done: true
106
+ });
107
+ }
108
+ return new Promise((resolve)=>{
109
+ resolveNext = resolve;
110
+ });
111
+ },
112
+ return () {
113
+ remove();
114
+ return Promise.resolve({
115
+ value: undefined,
116
+ done: true
117
+ });
118
+ }
119
+ };
120
+ return {
121
+ [Symbol.asyncIterator] () {
122
+ return iterator;
123
+ },
124
+ return () {
125
+ remove();
126
+ }
127
+ };
128
+ },
129
+ async publish (params) {
130
+ const result = await store.publish({
131
+ senderDID: params.senderDID,
132
+ topicID: params.topicID,
133
+ payload: params.payload,
134
+ ...params.retain != null ? {
135
+ retain: params.retain
136
+ } : {},
137
+ ...'expectedHead' in params ? {
138
+ expectedHead: params.expectedHead
139
+ } : {},
140
+ ...params.publishID != null ? {
141
+ publishID: params.publishID
142
+ } : {}
143
+ });
144
+ // A deduped replay stored nothing and every current subscriber already saw
145
+ // the original — re-delivering it would apply the same commit twice.
146
+ if (!result.deduped) {
147
+ deliver({
148
+ sequenceID: result.sequenceID,
149
+ senderDID: params.senderDID,
150
+ topicID: params.topicID,
151
+ payload: params.payload,
152
+ // One sequence serves both classes here, so a log frame's own sequenceID is
153
+ // its position in the topic's log — the same value `fetchTopic` serves it at.
154
+ // A mailbox frame carries no key: it has no place in a log, and a falsy
155
+ // placeholder is a position a cursor moves to, skipping every frame below it.
156
+ ...params.retain === 'log' ? {
157
+ logPosition: result.sequenceID
158
+ } : {}
159
+ });
160
+ }
161
+ return {
162
+ sequenceID: result.sequenceID
163
+ };
164
+ },
165
+ async fetchTopic (params) {
166
+ return await store.fetchTopic({
167
+ subscriberDID: params.subscriberDID,
168
+ topicID: params.topicID,
169
+ ...params.after != null ? {
170
+ after: params.after
171
+ } : {},
172
+ ...params.limit != null ? {
173
+ limit: params.limit
174
+ } : {}
175
+ });
176
+ }
177
+ };
178
+ }
@@ -1,4 +1,5 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
+ import type { Runtime } from '@sozai/runtime';
2
3
  export type Hub = {
3
4
  id: string;
4
5
  label: string | null;
@@ -12,6 +13,12 @@ export type CreateHubParams = {
12
13
  url: string;
13
14
  label?: string | null;
14
15
  serverDID?: string | null;
16
+ /**
17
+ * Host runtime, used here to mint the new hub row's id. Propagated from the
18
+ * caller rather than constructed: this package runs on React Native and in
19
+ * the browser as well as on node, where node's `crypto` is not available.
20
+ */
21
+ runtime: Runtime;
15
22
  };
16
23
  export type UpdateHubParams = {
17
24
  stores: StoreProvider;
@@ -54,46 +61,36 @@ export type ListGroupsByHubParams = {
54
61
  stores: StoreProvider;
55
62
  hubID: string;
56
63
  };
57
- export type CaptureServerDIDParams = {
58
- stores: StoreProvider;
59
- hubURL: string;
60
- serverDID: string;
61
- };
62
64
  export type UpsertHubParams = {
63
65
  stores: StoreProvider;
64
66
  url: string;
65
67
  serverDID?: string | null;
68
+ /** As `CreateHubParams.runtime`. */
69
+ runtime: Runtime;
66
70
  };
67
- export declare class HubServerDIDConflictError extends Error {
68
- #private;
69
- constructor(params: {
70
- hubID: string;
71
- url: string;
72
- existingServerDID: string;
73
- providedServerDID: string;
74
- });
75
- get hubID(): string;
76
- get url(): string;
77
- get existingServerDID(): string;
78
- get providedServerDID(): string;
79
- }
71
+ /**
72
+ * Register a hub by URL, or return the row that URL already has.
73
+ *
74
+ * `serverDID` is a cache of the hub's current identity, not a pin, so a provided
75
+ * DID that differs from the recorded one simply replaces it — a hub redeployed
76
+ * under a new key is a routine event, and a wrong value here costs one refused
77
+ * connection, after which the device rediscovers the DID from the hub's `/info`.
78
+ * A provided null never clears a recorded DID.
79
+ */
80
80
  export declare function createHub(params: CreateHubParams): Promise<Hub>;
81
81
  /**
82
- * Idempotent register-or-pin entry-point. Applies the conflict matrix:
82
+ * Idempotent register-or-record entry-point. Applies the URL matrix:
83
83
  *
84
84
  * - URL not registered → insert a fresh row with `serverDID` (or null) and
85
85
  * return it.
86
- * - existing row, `server_did === null`, provided null → no-op.
87
- * - existing row, `server_did === null`, provided DID-A upgrade pin to
88
- * DID-A.
89
- * - existing row, `server_did === DID-A`, provided null or matching DID-A
90
- * no-op (no silent downgrade).
91
- * - existing row, `server_did === DID-A`, provided DID-B → throws
92
- * `HubServerDIDConflictError`. Caller must explicit `updateHub` to change
93
- * a pinned DID.
86
+ * - existing row, provided null → no-op, whatever the row records.
87
+ * - existing row, provided DID-A matching the record → no-op.
88
+ * - existing row, provided DID-A differing from the record → record DID-A. The
89
+ * recorded DID is a cache of the hub's current identity, so the newest claim
90
+ * wins and a wrong one self-corrects from `/info` on the next connect.
94
91
  *
95
92
  * Unlike `createHub`, `upsertHub` takes no `label` — it is for atomic
96
- * register+bind paths where the inviter only ships connectivity + pin.
93
+ * register+bind paths where the inviter only ships connectivity + hub identity.
97
94
  */
98
95
  export declare function upsertHub(params: UpsertHubParams): Promise<Hub>;
99
96
  export declare function updateHub(params: UpdateHubParams): Promise<Hub>;
@@ -104,15 +101,3 @@ export declare function bindHubToGroup(params: BindHubToGroupParams): Promise<bo
104
101
  export declare function unbindHubFromGroup(params: UnbindHubFromGroupParams): Promise<boolean>;
105
102
  export declare function listHubsByGroup(params: ListHubsByGroupParams): Promise<Array<Hub>>;
106
103
  export declare function listGroupsByHub(params: ListGroupsByHubParams): Promise<Array<string>>;
107
- /**
108
- * TOFU capture path. Looks up the local hub row by URL and applies the
109
- * conflict matrix:
110
- * - URL not registered → no-op, returns `false`.
111
- * - existing `server_did` is null → upgrade to `serverDID`, returns `true`.
112
- * - existing `server_did` matches `serverDID` → no-op, returns `false`.
113
- * - existing `server_did` differs from `serverDID` → throws
114
- * `HubServerDIDConflictError`. Pinned-mismatch enforcement (hard-fail
115
- * before any message is delivered) lives in the transport wrapper and
116
- * surfaces `HubServerDIDMismatchError` to callers.
117
- */
118
- export declare function captureServerDID(params: CaptureServerDIDParams): Promise<boolean>;
@@ -1,30 +1,4 @@
1
1
  import { getP2PStore } from '@kubun/store-p2p';
2
- export class HubServerDIDConflictError extends Error {
3
- #hubID;
4
- #url;
5
- #existingServerDID;
6
- #providedServerDID;
7
- constructor(params){
8
- super(`hub at ${params.url} is pinned to ${params.existingServerDID}; refusing to overwrite with ${params.providedServerDID}`);
9
- this.name = 'HubServerDIDConflictError';
10
- this.#hubID = params.hubID;
11
- this.#url = params.url;
12
- this.#existingServerDID = params.existingServerDID;
13
- this.#providedServerDID = params.providedServerDID;
14
- }
15
- get hubID() {
16
- return this.#hubID;
17
- }
18
- get url() {
19
- return this.#url;
20
- }
21
- get existingServerDID() {
22
- return this.#existingServerDID;
23
- }
24
- get providedServerDID() {
25
- return this.#providedServerDID;
26
- }
27
- }
28
2
  function rowToHub(row) {
29
3
  return {
30
4
  id: row.id,
@@ -35,61 +9,76 @@ function rowToHub(row) {
35
9
  updatedAt: row.updated_at == null ? null : new Date(row.updated_at)
36
10
  };
37
11
  }
38
- export async function createHub(params) {
12
+ /**
13
+ * Return an existing hub row, having replaced its recorded server DID when the
14
+ * caller supplied a different one. A provided null never clears what is there.
15
+ */ async function adoptServerDID(store, row, providedDID) {
16
+ if (providedDID == null || row.server_did === providedDID) {
17
+ return rowToHub(row);
18
+ }
19
+ await store.updateHub(row.id, {
20
+ server_did: providedDID
21
+ });
22
+ const updated = await store.getHub(row.id);
23
+ if (updated == null) throw new Error('Failed to read hub after adopting a server DID');
24
+ return rowToHub(updated);
25
+ }
26
+ /**
27
+ * Register a hub by URL, or return the row that URL already has.
28
+ *
29
+ * `serverDID` is a cache of the hub's current identity, not a pin, so a provided
30
+ * DID that differs from the recorded one simply replaces it — a hub redeployed
31
+ * under a new key is a routine event, and a wrong value here costs one refused
32
+ * connection, after which the device rediscovers the DID from the hub's `/info`.
33
+ * A provided null never clears a recorded DID.
34
+ */ export async function createHub(params) {
39
35
  const store = await getP2PStore(params.stores);
40
36
  const providedDID = params.serverDID ?? null;
41
37
  const existing = await store.getHubByURL(params.url);
42
38
  if (existing != null) {
43
- if (existing.server_did != null && providedDID != null && existing.server_did !== providedDID) {
44
- throw new HubServerDIDConflictError({
45
- hubID: existing.id,
46
- url: params.url,
47
- existingServerDID: existing.server_did,
48
- providedServerDID: providedDID
49
- });
50
- }
51
- if (existing.server_did == null && providedDID != null) {
52
- await store.updateHub(existing.id, {
53
- server_did: providedDID
54
- });
55
- const updated = await store.getHub(existing.id);
56
- if (updated == null) throw new Error('Failed to read hub after upgrade');
57
- return rowToHub(updated);
58
- }
59
- return rowToHub(existing);
39
+ return await adoptServerDID(store, existing, providedDID);
40
+ }
41
+ const id = params.runtime.getRandomID();
42
+ try {
43
+ await store.insertHub({
44
+ id,
45
+ label: params.label ?? null,
46
+ url: params.url,
47
+ server_did: providedDID
48
+ });
49
+ } catch (error) {
50
+ // The check above and this insert are two statements against a UNIQUE url,
51
+ // so a concurrent register of the same hub — two groups binding it at once,
52
+ // an invite's `suggestedHubs` racing a manual add makes the loser throw out
53
+ // of a function whose whole contract is idempotence. Adopt the winner's row
54
+ // instead; a failure that is not the race has no row to find and rethrows.
55
+ const raced = await store.getHubByURL(params.url);
56
+ if (raced == null) throw error;
57
+ return await adoptServerDID(store, raced, providedDID);
60
58
  }
61
- const id = crypto.randomUUID();
62
- await store.insertHub({
63
- id,
64
- label: params.label ?? null,
65
- url: params.url,
66
- server_did: providedDID
67
- });
68
59
  const created = await store.getHub(id);
69
60
  if (created == null) throw new Error('Failed to read hub after insert');
70
61
  return rowToHub(created);
71
62
  }
72
63
  /**
73
- * Idempotent register-or-pin entry-point. Applies the conflict matrix:
64
+ * Idempotent register-or-record entry-point. Applies the URL matrix:
74
65
  *
75
66
  * - URL not registered → insert a fresh row with `serverDID` (or null) and
76
67
  * return it.
77
- * - existing row, `server_did === null`, provided null → no-op.
78
- * - existing row, `server_did === null`, provided DID-A upgrade pin to
79
- * DID-A.
80
- * - existing row, `server_did === DID-A`, provided null or matching DID-A
81
- * no-op (no silent downgrade).
82
- * - existing row, `server_did === DID-A`, provided DID-B → throws
83
- * `HubServerDIDConflictError`. Caller must explicit `updateHub` to change
84
- * a pinned DID.
68
+ * - existing row, provided null → no-op, whatever the row records.
69
+ * - existing row, provided DID-A matching the record → no-op.
70
+ * - existing row, provided DID-A differing from the record → record DID-A. The
71
+ * recorded DID is a cache of the hub's current identity, so the newest claim
72
+ * wins and a wrong one self-corrects from `/info` on the next connect.
85
73
  *
86
74
  * Unlike `createHub`, `upsertHub` takes no `label` — it is for atomic
87
- * register+bind paths where the inviter only ships connectivity + pin.
75
+ * register+bind paths where the inviter only ships connectivity + hub identity.
88
76
  */ export async function upsertHub(params) {
89
77
  return await createHub({
90
78
  stores: params.stores,
91
79
  url: params.url,
92
- serverDID: params.serverDID
80
+ serverDID: params.serverDID,
81
+ runtime: params.runtime
93
82
  });
94
83
  }
95
84
  export async function updateHub(params) {
@@ -164,35 +153,3 @@ export async function listGroupsByHub(params) {
164
153
  const store = await getP2PStore(params.stores);
165
154
  return await store.listGroupIDsByHubID(params.hubID);
166
155
  }
167
- /**
168
- * TOFU capture path. Looks up the local hub row by URL and applies the
169
- * conflict matrix:
170
- * - URL not registered → no-op, returns `false`.
171
- * - existing `server_did` is null → upgrade to `serverDID`, returns `true`.
172
- * - existing `server_did` matches `serverDID` → no-op, returns `false`.
173
- * - existing `server_did` differs from `serverDID` → throws
174
- * `HubServerDIDConflictError`. Pinned-mismatch enforcement (hard-fail
175
- * before any message is delivered) lives in the transport wrapper and
176
- * surfaces `HubServerDIDMismatchError` to callers.
177
- */ export async function captureServerDID(params) {
178
- const store = await getP2PStore(params.stores);
179
- const existing = await store.getHubByURL(params.hubURL);
180
- if (existing == null) {
181
- return false;
182
- }
183
- if (existing.server_did == null) {
184
- await store.updateHub(existing.id, {
185
- server_did: params.serverDID
186
- });
187
- return true;
188
- }
189
- if (existing.server_did === params.serverDID) {
190
- return false;
191
- }
192
- throw new HubServerDIDConflictError({
193
- hubID: existing.id,
194
- url: params.hubURL,
195
- existingServerDID: existing.server_did,
196
- providedServerDID: params.serverDID
197
- });
198
- }
@@ -0,0 +1,35 @@
1
+ import type { MailboxHub } from '@kumiai/hub-tunnel';
2
+ export type PeerScopedHubViewParams = {
3
+ /** The device-wide hub (from `createHubLike`) whose drain is shared. */
4
+ hub: MailboxHub;
5
+ /** Only frames the hub authenticated as sent by this DID are surfaced. */
6
+ peerDID: string;
7
+ };
8
+ /**
9
+ * A view over one device's hub that surfaces only the frames one peer sent.
10
+ *
11
+ * Every tunnel responder on a device receives on the same topic — this device's
12
+ * responder topic for the epoch, shared by its listeners for every co-member —
13
+ * so the shared drain hands each of them every peer's frames. Two hazards
14
+ * follow.
15
+ *
16
+ * **The filter has to run before the frames are decrypted.** MLS consumes a
17
+ * per-sender ratchet generation on open, so a responder that opens a frame
18
+ * addressed to a different peer has DESTROYED it: the responder it belonged to
19
+ * can never open it, and that peer's request vanishes with no error raised
20
+ * anywhere. Filtering already-decrypted frames leaves the defect untouched.
21
+ * Build this view over the device hub and hand the VIEW to the encrypting
22
+ * transport — never the other way around.
23
+ *
24
+ * `senderDID` is the hub's attestation, taken from the signed publisher, not an
25
+ * MLS-authenticated identity. That is enough: the filter routes, MLS still
26
+ * authorizes, and a hostile hub could only hide frames it can already drop.
27
+ *
28
+ * **`unsubscribe` is dropped.** The responder topic belongs to the (group,
29
+ * epoch), not to a session: every listener on the device shares it, one per
30
+ * co-member, and each respawns independently — so a departing transport must not
31
+ * take it away from them. Releasing it belongs to whoever owns the group's peer.
32
+ * `subscribe` is
33
+ * therefore issued once per topic and held for the life of the view.
34
+ */
35
+ export declare function createPeerScopedHubView({ hub, peerDID }: PeerScopedHubViewParams): MailboxHub;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A view over one device's hub that surfaces only the frames one peer sent.
3
+ *
4
+ * Every tunnel responder on a device receives on the same topic — this device's
5
+ * responder topic for the epoch, shared by its listeners for every co-member —
6
+ * so the shared drain hands each of them every peer's frames. Two hazards
7
+ * follow.
8
+ *
9
+ * **The filter has to run before the frames are decrypted.** MLS consumes a
10
+ * per-sender ratchet generation on open, so a responder that opens a frame
11
+ * addressed to a different peer has DESTROYED it: the responder it belonged to
12
+ * can never open it, and that peer's request vanishes with no error raised
13
+ * anywhere. Filtering already-decrypted frames leaves the defect untouched.
14
+ * Build this view over the device hub and hand the VIEW to the encrypting
15
+ * transport — never the other way around.
16
+ *
17
+ * `senderDID` is the hub's attestation, taken from the signed publisher, not an
18
+ * MLS-authenticated identity. That is enough: the filter routes, MLS still
19
+ * authorizes, and a hostile hub could only hide frames it can already drop.
20
+ *
21
+ * **`unsubscribe` is dropped.** The responder topic belongs to the (group,
22
+ * epoch), not to a session: every listener on the device shares it, one per
23
+ * co-member, and each respawns independently — so a departing transport must not
24
+ * take it away from them. Releasing it belongs to whoever owns the group's peer.
25
+ * `subscribe` is
26
+ * therefore issued once per topic and held for the life of the view.
27
+ */ export function createPeerScopedHubView({ hub, peerDID }) {
28
+ const armed = new Set();
29
+ const view = {
30
+ publish: (params)=>hub.publish(params),
31
+ subscribe: async (subscriberDID, topicID, options)=>{
32
+ if (armed.has(topicID)) {
33
+ return;
34
+ }
35
+ armed.add(topicID);
36
+ try {
37
+ await hub.subscribe(subscriberDID, topicID, options);
38
+ } catch (error) {
39
+ armed.delete(topicID);
40
+ throw error;
41
+ }
42
+ },
43
+ unsubscribe: ()=>{},
44
+ receive: (subscriberDID, options)=>{
45
+ // Forwarded, not dropped: the transport asks for its own `receiveTopicID`,
46
+ // and a responder handed the frames of the DIALING role on this same peer
47
+ // opens them — consuming the ratchet generation the dialer's own session
48
+ // was waiting on. Sender scope alone cannot tell those two apart.
49
+ const inner = hub.receive(subscriberDID, options);
50
+ const innerIterator = inner[Symbol.asyncIterator]();
51
+ const iterator = {
52
+ next: async ()=>{
53
+ while(true){
54
+ const result = await innerIterator.next();
55
+ if (result.done === true) {
56
+ return {
57
+ value: undefined,
58
+ done: true
59
+ };
60
+ }
61
+ if (result.value.senderDID === peerDID) {
62
+ return result;
63
+ }
64
+ }
65
+ },
66
+ return: ()=>{
67
+ void innerIterator.return?.();
68
+ return Promise.resolve({
69
+ value: undefined,
70
+ done: true
71
+ });
72
+ }
73
+ };
74
+ return {
75
+ [Symbol.asyncIterator]: ()=>iterator,
76
+ return: ()=>{
77
+ inner.return?.();
78
+ },
79
+ // Forwarded, never synthesized: a frame this view filtered out belongs to
80
+ // another peer's responder, and acking it here would tell the hub it was
81
+ // handled by a subscriber that never saw it.
82
+ ...inner.ack != null ? {
83
+ ack: (sequenceID)=>inner.ack?.(sequenceID)
84
+ } : {}
85
+ };
86
+ },
87
+ ...hub.events != null ? {
88
+ events: hub.events
89
+ } : {}
90
+ };
91
+ return view;
92
+ }
@@ -0,0 +1,41 @@
1
+ import type { MailboxHub } from '@kumiai/hub-tunnel';
2
+ import type { DeviceHub } from './hub-like.js';
3
+ export type SenderScopedHubViewParams = {
4
+ /** The device-wide hub (from `createHubLike`), whose drain every lane shares. */
5
+ hub: DeviceHub;
6
+ /** Only frames the hub authenticated as sent by this DID reach the consumer. */
7
+ peerDID: string;
8
+ /**
9
+ * How long a publish on this session may wait for a hub connection before it
10
+ * is failed. Omitted leaves the device hub's own behaviour, which is to WAIT.
11
+ *
12
+ * That wait is right for the lane it was built for: a commit authored while
13
+ * the hub is away should land when it returns, and the invite lane is built on
14
+ * it. It is wrong here. A directed session is opened because someone pressed
15
+ * something, and the device hub's `#ready` awaits one reconnect gate after
16
+ * another for as long as the hub stays down — so with no hub, `syncPeer`
17
+ * returned neither a result nor an error, and a screen awaiting it held its
18
+ * spinner indefinitely.
19
+ */
20
+ publishTimeoutMs?: number;
21
+ };
22
+ /**
23
+ * A view over one device's hub whose drain carries one peer's frames only.
24
+ *
25
+ * A device dialing two peers at once receives both answers on the same topic —
26
+ * its own dialer topic for the (group, epoch) — so an unfiltered drain hands
27
+ * each session the other's frames. Opening one consumes an MLS ratchet
28
+ * generation and DESTROYS it for the session it belonged to, which starves that
29
+ * session to its idle timeout with nothing raised anywhere.
30
+ *
31
+ * The filtering is asked of the hub rather than done here: a sink parks a
32
+ * single waiter, so a view that filtered by calling
33
+ * `next()` on the consumer's behalf would take that slot and starve the session
34
+ * it was meant to protect — measurably, even when it drops nothing at all. That
35
+ * is why this is not {@link createPeerScopedHubView}, which a RESPONDER uses:
36
+ * a responder's drain is long-lived and shared across its spawns, and it owns
37
+ * the topic subscription for its whole life. A caller's is neither, so
38
+ * `subscribe` and `unsubscribe` pass straight through to the session's own
39
+ * lifecycle.
40
+ */
41
+ export declare function createSenderScopedHubView({ hub, peerDID, publishTimeoutMs, }: SenderScopedHubViewParams): MailboxHub;