@freqhole/playlistz 0.0.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 (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,1073 @@
1
+ // p2p sharing service for playlistz.
2
+ //
3
+ // covers the phase 5 surface:
4
+ // - endpoint settings (name, avatar, public/knock mode)
5
+ // - share link generation + the open-share-link flow
6
+ // - peer reconnect on boot (registerAndReconnectPeers pattern)
7
+ // - knock protocol requester + responder on the playlistz ALPN
8
+ //
9
+ // the responder also dispatches blob_request messages to the blob
10
+ // transfer service (phase 6) so a single stream handler covers the
11
+ // whole freqhole-playlistz/1 protocol.
12
+
13
+ import {
14
+ PLAYLISTZ_ALPN,
15
+ encodeShareToken,
16
+ decodeShareToken,
17
+ shareFragment,
18
+ sendMessage,
19
+ readMessage,
20
+ addPeer as addPeerToDoc,
21
+ type Message,
22
+ type BiStreamLike,
23
+ type SharePayloadV1,
24
+ } from "@freqhole/api-client/playlistz";
25
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
26
+ import { getIrohAdapter, findPlaylistDoc, flushDoc, authorizePeerForDoc } from "./automergeRepo.js";
27
+ import {
28
+ startP2P,
29
+ getIdentity,
30
+ getNode,
31
+ waitForNode,
32
+ onLeadershipChange,
33
+ hasExistingIdentity,
34
+ } from "./p2pService.js";
35
+ import {
36
+ addDocIndexEntry,
37
+ getDocIndexEntry,
38
+ getAllDocIndexEntries,
39
+ upsertKnock,
40
+ getAllKnocks,
41
+ upsertAccessGrant,
42
+ getAccessGrant,
43
+ } from "./docIndexService.js";
44
+ import { loadSetting, saveSetting } from "./indexedDBService.js";
45
+ import type { KnockRecord } from "./indexedDBService.js";
46
+ import { serveBlobRequest } from "./blobTransferService.js";
47
+ import { log } from "../utils/log.js";
48
+
49
+ // --- endpoint settings ---
50
+
51
+ export interface ShareSettings {
52
+ name: string;
53
+ mode: "public" | "knock";
54
+ avatarDataUrl?: string;
55
+ }
56
+
57
+ const SETTINGS_KEY = "p2p:endpoint";
58
+
59
+ export async function getShareSettings(): Promise<ShareSettings> {
60
+ const stored = await loadSetting<ShareSettings>(SETTINGS_KEY);
61
+ return stored ?? { name: "", mode: "knock" };
62
+ }
63
+
64
+ export async function saveShareSettings(
65
+ settings: ShareSettings
66
+ ): Promise<void> {
67
+ await saveSetting(SETTINGS_KEY, settings);
68
+ // fire-and-forget: tell connected peers about our updated identity
69
+ void notifyPeersOfIdentityUpdate(settings);
70
+ }
71
+
72
+ /**
73
+ * open a stream to every currently-connected peer and send our current
74
+ * name + avatar so they can update their docIndex entries without waiting
75
+ * for the next explicit hello exchange.
76
+ */
77
+ async function notifyPeersOfIdentityUpdate(
78
+ settings: ShareSettings
79
+ ): Promise<void> {
80
+ if (!protocolHandlerRegistered) return;
81
+ let adapter: ReturnType<typeof getIrohAdapter>;
82
+ try {
83
+ adapter = getIrohAdapter();
84
+ } catch {
85
+ return;
86
+ }
87
+ const entries = await getAllDocIndexEntries().catch(() => [] as Awaited<ReturnType<typeof getAllDocIndexEntries>>);
88
+ const seen = new Set<string>();
89
+ const myNodeId = getIdentity()?.node_id ?? "";
90
+ for (const entry of entries) {
91
+ const nodeId = entry.remoteNodeId;
92
+ if (!nodeId || nodeId === myNodeId || seen.has(nodeId)) continue;
93
+ seen.add(nodeId);
94
+ if (!adapter.isConnected(nodeId)) continue;
95
+ void (async () => {
96
+ try {
97
+ const stream = await openPlaylistzStream(nodeId);
98
+ try {
99
+ await sendMessage(stream, {
100
+ v: 1,
101
+ type: "identity_update",
102
+ ...(settings.name ? { name: settings.name } : {}),
103
+ ...(settings.avatarDataUrl
104
+ ? { avatarDataUrl: settings.avatarDataUrl }
105
+ : {}),
106
+ });
107
+ } finally {
108
+ stream.close();
109
+ }
110
+ } catch {
111
+ // peer unreachable - they'll get fresh data on next hello
112
+ }
113
+ })();
114
+ }
115
+ }
116
+
117
+ // --- p2p bootstrap for sharing ---
118
+
119
+ let protocolHandlerRegistered = false;
120
+ let reconnectDone = false;
121
+ let leadershipWatched = false;
122
+ // interval id for the periodic reconnect timer (cleared on reset)
123
+ let reconnectIntervalId: ReturnType<typeof setInterval> | null = null;
124
+
125
+ // listeners notified when the knock inbox changes (new knock arrived)
126
+ const knockListeners = new Set<() => void>();
127
+
128
+ export function onKnocksChanged(cb: () => void): () => void {
129
+ knockListeners.add(cb);
130
+ return () => {
131
+ knockListeners.delete(cb);
132
+ };
133
+ }
134
+
135
+ function notifyKnocksChanged(): void {
136
+ for (const cb of knockListeners) {
137
+ try {
138
+ cb();
139
+ } catch {
140
+ // ignore listener errors
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * start p2p and wire up the playlistz protocol responder + peer reconnect.
147
+ * idempotent. safe to call from UI event handlers.
148
+ */
149
+ export async function ensureSharingReady(): Promise<void> {
150
+ await startP2P();
151
+
152
+ if (!protocolHandlerRegistered) {
153
+ protocolHandlerRegistered = true;
154
+ const adapter = getIrohAdapter();
155
+ adapter.registerAlpnHandler(PLAYLISTZ_ALPN, (stream) => {
156
+ void handlePlaylistzStream(stream);
157
+ });
158
+ }
159
+
160
+ // reconnect to peers recorded in docs once we hold the node
161
+ if (!leadershipWatched) {
162
+ leadershipWatched = true;
163
+ onLeadershipChange((leader) => {
164
+ if (leader && !reconnectDone) {
165
+ reconnectDone = true;
166
+ void reconnectKnownPeers();
167
+ // periodic reconnect: re-dial known peers every 90s so automerge
168
+ // can sync changes that arrived while the connection was down
169
+ if (!reconnectIntervalId) {
170
+ reconnectIntervalId = setInterval(() => void reconnectKnownPeers(), 90_000);
171
+ }
172
+ }
173
+ });
174
+ }
175
+
176
+ // startP2P resolves before the midden node finishes booting - wait so
177
+ // callers (buildShareLink, openShareLink) can dial immediately. resolves
178
+ // null fast in non-leader tabs, where the node lives elsewhere.
179
+ await waitForNode();
180
+ }
181
+
182
+ /**
183
+ * resume p2p on app boot, but only if the user has already enabled it
184
+ * (an identity exists). first-time p2p start stays an explicit user action
185
+ * in the share panel.
186
+ */
187
+ export async function resumeSharingIfEnabled(): Promise<void> {
188
+ if (await hasExistingIdentity()) {
189
+ await ensureSharingReady();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * do a quick hello exchange with a known peer and refresh their name +
195
+ * avatar in docIndex entries and access grant record. silently ignores
196
+ * errors (peer may be offline).
197
+ */
198
+ async function refreshPeerIdentity(nodeId: string): Promise<void> {
199
+ const identity = getIdentity();
200
+ const settings = await getShareSettings().catch(
201
+ () => ({ name: "", mode: "knock" as const })
202
+ );
203
+ let peerName: string | undefined;
204
+ let peerAvatarDataUrl: string | undefined;
205
+ try {
206
+ const stream = await openPlaylistzStream(nodeId);
207
+ try {
208
+ await sendMessage(stream, {
209
+ v: 1,
210
+ type: "hello",
211
+ nodeId: identity?.node_id ?? "",
212
+ ...(settings.name ? { name: settings.name } : {}),
213
+ });
214
+ const reply = await readMessage(stream);
215
+ if (reply?.type === "hello_ok") {
216
+ peerName = reply.name;
217
+ peerAvatarDataUrl = reply.avatarDataUrl;
218
+ }
219
+ } finally {
220
+ stream.close();
221
+ }
222
+ } catch {
223
+ return; // peer offline or unreachable
224
+ }
225
+
226
+ if (!peerName && !peerAvatarDataUrl) return;
227
+
228
+ // update all docIndex entries that reference this peer
229
+ const entries = await getAllDocIndexEntries().catch(
230
+ () => [] as Awaited<ReturnType<typeof getAllDocIndexEntries>>
231
+ );
232
+ for (const entry of entries) {
233
+ if (entry.remoteNodeId !== nodeId) continue;
234
+ await addDocIndexEntry({
235
+ ...entry,
236
+ ...(peerName ? { remoteName: peerName } : {}),
237
+ ...(peerAvatarDataUrl ? { remoteAvatarDataUrl: peerAvatarDataUrl } : {}),
238
+ }).catch(() => {});
239
+ }
240
+
241
+ // update access grant if we have one for this peer
242
+ const grant = await getAccessGrant(nodeId).catch(() => undefined);
243
+ if (grant) {
244
+ await upsertAccessGrant({
245
+ ...grant,
246
+ ...(peerName ? { name: peerName } : {}),
247
+ ...(peerAvatarDataUrl ? { avatarDataUrl: peerAvatarDataUrl } : {}),
248
+ }).catch(() => {});
249
+ }
250
+ }
251
+
252
+ /**
253
+ * connect to every peer recorded in the peers map of any indexed doc.
254
+ * also warms the repo's docPeerCache so sharePolicy can announce docs.
255
+ * pre-seeds the cache from docIndex entries before doc handles resolve
256
+ * to close the timing window where a peer reconnects before the cache
257
+ * is populated from the doc.
258
+ */
259
+ export async function reconnectKnownPeers(): Promise<void> {
260
+ const identity = getIdentity();
261
+ const myNodeId = identity?.node_id ?? "";
262
+ const adapter = getIrohAdapter();
263
+ const entries = await getAllDocIndexEntries();
264
+ const seen = new Set<string>();
265
+
266
+ // fast pass: pre-authorize known remote peers from the docIndex before
267
+ // waiting on doc handles. this prevents sharePolicy from rejecting a
268
+ // reconnecting peer during the async doc-load window.
269
+ for (const entry of entries) {
270
+ if (entry.remoteNodeId && entry.remoteNodeId !== myNodeId) {
271
+ authorizePeerForDoc(entry.docId as AutomergeUrl, entry.remoteNodeId);
272
+ }
273
+ }
274
+
275
+ for (const entry of entries) {
276
+ try {
277
+ const handle = await findPlaylistDoc(entry.docId as AutomergeUrl);
278
+ const doc = handle.doc();
279
+ if (!doc) continue;
280
+ for (const nodeId of Object.keys(doc.peers ?? {})) {
281
+ if (nodeId && nodeId !== myNodeId && !seen.has(nodeId)) {
282
+ seen.add(nodeId);
283
+ void adapter.addPeer(nodeId).then(async () => {
284
+ // refresh the peer's identity in docIndex + grant after connecting
285
+ void refreshPeerIdentity(nodeId);
286
+ }).catch((err) => {
287
+ log.warn("p2p.reconnect", "reconnect to peer failed:", nodeId.slice(0, 16), err);
288
+ });
289
+ }
290
+ }
291
+ } catch {
292
+ // doc unavailable locally - skip
293
+ }
294
+ }
295
+ }
296
+
297
+ // --- share links ---
298
+
299
+ // discriminated result of opening a share link.
300
+ // "synced" - doc is now local (direct access or already present)
301
+ // "knock_required" - owner is in knock mode; call knockForDocAccess to proceed
302
+ export type OpenShareLinkResult =
303
+ | { status: "synced"; docId: string }
304
+ | {
305
+ status: "knock_required";
306
+ ownerNodeId: string;
307
+ ownerName?: string;
308
+ docId: string;
309
+ title?: string;
310
+ };
311
+
312
+ /**
313
+ * build a share link for a playlist doc. requires a running node (the
314
+ * link embeds our node id so the recipient can dial us). embeds the
315
+ * current sharing mode so recipients know if a knock is required.
316
+ */
317
+ export async function buildShareLink(
318
+ docId: string,
319
+ title?: string
320
+ ): Promise<{ token: string; url: string; fragment: string }> {
321
+ await ensureSharingReady();
322
+ const identity = getIdentity();
323
+ if (!identity?.node_id) {
324
+ throw new Error(
325
+ "p2p node is not running - cannot create a share link without a node id"
326
+ );
327
+ }
328
+ const settings = await getShareSettings();
329
+ const payload: SharePayloadV1 = {
330
+ v: 1,
331
+ n: identity.node_id,
332
+ d: docId,
333
+ ...(title ? { t: title } : {}),
334
+ ...(settings.mode === "knock" ? { m: "knock" } : {}),
335
+ };
336
+ const token = encodeShareToken(payload);
337
+ const fragment = shareFragment(payload);
338
+ const base = `${window.location.origin}${window.location.pathname}`;
339
+ return { token, url: `${base}${fragment}`, fragment };
340
+ }
341
+
342
+ /**
343
+ * perform the actual automerge doc sync for a share payload.
344
+ * dials the peer, finds the doc, records peers in the doc, and indexes it.
345
+ * does a quick hello exchange to capture the peer's name and avatar.
346
+ */
347
+ async function syncSharedDoc(
348
+ payload: SharePayloadV1
349
+ ): Promise<{ status: "synced"; docId: string }> {
350
+ const identity = getIdentity();
351
+ const adapter = getIrohAdapter();
352
+ const mySettings = await getShareSettings();
353
+
354
+ // pre-authorize the sharing peer so sharePolicy trusts them before the doc
355
+ // arrives (the doc can't arrive if the policy already rejects the peer)
356
+ authorizePeerForDoc(payload.d as AutomergeUrl, payload.n);
357
+
358
+ // fetch name + avatar from the sharer via a hello exchange.
359
+ // best-effort: failures are silently ignored so the main sync still proceeds.
360
+ let peerName: string | undefined;
361
+ let peerAvatarDataUrl: string | undefined;
362
+ try {
363
+ const stream = await openPlaylistzStream(payload.n);
364
+ try {
365
+ await sendMessage(stream, {
366
+ v: 1,
367
+ type: "hello",
368
+ nodeId: identity?.node_id ?? "",
369
+ ...(mySettings.name ? { name: mySettings.name } : {}),
370
+ });
371
+ const reply = await readMessage(stream);
372
+ if (reply?.type === "hello_ok") {
373
+ peerName = reply.name;
374
+ peerAvatarDataUrl = reply.avatarDataUrl;
375
+ }
376
+ } finally {
377
+ stream.close();
378
+ }
379
+ } catch {
380
+ // peer may be offline or reject hello - not fatal
381
+ }
382
+
383
+ const alreadyLocal = await getDocIndexEntry(payload.d).catch(() => null);
384
+ if (!alreadyLocal) {
385
+ for (let attempt = 0; ; attempt++) {
386
+ try {
387
+ await adapter.addPeer(payload.n);
388
+ break;
389
+ } catch (err) {
390
+ if (attempt >= 5) {
391
+ log.warn("p2p.connect", "could not connect to sharing peer:", err);
392
+ break;
393
+ }
394
+ await new Promise((resolve) => setTimeout(resolve, 5000));
395
+ }
396
+ }
397
+ }
398
+
399
+ const handle = await findPlaylistDoc(payload.d as AutomergeUrl);
400
+ const doc = handle.doc();
401
+
402
+ const myNodeId = identity?.node_id;
403
+ if (doc) {
404
+ const peers = doc.peers ?? {};
405
+ const missingSelf = !!myNodeId && !(myNodeId in peers);
406
+ const missingSharer = !(payload.n in peers);
407
+ if (missingSelf || missingSharer) {
408
+ handle.change((d) => {
409
+ if (missingSelf && myNodeId) addPeerToDoc(d, myNodeId);
410
+ if (missingSharer) addPeerToDoc(d, payload.n);
411
+ });
412
+ await flushDoc(payload.d as AutomergeUrl);
413
+ }
414
+ }
415
+
416
+ const existing = await getDocIndexEntry(payload.d);
417
+ if (!existing) {
418
+ await addDocIndexEntry({
419
+ docId: payload.d,
420
+ title: doc?.title || payload.t || "shared playlist",
421
+ addedAt: Date.now(),
422
+ source: "shared",
423
+ remoteNodeId: payload.n,
424
+ remoteName: peerName,
425
+ remoteAvatarDataUrl: peerAvatarDataUrl,
426
+ });
427
+ } else if (peerName || peerAvatarDataUrl) {
428
+ // update name/avatar if we got fresher data
429
+ await addDocIndexEntry({
430
+ ...existing,
431
+ ...(peerName ? { remoteName: peerName } : {}),
432
+ ...(peerAvatarDataUrl ? { remoteAvatarDataUrl: peerAvatarDataUrl } : {}),
433
+ });
434
+ }
435
+
436
+ return { status: "synced", docId: payload.d };
437
+ }
438
+
439
+ /**
440
+ * open a share link (or raw token).
441
+ * - if the link embeds `m: "knock"`, returns knock_required without syncing.
442
+ * call knockForDocAccess() once the user confirms, then the doc syncs.
443
+ * - otherwise syncs the doc immediately and returns { status: "synced" }.
444
+ */
445
+ export async function openShareLink(
446
+ input: string
447
+ ): Promise<OpenShareLinkResult> {
448
+ const payload = decodeShareToken(input);
449
+ if (!payload) {
450
+ throw new Error("invalid share link");
451
+ }
452
+
453
+ await ensureSharingReady();
454
+
455
+ // if already local, skip re-sync
456
+ const alreadyLocal = await getDocIndexEntry(payload.d).catch(() => null);
457
+ if (alreadyLocal) {
458
+ return { status: "synced", docId: payload.d };
459
+ }
460
+
461
+ // knock mode encoded in the link: gate sync behind a knock
462
+ if (payload.m === "knock") {
463
+ return {
464
+ status: "knock_required",
465
+ ownerNodeId: payload.n,
466
+ docId: payload.d,
467
+ title: payload.t,
468
+ };
469
+ }
470
+
471
+ return syncSharedDoc(payload);
472
+ }
473
+
474
+ /**
475
+ * check location.hash for a #share/ fragment. if present, open it and
476
+ * clear the fragment. returns an OpenShareLinkResult or null.
477
+ */
478
+ export async function handleShareFragment(): Promise<OpenShareLinkResult | null> {
479
+ const hash = window.location.hash;
480
+ if (!hash.startsWith("#share/")) return null;
481
+ try {
482
+ const result = await openShareLink(hash);
483
+ // clear the fragment so reloads don't re-trigger
484
+ history.replaceState(null, "", window.location.pathname);
485
+ return result;
486
+ } catch (err) {
487
+ log.error("share.fragment", "failed to open share link:", err);
488
+ history.replaceState(null, "", window.location.pathname);
489
+ throw err;
490
+ }
491
+ }
492
+
493
+ // --- knock requester ---
494
+
495
+ export interface PeerPlaylistListing {
496
+ nodeId: string;
497
+ name?: string;
498
+ avatarDataUrl?: string;
499
+ public: boolean;
500
+ items: { docId: string; title: string; songCount: number }[];
501
+ knockRequired: boolean;
502
+ }
503
+
504
+ async function openPlaylistzStream(nodeId: string): Promise<BiStreamLike> {
505
+ await ensureSharingReady();
506
+ const node = getNode();
507
+ if (!node) {
508
+ throw new Error("p2p node is not running in this tab");
509
+ }
510
+ return (await node.open_bi(
511
+ nodeId,
512
+ PLAYLISTZ_ALPN
513
+ )) as unknown as BiStreamLike;
514
+ }
515
+
516
+ /**
517
+ * query a peer for its playlist listing. sends hello + list_playlists.
518
+ * if the peer requires a knock, knockRequired is true and items is empty.
519
+ */
520
+ export async function queryPeerPlaylists(
521
+ nodeId: string
522
+ ): Promise<PeerPlaylistListing> {
523
+ const identity = getIdentity();
524
+ const settings = await getShareSettings();
525
+ const stream = await openPlaylistzStream(nodeId);
526
+ try {
527
+ await sendMessage(stream, {
528
+ v: 1,
529
+ type: "hello",
530
+ nodeId: identity?.node_id ?? "",
531
+ ...(settings.name ? { name: settings.name } : {}),
532
+ });
533
+ const helloReply = await readMessage(stream);
534
+ if (helloReply?.type !== "hello_ok") {
535
+ throw new Error("peer did not answer hello");
536
+ }
537
+
538
+ await sendMessage(stream, { v: 1, type: "list_playlists" });
539
+ const listReply = await readMessage(stream);
540
+
541
+ if (listReply?.type === "playlists") {
542
+ return {
543
+ nodeId,
544
+ name: helloReply.name,
545
+ avatarDataUrl: helloReply.avatarDataUrl,
546
+ public: helloReply.public,
547
+ items: listReply.items,
548
+ knockRequired: false,
549
+ };
550
+ }
551
+ if (listReply?.type === "error" && listReply.code === "knock_required") {
552
+ return {
553
+ nodeId,
554
+ name: helloReply.name,
555
+ avatarDataUrl: helloReply.avatarDataUrl,
556
+ public: helloReply.public,
557
+ items: [],
558
+ knockRequired: true,
559
+ };
560
+ }
561
+ throw new Error("unexpected reply to list_playlists");
562
+ } finally {
563
+ stream.close();
564
+ }
565
+ }
566
+
567
+ /**
568
+ * knock on a peer. returns the resulting status; when accepted, the
569
+ * granted doc ids are opened + indexed automatically.
570
+ */
571
+ export async function knockOnPeer(
572
+ nodeId: string,
573
+ message?: string
574
+ ): Promise<{ status: "pending" | "accepted" | "denied"; docIds: string[] }> {
575
+ const identity = getIdentity();
576
+ const settings = await getShareSettings();
577
+ const stream = await openPlaylistzStream(nodeId);
578
+ let reply: Message | null;
579
+ try {
580
+ await sendMessage(stream, {
581
+ v: 1,
582
+ type: "knock",
583
+ nodeId: identity?.node_id ?? "",
584
+ ...(settings.name ? { name: settings.name } : {}),
585
+ ...(message ? { message } : {}),
586
+ });
587
+ reply = await readMessage(stream);
588
+ } finally {
589
+ stream.close();
590
+ }
591
+
592
+ if (reply?.type !== "knock_status") {
593
+ throw new Error("peer did not answer knock");
594
+ }
595
+
596
+ // track the outbound knock for the UI
597
+ await upsertKnock({
598
+ id: `out:${nodeId}`,
599
+ nodeId,
600
+ direction: "outbound",
601
+ name: "",
602
+ message: message ?? "",
603
+ status: reply.status === "denied" ? "rejected" : reply.status,
604
+ createdAt: Date.now(),
605
+ ...(reply.status !== "pending" ? { processedAt: Date.now() } : {}),
606
+ });
607
+
608
+ const docIds = reply.grantedDocIds ?? [];
609
+ if (reply.status === "accepted" && docIds.length > 0) {
610
+ const adapter = getIrohAdapter();
611
+ await adapter.addPeer(nodeId).catch(() => {});
612
+ for (const docId of docIds) {
613
+ try {
614
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
615
+ const doc = handle.doc();
616
+ const myNodeId = identity?.node_id;
617
+ if (myNodeId && doc && !(myNodeId in (doc.peers ?? {}))) {
618
+ handle.change((d) => addPeerToDoc(d, myNodeId));
619
+ await flushDoc(docId as AutomergeUrl);
620
+ }
621
+ if (!(await getDocIndexEntry(docId))) {
622
+ await addDocIndexEntry({
623
+ docId,
624
+ title: doc?.title || "shared playlist",
625
+ addedAt: Date.now(),
626
+ source: "shared",
627
+ remoteNodeId: nodeId,
628
+ });
629
+ }
630
+ } catch (err) {
631
+ log.warn("p2p.knock", "failed to open granted doc:", docId, err);
632
+ }
633
+ }
634
+ }
635
+
636
+ return { status: reply.status, docIds };
637
+ }
638
+
639
+ /**
640
+ * send a doc_access knock to a specific peer for a specific playlist doc.
641
+ * used after openShareLink returns knock_required.
642
+ * when accepted, syncs the doc and indexes it automatically.
643
+ */
644
+ export async function knockForDocAccess(
645
+ ownerNodeId: string,
646
+ docId: string,
647
+ message: string,
648
+ titleHint?: string
649
+ ): Promise<{ status: "pending" | "accepted" | "denied" }> {
650
+ const identity = getIdentity();
651
+ const settings = await getShareSettings();
652
+ const stream = await openPlaylistzStream(ownerNodeId);
653
+ let reply: Message | null;
654
+ try {
655
+ await sendMessage(stream, {
656
+ v: 1,
657
+ type: "knock",
658
+ nodeId: identity?.node_id ?? "",
659
+ ...(settings.name ? { name: settings.name } : {}),
660
+ ...(message ? { message } : {}),
661
+ knockType: "doc_access",
662
+ docId,
663
+ });
664
+ reply = await readMessage(stream);
665
+ } finally {
666
+ stream.close();
667
+ }
668
+
669
+ if (reply?.type !== "knock_status") {
670
+ throw new Error("peer did not answer knock");
671
+ }
672
+
673
+ await upsertKnock({
674
+ id: `out:${ownerNodeId}:doc:${docId}`,
675
+ nodeId: ownerNodeId,
676
+ direction: "outbound",
677
+ name: "",
678
+ message,
679
+ status: reply.status === "denied" ? "rejected" : reply.status,
680
+ createdAt: Date.now(),
681
+ knockType: "doc_access",
682
+ requestedDocId: docId,
683
+ ...(reply.status !== "pending" ? { processedAt: Date.now() } : {}),
684
+ });
685
+
686
+ if (reply.status === "accepted") {
687
+ const granted = reply.grantedDocIds ?? [docId];
688
+ if (granted.includes(docId)) {
689
+ await syncSharedDoc({ v: 1, n: ownerNodeId, d: docId, ...(titleHint ? { t: titleHint } : {}) });
690
+ }
691
+ }
692
+
693
+ return { status: reply.status };
694
+ }
695
+
696
+ /**
697
+ * accept an inbound knock: persist the grant, record the peer in each
698
+ * granted doc, and dial the peer so sync starts immediately.
699
+ */
700
+ export async function acceptKnock(
701
+ knockId: string,
702
+ docIds: string[]
703
+ ): Promise<void> {
704
+ const knocks = await getAllKnocks();
705
+ const knock = knocks.find((k) => k.id === knockId);
706
+ if (!knock) throw new Error("knock not found");
707
+
708
+ // try to get the peer's avatar from any docIndex entry we already have
709
+ const allEntries = await getAllDocIndexEntries().catch(() => [] as Awaited<ReturnType<typeof getAllDocIndexEntries>>);
710
+ const peerEntry = allEntries.find((e) => e.remoteNodeId === knock.nodeId);
711
+
712
+ await upsertAccessGrant({
713
+ nodeId: knock.nodeId,
714
+ name: knock.name,
715
+ grantedAt: Date.now(),
716
+ docIds,
717
+ ...(peerEntry?.remoteAvatarDataUrl
718
+ ? { avatarDataUrl: peerEntry.remoteAvatarDataUrl }
719
+ : {}),
720
+ });
721
+ await upsertKnock({
722
+ ...knock,
723
+ status: "accepted",
724
+ processedAt: Date.now(),
725
+ });
726
+
727
+ for (const docId of docIds) {
728
+ try {
729
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
730
+ const doc = handle.doc();
731
+ if (doc && !(knock.nodeId in (doc.peers ?? {}))) {
732
+ handle.change((d) => addPeerToDoc(d, knock.nodeId));
733
+ await flushDoc(docId as AutomergeUrl);
734
+ }
735
+ } catch (err) {
736
+ log.warn("p2p.knock", "failed to record peer in doc:", docId, err);
737
+ }
738
+ }
739
+
740
+ const adapter = getIrohAdapter();
741
+ await adapter.addPeer(knock.nodeId).catch(() => {});
742
+
743
+ // fire-and-forget: notify the peer they've been accepted so they don't
744
+ // have to poll. if the peer is offline this fails silently.
745
+ const identity = getIdentity();
746
+ void (async () => {
747
+ try {
748
+ const stream = await openPlaylistzStream(knock.nodeId);
749
+ try {
750
+ await sendMessage(stream, {
751
+ v: 1,
752
+ type: "knock_notify",
753
+ status: "accepted",
754
+ docIds,
755
+ ownerNodeId: identity?.node_id ?? "",
756
+ });
757
+ } finally {
758
+ stream.close();
759
+ }
760
+ } catch {
761
+ // peer offline or unreachable - they'll get the status on their next knock
762
+ }
763
+ })();
764
+
765
+ notifyKnocksChanged();
766
+ }
767
+
768
+ /** deny an inbound knock. */
769
+ export async function denyKnock(knockId: string): Promise<void> {
770
+ const knocks = await getAllKnocks();
771
+ const knock = knocks.find((k) => k.id === knockId);
772
+ if (!knock) return;
773
+ await upsertKnock({
774
+ ...knock,
775
+ status: "rejected",
776
+ processedAt: Date.now(),
777
+ });
778
+ notifyKnocksChanged();
779
+ }
780
+
781
+ /** list inbound knocks for the inbox UI (newest first). */
782
+ export async function getInboundKnocks(): Promise<KnockRecord[]> {
783
+ const knocks = await getAllKnocks();
784
+ return knocks
785
+ .filter((k) => k.direction === "inbound")
786
+ .sort((a, b) => b.createdAt - a.createdAt);
787
+ }
788
+
789
+ /** list outbound knocks (sent by us) for the pending-access UI. */
790
+ export async function getOutboundKnocks(): Promise<KnockRecord[]> {
791
+ const knocks = await getAllKnocks();
792
+ return knocks
793
+ .filter((k) => k.direction === "outbound")
794
+ .sort((a, b) => b.createdAt - a.createdAt);
795
+ }
796
+
797
+ // --- protocol responder ---
798
+
799
+ async function buildPlaylistItems(): Promise<
800
+ { docId: string; title: string; songCount: number }[]
801
+ > {
802
+ const entries = await getAllDocIndexEntries();
803
+ const items: { docId: string; title: string; songCount: number }[] = [];
804
+ for (const entry of entries) {
805
+ try {
806
+ const handle = await findPlaylistDoc(entry.docId as AutomergeUrl);
807
+ const doc = handle.doc();
808
+ items.push({
809
+ docId: entry.docId,
810
+ title: doc?.title || entry.title,
811
+ songCount: doc ? Object.keys(doc.songs ?? {}).length : 0,
812
+ });
813
+ } catch {
814
+ items.push({ docId: entry.docId, title: entry.title, songCount: 0 });
815
+ }
816
+ }
817
+ return items;
818
+ }
819
+
820
+ /**
821
+ * handle one inbound stream on the playlistz ALPN. loops over messages
822
+ * until EOF. exported for tests.
823
+ */
824
+ export async function handlePlaylistzStream(
825
+ stream: BiStreamLike
826
+ ): Promise<void> {
827
+ const peerNodeId = stream.peer_node_id();
828
+ try {
829
+ for (;;) {
830
+ const msg = await readMessage(stream);
831
+ if (msg === null) break;
832
+ await handleProtocolMessage(stream, peerNodeId, msg);
833
+ }
834
+ } catch (err) {
835
+ log.warn("p2p.protocol", "protocol stream error:", err);
836
+ } finally {
837
+ try {
838
+ stream.close();
839
+ } catch {
840
+ // already closed
841
+ }
842
+ }
843
+ }
844
+
845
+ async function handleProtocolMessage(
846
+ stream: BiStreamLike,
847
+ peerNodeId: string,
848
+ msg: Message
849
+ ): Promise<void> {
850
+ const identity = getIdentity();
851
+ const settings = await getShareSettings();
852
+
853
+ switch (msg.type) {
854
+ case "hello": {
855
+ await sendMessage(stream, {
856
+ v: 1,
857
+ type: "hello_ok",
858
+ nodeId: identity?.node_id ?? "",
859
+ ...(settings.name ? { name: settings.name } : {}),
860
+ ...(settings.avatarDataUrl ? { avatarDataUrl: settings.avatarDataUrl } : {}),
861
+ public: settings.mode === "public",
862
+ });
863
+ break;
864
+ }
865
+
866
+ case "list_playlists": {
867
+ const grant = await getAccessGrant(peerNodeId);
868
+ if (settings.mode !== "public" && !grant) {
869
+ await sendMessage(stream, {
870
+ v: 1,
871
+ type: "error",
872
+ code: "knock_required",
873
+ message: "this node requires a knock before listing playlists",
874
+ });
875
+ break;
876
+ }
877
+ let items = await buildPlaylistItems();
878
+ // a grant may be scoped to specific docs
879
+ if (settings.mode !== "public" && grant?.docIds) {
880
+ const allowed = new Set(grant.docIds);
881
+ items = items.filter((i) => allowed.has(i.docId));
882
+ }
883
+ await sendMessage(stream, { v: 1, type: "playlists", items });
884
+ break;
885
+ }
886
+
887
+ case "knock": {
888
+ const isDocAccessKnock = msg.knockType === "doc_access" && !!msg.docId;
889
+ const existing = await getAccessGrant(msg.nodeId);
890
+
891
+ if (isDocAccessKnock && msg.docId) {
892
+ // doc_access knock: auto-accept only if the doc has collaborative editing
893
+ // enabled. in public mode any peer qualifies; in knock mode the peer must
894
+ // already have an accepted grant covering this doc.
895
+ let isCollaborative = false;
896
+ try {
897
+ const handle = await findPlaylistDoc(msg.docId as AutomergeUrl);
898
+ const doc = handle.doc() as Record<string, unknown> | undefined;
899
+ isCollaborative = !!(doc?.collaborative);
900
+ } catch { /* doc not available */ }
901
+
902
+ const peerQualifies =
903
+ settings.mode === "public" ||
904
+ (existing &&
905
+ (!existing.docIds || existing.docIds.includes(msg.docId)));
906
+
907
+ if (isCollaborative && peerQualifies) {
908
+ await sendMessage(stream, {
909
+ v: 1,
910
+ type: "knock_status",
911
+ status: "accepted",
912
+ grantedDocIds: [msg.docId],
913
+ });
914
+ break;
915
+ }
916
+ } else if (existing) {
917
+ // browse knock: check if any grant exists
918
+ await sendMessage(stream, {
919
+ v: 1,
920
+ type: "knock_status",
921
+ status: "accepted",
922
+ grantedDocIds: existing.docIds ?? [],
923
+ });
924
+ break;
925
+ }
926
+
927
+ // check for a prior knock of the same type from this node
928
+ const knocks = await getAllKnocks();
929
+ const prior = knocks.find(
930
+ (k) =>
931
+ k.direction === "inbound" &&
932
+ k.nodeId === msg.nodeId &&
933
+ (isDocAccessKnock
934
+ ? k.knockType === "doc_access" && k.requestedDocId === msg.docId
935
+ : k.knockType !== "doc_access")
936
+ );
937
+ if (prior?.status === "rejected") {
938
+ await sendMessage(stream, {
939
+ v: 1,
940
+ type: "knock_status",
941
+ status: "denied",
942
+ });
943
+ break;
944
+ }
945
+ if (!prior) {
946
+ await upsertKnock({
947
+ id: crypto.randomUUID(),
948
+ nodeId: msg.nodeId,
949
+ direction: "inbound",
950
+ name: msg.name ?? "",
951
+ message: msg.message ?? "",
952
+ status: "pending",
953
+ createdAt: Date.now(),
954
+ knockType: isDocAccessKnock ? "doc_access" : "browse",
955
+ ...(isDocAccessKnock && msg.docId ? { requestedDocId: msg.docId } : {}),
956
+ });
957
+ notifyKnocksChanged();
958
+ }
959
+ await sendMessage(stream, {
960
+ v: 1,
961
+ type: "knock_status",
962
+ status: "pending",
963
+ });
964
+ break;
965
+ }
966
+
967
+ case "blob_request": {
968
+ // only serve blobs to peers who have an accepted grant (or if public mode)
969
+ const blobGrant = await getAccessGrant(peerNodeId);
970
+ if (settings.mode !== "public" && !blobGrant) {
971
+ await sendMessage(stream, {
972
+ v: 1,
973
+ type: "error",
974
+ code: "knock_required",
975
+ message: "access denied: knock required before requesting blobs",
976
+ });
977
+ break;
978
+ }
979
+ await serveBlobRequest(stream, msg.sha256);
980
+ break;
981
+ }
982
+
983
+ case "knock_notify": {
984
+ // the peer owner has accepted our knock and is notifying us proactively.
985
+ // update our outbound knock record and sync the granted docs.
986
+ const myNodeId = getIdentity()?.node_id ?? "";
987
+ for (const docId of msg.docIds) {
988
+ try {
989
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
990
+ const doc = handle.doc();
991
+ if (myNodeId && doc && !(myNodeId in (doc.peers ?? {}))) {
992
+ handle.change((d) => addPeerToDoc(d, myNodeId));
993
+ await flushDoc(docId as AutomergeUrl);
994
+ }
995
+ if (!(await getDocIndexEntry(docId))) {
996
+ await addDocIndexEntry({
997
+ docId,
998
+ title: doc?.title || "shared playlist",
999
+ addedAt: Date.now(),
1000
+ source: "shared",
1001
+ remoteNodeId: msg.ownerNodeId,
1002
+ });
1003
+ }
1004
+ } catch (err) {
1005
+ log.warn("p2p.knock", "failed to sync granted doc from notify:", docId, err);
1006
+ }
1007
+ }
1008
+ // mark any matching outbound knock as accepted
1009
+ const allKnocks = await getAllKnocks();
1010
+ for (const k of allKnocks) {
1011
+ if (k.direction === "outbound" && k.nodeId === peerNodeId && k.status === "pending") {
1012
+ await upsertKnock({ ...k, status: "accepted", processedAt: Date.now() });
1013
+ }
1014
+ }
1015
+ notifyKnocksChanged();
1016
+ break;
1017
+ }
1018
+
1019
+ case "identity_update": {
1020
+ // peer changed their name or avatar - update all our docIndex entries
1021
+ // and access grant records that reference this peer
1022
+ const updates: Promise<void>[] = [];
1023
+ const entries = await getAllDocIndexEntries();
1024
+ for (const entry of entries) {
1025
+ if (entry.remoteNodeId !== peerNodeId) continue;
1026
+ const updated = {
1027
+ ...entry,
1028
+ ...(msg.name !== undefined ? { remoteName: msg.name } : {}),
1029
+ ...(msg.avatarDataUrl !== undefined
1030
+ ? { remoteAvatarDataUrl: msg.avatarDataUrl }
1031
+ : {}),
1032
+ };
1033
+ updates.push(addDocIndexEntry(updated));
1034
+ }
1035
+ // also update the access grant record if we have one for this peer
1036
+ const grant = await getAccessGrant(peerNodeId).catch(() => undefined);
1037
+ if (grant) {
1038
+ updates.push(
1039
+ upsertAccessGrant({
1040
+ ...grant,
1041
+ ...(msg.name !== undefined ? { name: msg.name } : {}),
1042
+ ...(msg.avatarDataUrl !== undefined
1043
+ ? { avatarDataUrl: msg.avatarDataUrl }
1044
+ : {}),
1045
+ })
1046
+ );
1047
+ }
1048
+ await Promise.allSettled(updates);
1049
+ break;
1050
+ }
1051
+
1052
+ default: {
1053
+ await sendMessage(stream, {
1054
+ v: 1,
1055
+ type: "error",
1056
+ code: "unexpected_message",
1057
+ message: `unexpected message type: ${msg.type}`,
1058
+ });
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ /** reset module state. for use in tests only. */
1064
+ export function _resetSharingForTests(): void {
1065
+ protocolHandlerRegistered = false;
1066
+ reconnectDone = false;
1067
+ leadershipWatched = false;
1068
+ if (reconnectIntervalId !== null) {
1069
+ clearInterval(reconnectIntervalId);
1070
+ reconnectIntervalId = null;
1071
+ }
1072
+ knockListeners.clear();
1073
+ }