@freqhole/api-client 0.1.30
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.
- package/auth-test.html +651 -0
- package/package.json +42 -0
- package/src/AdminClient.ts +154 -0
- package/src/CharnelLocalTransport.ts +598 -0
- package/src/CharnelTransport.ts +616 -0
- package/src/FreqholeClient.ts +261 -0
- package/src/WasmTransport.ts +1077 -0
- package/src/automerge/IrohNetworkAdapter.ts +727 -0
- package/src/automerge/index.ts +14 -0
- package/src/codegen/admin_commands.ts +61 -0
- package/src/codegen/routes.ts +213 -0
- package/src/codegen/schema.ts +6721 -0
- package/src/domains/admin.ts +97 -0
- package/src/domains/app.ts +98 -0
- package/src/domains/auth.ts +191 -0
- package/src/domains/favorites.types.ts +52 -0
- package/src/domains/jobs.ts +48 -0
- package/src/domains/music.ts +1935 -0
- package/src/domains/types.ts +19 -0
- package/src/domains/upload.ts +214 -0
- package/src/index.ts +48 -0
- package/src/permissions.ts +468 -0
- package/src/playlistz/convert.ts +267 -0
- package/src/playlistz/index.ts +6 -0
- package/src/playlistz/mutations.ts +181 -0
- package/src/playlistz/protocol.ts +222 -0
- package/src/playlistz/schema.ts +138 -0
- package/src/playlistz/shareLink.ts +76 -0
- package/src/storage/blobs.ts +272 -0
- package/src/storage/identity.ts +190 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/webLocks.ts +92 -0
- package/src/test/automerge.ts +796 -0
- package/src/test/coverage.ts +105 -0
- package/src/test/fixtures.ts +460 -0
- package/src/test/integration.ts +320 -0
- package/src/test/playlistz.ts +631 -0
- package/src/test/stateful.ts +561 -0
- package/src/test/storage.ts +443 -0
- package/src/test.ts +75 -0
- package/src/transport.ts +391 -0
- package/src/utils.ts +79 -0
- package/src/webauthn.ts +228 -0
- package/tsconfig.json +22 -0
- package/vite.config.js +9 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// converters between external record shapes and PlaylistDoc.
|
|
2
|
+
// all converters are total: missing optional source fields become schema defaults.
|
|
3
|
+
// never throw on extra fields.
|
|
4
|
+
import type { Playlist as FreqholePlaylist, Song as FreqholeSong } from "../codegen/schema.js";
|
|
5
|
+
import {
|
|
6
|
+
emptyPlaylistDoc,
|
|
7
|
+
type ImageRef,
|
|
8
|
+
type PlaylistDoc,
|
|
9
|
+
type SongEntry,
|
|
10
|
+
type EntityUrl,
|
|
11
|
+
} from "./schema.js";
|
|
12
|
+
|
|
13
|
+
// ---- shared helpers ----
|
|
14
|
+
|
|
15
|
+
function tsToIso(ts: number): string {
|
|
16
|
+
return new Date(ts).toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mapEntityUrls(
|
|
20
|
+
urls?: Array<{ id?: string | null; name?: string | null; url: string }> | null,
|
|
21
|
+
): EntityUrl[] {
|
|
22
|
+
if (!urls) return [];
|
|
23
|
+
return urls.map((u) => ({
|
|
24
|
+
url: u.url,
|
|
25
|
+
...(u.id != null ? { id: u.id } : {}),
|
|
26
|
+
...(u.name != null ? { name: u.name } : {}),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---- legacy playlistz types ----
|
|
31
|
+
// (removed: playlistz has no deployed users, so there is no legacy idb data
|
|
32
|
+
// to convert - new playlists are created directly as docs)
|
|
33
|
+
|
|
34
|
+
// ---- spume idb types (inline - do not import from spume source) ----
|
|
35
|
+
// field shapes from tomb/client/spume/src/music/services/storage/types.ts
|
|
36
|
+
|
|
37
|
+
interface SpumeImageMetadata {
|
|
38
|
+
local_blob_id?: string;
|
|
39
|
+
remote_blob_id?: string;
|
|
40
|
+
is_primary: boolean;
|
|
41
|
+
blob_type: "thumbnail" | "waveform" | "original" | "preview";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SpumePlaylist {
|
|
45
|
+
playlist_id: string;
|
|
46
|
+
title: string;
|
|
47
|
+
description?: string | null;
|
|
48
|
+
is_public?: boolean;
|
|
49
|
+
images?: SpumeImageMetadata[];
|
|
50
|
+
urls?: Array<{ id?: string; name?: string; url: string }>;
|
|
51
|
+
created_at?: number;
|
|
52
|
+
updated_at?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SpumeSong {
|
|
56
|
+
id: string;
|
|
57
|
+
sha256: string;
|
|
58
|
+
title: string;
|
|
59
|
+
artist_name?: string;
|
|
60
|
+
album_title?: string;
|
|
61
|
+
duration_seconds?: number;
|
|
62
|
+
mime_type?: string | null;
|
|
63
|
+
file_size?: number | null;
|
|
64
|
+
blake3?: string | null;
|
|
65
|
+
lyrics?: string | null;
|
|
66
|
+
images?: SpumeImageMetadata[];
|
|
67
|
+
urls?: Array<{ id?: string; name?: string; url: string }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// map SpumeImageMetadata -> ImageRef, skipping entries with no blob id
|
|
71
|
+
function mapSpumeImages(images?: SpumeImageMetadata[]): ImageRef[] {
|
|
72
|
+
if (!images) return [];
|
|
73
|
+
const result: ImageRef[] = [];
|
|
74
|
+
for (const img of images) {
|
|
75
|
+
const blobId = img.local_blob_id ?? img.remote_blob_id;
|
|
76
|
+
if (!blobId) continue;
|
|
77
|
+
result.push({
|
|
78
|
+
blobId,
|
|
79
|
+
isPrimary: img.is_primary,
|
|
80
|
+
blobType: img.blob_type,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// map freqhole wire image (is_primary as 0/1 number) -> ImageRef
|
|
87
|
+
function mapFreqholeImages(
|
|
88
|
+
images?: Array<{
|
|
89
|
+
blob_id: string;
|
|
90
|
+
is_primary: number;
|
|
91
|
+
blob_type: "original" | "thumbnail" | "waveform" | "preview";
|
|
92
|
+
}> | null,
|
|
93
|
+
): ImageRef[] {
|
|
94
|
+
if (!images) return [];
|
|
95
|
+
return images.map((img) => ({
|
|
96
|
+
blobId: img.blob_id,
|
|
97
|
+
isPrimary: img.is_primary !== 0,
|
|
98
|
+
blobType: img.blob_type,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- converters ----
|
|
103
|
+
|
|
104
|
+
// convert a freqhole wire Playlist + Song[] to a PlaylistDoc.
|
|
105
|
+
// maps snake_case fields and is_primary number (0/1) -> boolean.
|
|
106
|
+
// note: the freqhole Song wire type does not carry album_title; that field defaults to "".
|
|
107
|
+
// media_blob_id is used as sha256 (it is the server's short blob id, not a true content hash).
|
|
108
|
+
export function freqholePlaylistToDoc(
|
|
109
|
+
playlist: FreqholePlaylist,
|
|
110
|
+
songs: FreqholeSong[],
|
|
111
|
+
): PlaylistDoc {
|
|
112
|
+
const createdAt = tsToIso(playlist.created_at);
|
|
113
|
+
const lastModified = tsToIso(playlist.updated_at);
|
|
114
|
+
|
|
115
|
+
const songEntries: Record<string, SongEntry> = {};
|
|
116
|
+
const order: string[] = [];
|
|
117
|
+
|
|
118
|
+
for (const song of songs) {
|
|
119
|
+
const entry: SongEntry = {
|
|
120
|
+
id: song.id,
|
|
121
|
+
title: song.title ?? "",
|
|
122
|
+
artist: song.track_artist ?? "",
|
|
123
|
+
album: "",
|
|
124
|
+
duration: song.duration ?? 0,
|
|
125
|
+
mimeType: "",
|
|
126
|
+
fileSize: 0,
|
|
127
|
+
sha256: song.media_blob_id,
|
|
128
|
+
images: mapFreqholeImages(song.images),
|
|
129
|
+
urls: mapEntityUrls(song.urls),
|
|
130
|
+
...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
|
|
131
|
+
};
|
|
132
|
+
songEntries[song.id] = entry;
|
|
133
|
+
order.push(song.id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return emptyPlaylistDoc({
|
|
137
|
+
title: playlist.title ?? "",
|
|
138
|
+
description: playlist.description ?? "",
|
|
139
|
+
createdAt,
|
|
140
|
+
lastModified,
|
|
141
|
+
images: mapFreqholeImages(playlist.images),
|
|
142
|
+
urls: mapEntityUrls(playlist.urls),
|
|
143
|
+
songs: songEntries,
|
|
144
|
+
order,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// convert a spume idb Playlist + Song[] to a PlaylistDoc.
|
|
149
|
+
// maps spume's ImageMetadata (local_blob_id/remote_blob_id) to ImageRef.
|
|
150
|
+
// spume Song fields: sha256, artist_name, album_title, duration_seconds, mime_type, file_size, blake3.
|
|
151
|
+
export function spumePlaylistToDoc(
|
|
152
|
+
playlist: SpumePlaylist,
|
|
153
|
+
songs: SpumeSong[],
|
|
154
|
+
): PlaylistDoc {
|
|
155
|
+
const now = new Date().toISOString();
|
|
156
|
+
const createdAt = playlist.created_at ? tsToIso(playlist.created_at) : now;
|
|
157
|
+
const lastModified = playlist.updated_at ? tsToIso(playlist.updated_at) : now;
|
|
158
|
+
|
|
159
|
+
const songEntries: Record<string, SongEntry> = {};
|
|
160
|
+
const order: string[] = [];
|
|
161
|
+
|
|
162
|
+
for (const song of songs) {
|
|
163
|
+
const entry: SongEntry = {
|
|
164
|
+
id: song.id,
|
|
165
|
+
title: song.title ?? "",
|
|
166
|
+
artist: song.artist_name ?? "",
|
|
167
|
+
album: song.album_title ?? "",
|
|
168
|
+
duration: song.duration_seconds ?? 0,
|
|
169
|
+
mimeType: song.mime_type ?? "",
|
|
170
|
+
fileSize: song.file_size ?? 0,
|
|
171
|
+
sha256: song.sha256,
|
|
172
|
+
images: mapSpumeImages(song.images),
|
|
173
|
+
urls: mapEntityUrls(song.urls),
|
|
174
|
+
...(song.blake3 != null ? { blake3: song.blake3 } : {}),
|
|
175
|
+
...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
|
|
176
|
+
};
|
|
177
|
+
songEntries[song.id] = entry;
|
|
178
|
+
order.push(song.id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return emptyPlaylistDoc({
|
|
182
|
+
title: playlist.title ?? "",
|
|
183
|
+
description: playlist.description ?? "",
|
|
184
|
+
createdAt,
|
|
185
|
+
lastModified,
|
|
186
|
+
images: mapSpumeImages(playlist.images),
|
|
187
|
+
urls: mapEntityUrls(playlist.urls),
|
|
188
|
+
songs: songEntries,
|
|
189
|
+
order,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// convert a PlaylistDoc back to the freqhole wire format where lossless.
|
|
194
|
+
// id is not stored in the doc - defaults to "". caller must supply the real
|
|
195
|
+
// playlist id if needed (e.g. from the docIndex entry).
|
|
196
|
+
// note: album_title, mime_type, file_size are not in the freqhole Song wire type
|
|
197
|
+
// and are dropped. sha256 is mapped back to media_blob_id.
|
|
198
|
+
export function docToFreqholePlaylist(
|
|
199
|
+
doc: PlaylistDoc,
|
|
200
|
+
): { playlist: FreqholePlaylist; songs: FreqholeSong[] } {
|
|
201
|
+
const createdAt = doc.createdAt ? new Date(doc.createdAt).getTime() : 0;
|
|
202
|
+
const updatedAt = doc.lastModified ? new Date(doc.lastModified).getTime() : 0;
|
|
203
|
+
|
|
204
|
+
const playlist: FreqholePlaylist = {
|
|
205
|
+
id: "",
|
|
206
|
+
title: doc.title,
|
|
207
|
+
description: doc.description || null,
|
|
208
|
+
is_public: 0,
|
|
209
|
+
images: doc.images.map((img) => ({
|
|
210
|
+
blob_id: img.blobId,
|
|
211
|
+
is_primary: img.isPrimary ? 1 : 0,
|
|
212
|
+
blob_type: img.blobType,
|
|
213
|
+
})),
|
|
214
|
+
urls: doc.urls.map((u) => ({
|
|
215
|
+
url: u.url,
|
|
216
|
+
id: u.id ?? null,
|
|
217
|
+
name: u.name ?? null,
|
|
218
|
+
})),
|
|
219
|
+
created_by_id: null,
|
|
220
|
+
created_at: createdAt,
|
|
221
|
+
updated_at: updatedAt,
|
|
222
|
+
deleted_at: null,
|
|
223
|
+
deleted_by: null,
|
|
224
|
+
created_by: null,
|
|
225
|
+
updated_by: null,
|
|
226
|
+
song_count: doc.order.length,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const songs: FreqholeSong[] = doc.order
|
|
230
|
+
.map((id) => doc.songs[id])
|
|
231
|
+
.filter((s): s is SongEntry => s != null)
|
|
232
|
+
.map((s) => {
|
|
233
|
+
return {
|
|
234
|
+
id: s.id,
|
|
235
|
+
media_blob_id: s.sha256,
|
|
236
|
+
title: s.title,
|
|
237
|
+
track_number: 1,
|
|
238
|
+
disc_number: 1,
|
|
239
|
+
duration: s.duration,
|
|
240
|
+
track_artist: s.artist || null,
|
|
241
|
+
images: s.images.map((img) => ({
|
|
242
|
+
blob_id: img.blobId,
|
|
243
|
+
is_primary: img.isPrimary ? 1 : 0,
|
|
244
|
+
blob_type: img.blobType,
|
|
245
|
+
})),
|
|
246
|
+
urls: s.urls.map((u) => ({
|
|
247
|
+
url: u.url,
|
|
248
|
+
id: u.id ?? null,
|
|
249
|
+
name: u.name ?? null,
|
|
250
|
+
})),
|
|
251
|
+
metadata: null,
|
|
252
|
+
lyrics: s.lyrics ?? null,
|
|
253
|
+
created_at: createdAt,
|
|
254
|
+
updated_at: updatedAt,
|
|
255
|
+
deleted_at: null,
|
|
256
|
+
deleted_by: null,
|
|
257
|
+
created_by: null,
|
|
258
|
+
updated_by: null,
|
|
259
|
+
created_by_username: null,
|
|
260
|
+
updated_by_username: null,
|
|
261
|
+
bpm: null,
|
|
262
|
+
play_count: null,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return { playlist, songs };
|
|
267
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// pure mutation helpers for PlaylistDoc.
|
|
2
|
+
// each function mutates the doc draft in place - safe to call inside handle.change().
|
|
3
|
+
// no automerge imports: these are plain object mutations over plain objects.
|
|
4
|
+
import type { PlaylistDoc, SongEntry, ImageRef } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
function nowIso(): string {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// bump lastModified to now, optionally setting lastModifiedBy
|
|
11
|
+
function bumpModified(doc: PlaylistDoc, by?: string): void {
|
|
12
|
+
doc.lastModified = nowIso();
|
|
13
|
+
if (by !== undefined) {
|
|
14
|
+
doc.lastModifiedBy = by;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// insert or replace a song in the doc, appending its id to order if not already present
|
|
19
|
+
export function upsertSong(doc: PlaylistDoc, song: SongEntry, by?: string): void {
|
|
20
|
+
doc.songs[song.id] = song;
|
|
21
|
+
if (!doc.order.includes(song.id)) {
|
|
22
|
+
doc.order.push(song.id);
|
|
23
|
+
}
|
|
24
|
+
bumpModified(doc, by);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// remove a song from songs and order
|
|
28
|
+
export function removeSong(doc: PlaylistDoc, songId: string, by?: string): void {
|
|
29
|
+
delete doc.songs[songId];
|
|
30
|
+
const idx = doc.order.indexOf(songId);
|
|
31
|
+
if (idx !== -1) {
|
|
32
|
+
doc.order.splice(idx, 1);
|
|
33
|
+
}
|
|
34
|
+
bumpModified(doc, by);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// move a single song to a target index within order.
|
|
38
|
+
// single-item splice keeps automerge merge semantics sane.
|
|
39
|
+
export function reorderSongs(
|
|
40
|
+
doc: PlaylistDoc,
|
|
41
|
+
songId: string,
|
|
42
|
+
toIndex: number,
|
|
43
|
+
by?: string,
|
|
44
|
+
): void {
|
|
45
|
+
const fromIdx = doc.order.indexOf(songId);
|
|
46
|
+
if (fromIdx === -1) return;
|
|
47
|
+
doc.order.splice(fromIdx, 1);
|
|
48
|
+
const clamped = Math.max(0, Math.min(toIndex, doc.order.length));
|
|
49
|
+
doc.order.splice(clamped, 0, songId);
|
|
50
|
+
bumpModified(doc, by);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// playlist metadata fields settable via setMetadata
|
|
54
|
+
type MetadataFields = Partial<
|
|
55
|
+
Pick<
|
|
56
|
+
PlaylistDoc,
|
|
57
|
+
| "title"
|
|
58
|
+
| "description"
|
|
59
|
+
| "bgFilterEnabled"
|
|
60
|
+
| "bgFilterBlur"
|
|
61
|
+
| "bgFilterContrast"
|
|
62
|
+
| "bgFilterBrightness"
|
|
63
|
+
| "coverFilterEnabled"
|
|
64
|
+
| "coverFilterBlur"
|
|
65
|
+
| "bgSize"
|
|
66
|
+
| "bgPosition"
|
|
67
|
+
| "bgRepeat"
|
|
68
|
+
>
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
// update playlist-level metadata fields
|
|
72
|
+
export function setMetadata(
|
|
73
|
+
doc: PlaylistDoc,
|
|
74
|
+
fields: MetadataFields,
|
|
75
|
+
by?: string,
|
|
76
|
+
): void {
|
|
77
|
+
if (fields.title !== undefined) doc.title = fields.title;
|
|
78
|
+
if (fields.description !== undefined) doc.description = fields.description;
|
|
79
|
+
for (const key of [
|
|
80
|
+
"bgFilterEnabled",
|
|
81
|
+
"bgFilterBlur",
|
|
82
|
+
"bgFilterContrast",
|
|
83
|
+
"bgFilterBrightness",
|
|
84
|
+
"coverFilterEnabled",
|
|
85
|
+
"coverFilterBlur",
|
|
86
|
+
"bgSize",
|
|
87
|
+
"bgPosition",
|
|
88
|
+
"bgRepeat",
|
|
89
|
+
] as const) {
|
|
90
|
+
if (fields[key] !== undefined) {
|
|
91
|
+
// assignment is type-safe per key; typescript can't narrow the loop var
|
|
92
|
+
(doc as Record<string, unknown>)[key] = fields[key];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
bumpModified(doc, by);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// add an image ref to the playlist or to a specific song.
|
|
99
|
+
// if ref.isPrimary is true, clears isPrimary on all sibling images first.
|
|
100
|
+
export function addImage(
|
|
101
|
+
doc: PlaylistDoc,
|
|
102
|
+
ref: ImageRef,
|
|
103
|
+
target?: { songId?: string },
|
|
104
|
+
by?: string,
|
|
105
|
+
): void {
|
|
106
|
+
const images =
|
|
107
|
+
target?.songId != null
|
|
108
|
+
? doc.songs[target.songId]?.images
|
|
109
|
+
: doc.images;
|
|
110
|
+
|
|
111
|
+
if (!images) return;
|
|
112
|
+
|
|
113
|
+
if (ref.isPrimary) {
|
|
114
|
+
for (const img of images) {
|
|
115
|
+
img.isPrimary = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
images.push(ref);
|
|
119
|
+
bumpModified(doc, by);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// set the primary image by blobId, clearing isPrimary on all others.
|
|
123
|
+
// operates on playlist images or a specific song's images.
|
|
124
|
+
export function setPrimaryImage(
|
|
125
|
+
doc: PlaylistDoc,
|
|
126
|
+
blobId: string,
|
|
127
|
+
target?: { songId?: string },
|
|
128
|
+
by?: string,
|
|
129
|
+
): void {
|
|
130
|
+
const images =
|
|
131
|
+
target?.songId != null
|
|
132
|
+
? doc.songs[target.songId]?.images
|
|
133
|
+
: doc.images;
|
|
134
|
+
|
|
135
|
+
if (!images) return;
|
|
136
|
+
|
|
137
|
+
for (const img of images) {
|
|
138
|
+
img.isPrimary = img.blobId === blobId;
|
|
139
|
+
}
|
|
140
|
+
bumpModified(doc, by);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// record a peer joining this doc. idempotent - no-op if peer is already present.
|
|
144
|
+
// does not bump lastModified (peer bookkeeping is not a content edit).
|
|
145
|
+
export function addPeer(doc: PlaylistDoc, nodeId: string): void {
|
|
146
|
+
if (doc.peers[nodeId]) return;
|
|
147
|
+
doc.peers[nodeId] = {
|
|
148
|
+
nodeId,
|
|
149
|
+
joinedAt: nowIso(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// update lastSeenAt for an existing peer entry.
|
|
154
|
+
// does not bump lastModified (ephemeral presence, not a content edit).
|
|
155
|
+
export function stampLastSeen(doc: PlaylistDoc, nodeId: string): void {
|
|
156
|
+
const peer = doc.peers[nodeId];
|
|
157
|
+
if (peer) {
|
|
158
|
+
peer.lastSeenAt = nowIso();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// set or update the access role for a node in the acl
|
|
163
|
+
export function setAclRole(
|
|
164
|
+
doc: PlaylistDoc,
|
|
165
|
+
nodeId: string,
|
|
166
|
+
role: "owner" | "editor" | "viewer",
|
|
167
|
+
by?: string,
|
|
168
|
+
): void {
|
|
169
|
+
if (!doc.acl) {
|
|
170
|
+
doc.acl = {};
|
|
171
|
+
}
|
|
172
|
+
doc.acl[nodeId] = { role };
|
|
173
|
+
bumpModified(doc, by);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// mark this playlist as deleted (tombstone).
|
|
177
|
+
// the doc remains in the repo; consumers check deleted before rendering.
|
|
178
|
+
export function tombstone(doc: PlaylistDoc, by?: string): void {
|
|
179
|
+
doc.deleted = true;
|
|
180
|
+
bumpModified(doc, by);
|
|
181
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// freqhole-playlistz/1 discovery and knock protocol.
|
|
2
|
+
// zod-validated message envelopes + BiStream encode/decode helpers.
|
|
3
|
+
// no midden or automerge imports.
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
|
|
6
|
+
// ALPN strings used when registering iroh endpoints
|
|
7
|
+
export const PLAYLISTZ_ALPN = "freqhole-playlistz/1";
|
|
8
|
+
export const AUTOMERGE_ALPN = "iroh/automerge-repo/1";
|
|
9
|
+
|
|
10
|
+
// ---- message schemas ----
|
|
11
|
+
|
|
12
|
+
const HelloSchema = z.object({
|
|
13
|
+
v: z.literal(1),
|
|
14
|
+
type: z.literal("hello"),
|
|
15
|
+
nodeId: z.string(),
|
|
16
|
+
name: z.string().optional(),
|
|
17
|
+
avatarBlobId: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const HelloOkSchema = z.object({
|
|
21
|
+
v: z.literal(1),
|
|
22
|
+
type: z.literal("hello_ok"),
|
|
23
|
+
nodeId: z.string(),
|
|
24
|
+
name: z.string().optional(),
|
|
25
|
+
avatarDataUrl: z.string().optional(),
|
|
26
|
+
public: z.boolean(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ListPlaylistsSchema = z.object({
|
|
30
|
+
v: z.literal(1),
|
|
31
|
+
type: z.literal("list_playlists"),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const PlaylistItemSchema = z.object({
|
|
35
|
+
docId: z.string(),
|
|
36
|
+
title: z.string(),
|
|
37
|
+
songCount: z.number(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const PlaylistsSchema = z.object({
|
|
41
|
+
v: z.literal(1),
|
|
42
|
+
type: z.literal("playlists"),
|
|
43
|
+
items: z.array(PlaylistItemSchema),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const KnockSchema = z.object({
|
|
47
|
+
v: z.literal(1),
|
|
48
|
+
type: z.literal("knock"),
|
|
49
|
+
nodeId: z.string(),
|
|
50
|
+
name: z.string().optional(),
|
|
51
|
+
message: z.string().optional(),
|
|
52
|
+
knockType: z.enum(["browse", "doc_access"]).optional(), // default: "browse"
|
|
53
|
+
docId: z.string().optional(), // present when knockType is "doc_access"
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const KnockStatusSchema = z.object({
|
|
57
|
+
v: z.literal(1),
|
|
58
|
+
type: z.literal("knock_status"),
|
|
59
|
+
status: z.union([
|
|
60
|
+
z.literal("pending"),
|
|
61
|
+
z.literal("accepted"),
|
|
62
|
+
z.literal("denied"),
|
|
63
|
+
]),
|
|
64
|
+
grantedDocIds: z.array(z.string()).optional(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ask a peer to make a blob available for iroh-blobs verified download.
|
|
68
|
+
// sha256 is the blob store key carried in playlist docs.
|
|
69
|
+
const BlobRequestSchema = z.object({
|
|
70
|
+
v: z.literal(1),
|
|
71
|
+
type: z.literal("blob_request"),
|
|
72
|
+
sha256: z.string(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// reply: the peer imported the blob into its iroh-blobs store.
|
|
76
|
+
// blake3 + size are what download_verified_streaming needs.
|
|
77
|
+
const BlobReadySchema = z.object({
|
|
78
|
+
v: z.literal(1),
|
|
79
|
+
type: z.literal("blob_ready"),
|
|
80
|
+
sha256: z.string(),
|
|
81
|
+
blake3: z.string(),
|
|
82
|
+
size: z.number(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const ErrorSchema = z.object({
|
|
86
|
+
v: z.literal(1),
|
|
87
|
+
type: z.literal("error"),
|
|
88
|
+
code: z.string(),
|
|
89
|
+
message: z.string(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// proactive acceptance notification: owner opens a stream to the peer and
|
|
93
|
+
// sends this after accepting their knock, so the peer doesn't have to poll.
|
|
94
|
+
const KnockNotifySchema = z.object({
|
|
95
|
+
v: z.literal(1),
|
|
96
|
+
type: z.literal("knock_notify"),
|
|
97
|
+
status: z.literal("accepted"),
|
|
98
|
+
docIds: z.array(z.string()),
|
|
99
|
+
ownerNodeId: z.string(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// proactive identity update: a peer broadcasts their current name/avatar
|
|
103
|
+
// to all peers they have established connections with.
|
|
104
|
+
const IdentityUpdateSchema = z.object({
|
|
105
|
+
v: z.literal(1),
|
|
106
|
+
type: z.literal("identity_update"),
|
|
107
|
+
name: z.string().optional(),
|
|
108
|
+
avatarDataUrl: z.string().optional(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// discriminated union of all protocol messages
|
|
112
|
+
export const MessageSchema = z.discriminatedUnion("type", [
|
|
113
|
+
HelloSchema,
|
|
114
|
+
HelloOkSchema,
|
|
115
|
+
ListPlaylistsSchema,
|
|
116
|
+
PlaylistsSchema,
|
|
117
|
+
KnockSchema,
|
|
118
|
+
KnockStatusSchema,
|
|
119
|
+
BlobRequestSchema,
|
|
120
|
+
BlobReadySchema,
|
|
121
|
+
ErrorSchema,
|
|
122
|
+
KnockNotifySchema,
|
|
123
|
+
IdentityUpdateSchema,
|
|
124
|
+
]);
|
|
125
|
+
export type Message = z.infer<typeof MessageSchema>;
|
|
126
|
+
|
|
127
|
+
// convenience inferred types per message kind
|
|
128
|
+
export type HelloMessage = z.infer<typeof HelloSchema>;
|
|
129
|
+
export type HelloOkMessage = z.infer<typeof HelloOkSchema>;
|
|
130
|
+
export type ListPlaylistsMessage = z.infer<typeof ListPlaylistsSchema>;
|
|
131
|
+
export type PlaylistsMessage = z.infer<typeof PlaylistsSchema>;
|
|
132
|
+
export type KnockMessage = z.infer<typeof KnockSchema>;
|
|
133
|
+
export type KnockStatusMessage = z.infer<typeof KnockStatusSchema>;
|
|
134
|
+
export type BlobRequestMessage = z.infer<typeof BlobRequestSchema>;
|
|
135
|
+
export type BlobReadyMessage = z.infer<typeof BlobReadySchema>;
|
|
136
|
+
export type ErrorMessage = z.infer<typeof ErrorSchema>;
|
|
137
|
+
export type KnockNotifyMessage = z.infer<typeof KnockNotifySchema>;
|
|
138
|
+
|
|
139
|
+
// ---- BiStream structural interface ----
|
|
140
|
+
// matches the shape provided by midden without importing it
|
|
141
|
+
|
|
142
|
+
export interface BiStreamLike {
|
|
143
|
+
write_message(data: Uint8Array): Promise<void>;
|
|
144
|
+
read_message(): Promise<Uint8Array | null>;
|
|
145
|
+
close(): void;
|
|
146
|
+
peer_node_id(): string;
|
|
147
|
+
alpn(): string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- codec ----
|
|
151
|
+
|
|
152
|
+
// error thrown when decoding an invalid or unrecognized message
|
|
153
|
+
export class ProtocolError extends Error {
|
|
154
|
+
readonly code: string;
|
|
155
|
+
|
|
156
|
+
constructor(code: string, message: string) {
|
|
157
|
+
super(message);
|
|
158
|
+
this.name = "ProtocolError";
|
|
159
|
+
this.code = code;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const encoder = new TextEncoder();
|
|
164
|
+
const decoder = new TextDecoder();
|
|
165
|
+
|
|
166
|
+
// serialize a message to utf-8 JSON bytes
|
|
167
|
+
export function encodeMessage(msg: Message): Uint8Array {
|
|
168
|
+
return encoder.encode(JSON.stringify(msg));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// parse utf-8 JSON bytes back to a Message.
|
|
172
|
+
// throws ProtocolError with a descriptive code on invalid input.
|
|
173
|
+
export function decodeMessage(bytes: Uint8Array): Message {
|
|
174
|
+
let json: string;
|
|
175
|
+
try {
|
|
176
|
+
json = decoder.decode(bytes);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new ProtocolError(
|
|
179
|
+
"decode_error",
|
|
180
|
+
`failed to decode utf-8: ${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let raw: unknown;
|
|
185
|
+
try {
|
|
186
|
+
raw = JSON.parse(json);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new ProtocolError(
|
|
189
|
+
"parse_error",
|
|
190
|
+
`failed to parse json: ${err instanceof Error ? err.message : String(err)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = MessageSchema.safeParse(raw);
|
|
195
|
+
if (!result.success) {
|
|
196
|
+
const firstIssue = result.error.issues[0];
|
|
197
|
+
throw new ProtocolError(
|
|
198
|
+
"schema_error",
|
|
199
|
+
`invalid message: ${firstIssue?.message ?? "unknown"}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return result.data;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// write a message to a BiStream
|
|
206
|
+
export async function sendMessage(
|
|
207
|
+
stream: BiStreamLike,
|
|
208
|
+
msg: Message,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
await stream.write_message(encodeMessage(msg));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// read the next message from a BiStream.
|
|
214
|
+
// returns null on clean EOF (read_message returned null).
|
|
215
|
+
// throws ProtocolError on invalid data.
|
|
216
|
+
export async function readMessage(
|
|
217
|
+
stream: BiStreamLike,
|
|
218
|
+
): Promise<Message | null> {
|
|
219
|
+
const bytes = await stream.read_message();
|
|
220
|
+
if (bytes === null) return null;
|
|
221
|
+
return decodeMessage(bytes);
|
|
222
|
+
}
|