@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
package/src/transport.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// transport abstraction for HTTP and P2P
|
|
2
|
+
//
|
|
3
|
+
// transports handle the low-level request/response mechanics.
|
|
4
|
+
// FreqholeClient uses a transport to make requests, then handles
|
|
5
|
+
// Zod validation on top.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
CloseReason,
|
|
9
|
+
EventFilter,
|
|
10
|
+
JobEvent,
|
|
11
|
+
JobStateSnapshot,
|
|
12
|
+
} from "./codegen/schema.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* response from a transport request
|
|
16
|
+
*/
|
|
17
|
+
export interface TransportResponse {
|
|
18
|
+
status: number;
|
|
19
|
+
body: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* blob data with metadata
|
|
24
|
+
*/
|
|
25
|
+
export interface BlobData {
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
contentType: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* transport interface - implemented by HttpTransport, AppTransport, WasmTransport
|
|
32
|
+
*/
|
|
33
|
+
export interface Transport {
|
|
34
|
+
/**
|
|
35
|
+
* make an API request
|
|
36
|
+
* @param method - HTTP method (GET, POST, etc)
|
|
37
|
+
* @param path - API path (e.g., /api/songs/query)
|
|
38
|
+
* @param body - optional JSON body string
|
|
39
|
+
* @returns response with status code and body string
|
|
40
|
+
*/
|
|
41
|
+
request(method: string, path: string, body?: string): Promise<TransportResponse>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* upload a file via FormData
|
|
45
|
+
* @param path - API path (e.g., /api/upload/music)
|
|
46
|
+
* @param formData - FormData with file and metadata
|
|
47
|
+
* @returns response with status code and body string
|
|
48
|
+
*/
|
|
49
|
+
upload(path: string, formData: FormData): Promise<TransportResponse>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* fetch a blob by ID
|
|
53
|
+
* @param blobId - the blob ID to fetch
|
|
54
|
+
* @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
|
|
55
|
+
* @returns blob data with content type
|
|
56
|
+
*/
|
|
57
|
+
fetchBlob(blobId: string, blake3?: string): Promise<BlobData>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* get a URL for a blob (for <audio>/<img> src)
|
|
61
|
+
* HTTP transport returns direct URL, P2P transports may need caching
|
|
62
|
+
* @param blobId - the blob ID to fetch
|
|
63
|
+
* @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
|
|
64
|
+
*/
|
|
65
|
+
getBlobUrl(blobId: string, blake3?: string): string | Promise<string>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* get a URL for a blob with progress callback (optional).
|
|
69
|
+
* only implemented by transports that support streaming progress (WasmTransport).
|
|
70
|
+
* @param blobId - the blob ID to fetch
|
|
71
|
+
* @param onProgress - callback with (received, total) bytes
|
|
72
|
+
* @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
|
|
73
|
+
* @param totalBytes - optional known total size of the blob in bytes; used so
|
|
74
|
+
* the progress callback reports a correct received/total ratio even when
|
|
75
|
+
* the underlying iroh-blobs stream doesn't supply size up front
|
|
76
|
+
* @param mimeType - optional content type for the assembled blob (e.g.
|
|
77
|
+
* the song's media_blob.mime). midden's streaming path doesn't surface
|
|
78
|
+
* the source mime, so callers should pass it when known.
|
|
79
|
+
*/
|
|
80
|
+
getBlobUrlWithProgress?(
|
|
81
|
+
blobId: string,
|
|
82
|
+
onProgress: (received: number, total: number) => void,
|
|
83
|
+
blake3?: string,
|
|
84
|
+
totalBytes?: number,
|
|
85
|
+
mimeType?: string,
|
|
86
|
+
): Promise<string>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* fetch server image (public, no auth required)
|
|
90
|
+
* used during "add remote" flow before user is authenticated
|
|
91
|
+
* only implemented by P2P transports (WasmTransport)
|
|
92
|
+
*/
|
|
93
|
+
fetchHelloImage?(): Promise<BlobData | null>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* upload a file by filesystem path (optional).
|
|
97
|
+
* only implemented by transports with direct filesystem access (CharnelLocalTransport).
|
|
98
|
+
* skips base64 encoding by passing the path to the backend.
|
|
99
|
+
* @param path - API path (e.g., /api/upload/image)
|
|
100
|
+
* @param filePath - local filesystem path to the file
|
|
101
|
+
* @param metadata - optional metadata to include (e.g., associate_with)
|
|
102
|
+
*/
|
|
103
|
+
uploadByPath?(
|
|
104
|
+
path: string,
|
|
105
|
+
filePath: string,
|
|
106
|
+
metadata?: Record<string, unknown>,
|
|
107
|
+
): Promise<TransportResponse>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* one-shot snapshot of currently-active jobs the caller is allowed to
|
|
111
|
+
* see. used to rehydrate after a page reload / fresh mount. transports
|
|
112
|
+
* that don't override fall back to the default `request()` based impl
|
|
113
|
+
* (POST /api/jobs/events/snapshot).
|
|
114
|
+
*/
|
|
115
|
+
snapshotJobEvents?(filter?: EventFilter): Promise<JobStateSnapshot[]>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* live subscription to job events. each iterator value is one event in
|
|
119
|
+
* snake_case-tagged form. consumers should treat termination as a
|
|
120
|
+
* normal stream end (transport-specific reason surfaced via the
|
|
121
|
+
* optional `return()` path on the iterator). transports that don't
|
|
122
|
+
* override use the default polling fallback (see `pollingJobEvents`).
|
|
123
|
+
*/
|
|
124
|
+
subscribeJobEvents?(
|
|
125
|
+
filter?: EventFilter,
|
|
126
|
+
signal?: AbortSignal,
|
|
127
|
+
): AsyncIterable<JobEvent>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* HTTP transport - uses fetch API
|
|
132
|
+
*/
|
|
133
|
+
export class HttpTransport implements Transport {
|
|
134
|
+
constructor(
|
|
135
|
+
public readonly baseUrl: string,
|
|
136
|
+
private readonly apiKey?: string,
|
|
137
|
+
) {}
|
|
138
|
+
|
|
139
|
+
async request(method: string, path: string, body?: string): Promise<TransportResponse> {
|
|
140
|
+
const url = this.baseUrl + path;
|
|
141
|
+
const headers: Record<string, string> = {};
|
|
142
|
+
|
|
143
|
+
if (body) {
|
|
144
|
+
headers["Content-Type"] = "application/json";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.apiKey) {
|
|
148
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const options: RequestInit = {
|
|
152
|
+
method,
|
|
153
|
+
headers,
|
|
154
|
+
credentials: this.apiKey ? "omit" : "include",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// disable cache for blob metadata routes
|
|
158
|
+
if (path.includes("/api/blobs/") && path.includes("/metadata")) {
|
|
159
|
+
options.cache = "no-store";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (body) {
|
|
163
|
+
options.body = body;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const response = await fetch(url, options);
|
|
167
|
+
const responseBody = await response.text();
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
status: response.status,
|
|
171
|
+
body: responseBody,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async upload(path: string, formData: FormData): Promise<TransportResponse> {
|
|
176
|
+
const url = this.baseUrl + path;
|
|
177
|
+
const headers: Record<string, string> = {};
|
|
178
|
+
|
|
179
|
+
// don't set Content-Type - browser sets it with boundary for FormData
|
|
180
|
+
if (this.apiKey) {
|
|
181
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const response = await fetch(url, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers,
|
|
187
|
+
body: formData,
|
|
188
|
+
credentials: this.apiKey ? "omit" : "include",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const responseBody = await response.text();
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
status: response.status,
|
|
195
|
+
body: responseBody,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async fetchBlob(blobId: string, _blake3?: string): Promise<BlobData> {
|
|
200
|
+
const url = `${this.baseUrl}/api/blobs/${blobId}`;
|
|
201
|
+
const headers: Record<string, string> = {};
|
|
202
|
+
|
|
203
|
+
if (this.apiKey) {
|
|
204
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const response = await fetch(url, {
|
|
208
|
+
headers,
|
|
209
|
+
credentials: this.apiKey ? "omit" : "include",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
throw new Error(`failed to fetch blob: ${response.status}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
217
|
+
const contentType = response.headers.get("Content-Type") || "application/octet-stream";
|
|
218
|
+
|
|
219
|
+
return { data, contentType };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getBlobUrl(blobId: string, _blake3?: string): string {
|
|
223
|
+
return `${this.baseUrl}/api/blobs/${blobId}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// -----------------------------------------------------------------
|
|
227
|
+
// job events (http impl: plain POST snapshot + polling fallback for
|
|
228
|
+
// subscribe. real bi-directional streaming over http would need sse
|
|
229
|
+
// or websockets; that's deferred. polling diffs by `job_id`/status
|
|
230
|
+
// and synthesizes `StatusChanged` events so consumers can use the
|
|
231
|
+
// same handler as the iroh/ipc transports.)
|
|
232
|
+
// -----------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
async snapshotJobEvents(filter?: EventFilter): Promise<JobStateSnapshot[]> {
|
|
235
|
+
return snapshotJobEventsViaRequest(this, filter);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
subscribeJobEvents(
|
|
239
|
+
filter?: EventFilter,
|
|
240
|
+
signal?: AbortSignal,
|
|
241
|
+
): AsyncIterable<JobEvent> {
|
|
242
|
+
return pollingJobEvents(this, filter, signal);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------
|
|
247
|
+
// shared http-style helpers (used by HttpTransport directly, and by any
|
|
248
|
+
// transport that wants the same polling fallback shape)
|
|
249
|
+
// ---------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* default snapshot impl: POST /api/jobs/events/snapshot. relies only on
|
|
253
|
+
* the transport's `request()`, so it works for any http-backed transport.
|
|
254
|
+
*/
|
|
255
|
+
export async function snapshotJobEventsViaRequest(
|
|
256
|
+
transport: Transport,
|
|
257
|
+
filter?: EventFilter,
|
|
258
|
+
): Promise<JobStateSnapshot[]> {
|
|
259
|
+
const body = JSON.stringify(filter ?? {});
|
|
260
|
+
const resp = await transport.request(
|
|
261
|
+
"POST",
|
|
262
|
+
"/api/jobs/events/snapshot",
|
|
263
|
+
body,
|
|
264
|
+
);
|
|
265
|
+
if (resp.status >= 400 || resp.status === 0) {
|
|
266
|
+
throw new Error(`snapshotJobEvents failed: status ${resp.status}`);
|
|
267
|
+
}
|
|
268
|
+
let parsed: unknown;
|
|
269
|
+
try {
|
|
270
|
+
parsed = JSON.parse(resp.body);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new Error("snapshotJobEvents: non-json response");
|
|
273
|
+
}
|
|
274
|
+
// unwrap GrimoireResponse { success, data, ... }
|
|
275
|
+
const root = parsed as { data?: unknown; success?: boolean };
|
|
276
|
+
const data = root && "data" in root ? root.data : parsed;
|
|
277
|
+
if (!Array.isArray(data)) {
|
|
278
|
+
throw new Error("snapshotJobEvents: response data not an array");
|
|
279
|
+
}
|
|
280
|
+
return data as JobStateSnapshot[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** poll interval used by the http subscribe-fallback. */
|
|
284
|
+
export const POLL_INTERVAL_FALLBACK_MS = 3_000;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* polling fallback iterator. snapshots every `POLL_INTERVAL_FALLBACK_MS`
|
|
288
|
+
* and yields synthetic `StatusChanged` / `Stage` events for any
|
|
289
|
+
* differences from the previous snapshot. event shape matches the
|
|
290
|
+
* `JobEvent` schema so callers can use the same reducer regardless of
|
|
291
|
+
* transport.
|
|
292
|
+
*
|
|
293
|
+
* the first tick yields nothing (it just seeds the baseline). subsequent
|
|
294
|
+
* ticks diff against the previous snapshot:
|
|
295
|
+
* - new `job_id` -> StatusChanged with `from: null, to: status`
|
|
296
|
+
* - status change -> StatusChanged with `from: prev, to: next`
|
|
297
|
+
* - stage/message change -> Stage with the new values
|
|
298
|
+
* - disappearing `job_id` -> ignored (terminal status already emitted
|
|
299
|
+
* when first seen as Completed/Failed/Cancelled)
|
|
300
|
+
*/
|
|
301
|
+
export async function* pollingJobEvents(
|
|
302
|
+
transport: Transport,
|
|
303
|
+
filter?: EventFilter,
|
|
304
|
+
signal?: AbortSignal,
|
|
305
|
+
): AsyncGenerator<JobEvent, void, void> {
|
|
306
|
+
let prev = new Map<string, JobStateSnapshot>();
|
|
307
|
+
let first = true;
|
|
308
|
+
while (true) {
|
|
309
|
+
if (signal?.aborted) return;
|
|
310
|
+
let snaps: JobStateSnapshot[] = [];
|
|
311
|
+
try {
|
|
312
|
+
snaps = await snapshotJobEventsViaRequest(transport, filter);
|
|
313
|
+
} catch {
|
|
314
|
+
// swallow; try again next tick. callers can fail-fast by
|
|
315
|
+
// observing snapshot errors separately via snapshotJobEvents.
|
|
316
|
+
}
|
|
317
|
+
if (!first) {
|
|
318
|
+
const next = new Map<string, JobStateSnapshot>();
|
|
319
|
+
for (const s of snaps) next.set(s.job_id, s);
|
|
320
|
+
for (const [job_id, cur] of next) {
|
|
321
|
+
const before = prev.get(job_id);
|
|
322
|
+
if (!before) {
|
|
323
|
+
yield {
|
|
324
|
+
kind: "status_changed",
|
|
325
|
+
session_id: cur.session_id ?? "",
|
|
326
|
+
job_id,
|
|
327
|
+
from: null,
|
|
328
|
+
to: cur.status,
|
|
329
|
+
topic: cur.job_type,
|
|
330
|
+
entity_ref: cur.entity_ref ?? null,
|
|
331
|
+
created_by: cur.created_by ?? null,
|
|
332
|
+
} as JobEvent;
|
|
333
|
+
} else if (before.status !== cur.status) {
|
|
334
|
+
yield {
|
|
335
|
+
kind: "status_changed",
|
|
336
|
+
session_id: cur.session_id ?? "",
|
|
337
|
+
job_id,
|
|
338
|
+
from: before.status,
|
|
339
|
+
to: cur.status,
|
|
340
|
+
topic: cur.job_type,
|
|
341
|
+
entity_ref: cur.entity_ref ?? null,
|
|
342
|
+
created_by: cur.created_by ?? null,
|
|
343
|
+
} as JobEvent;
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
(before?.last_stage ?? null) !== (cur.last_stage ?? null) ||
|
|
347
|
+
(before?.last_message ?? null) !== (cur.last_message ?? null)
|
|
348
|
+
) {
|
|
349
|
+
if (cur.last_stage) {
|
|
350
|
+
yield {
|
|
351
|
+
kind: "stage",
|
|
352
|
+
session_id: cur.session_id ?? null,
|
|
353
|
+
job_id,
|
|
354
|
+
stage: cur.last_stage,
|
|
355
|
+
message: cur.last_message ?? null,
|
|
356
|
+
topic: cur.job_type,
|
|
357
|
+
entity_ref: cur.entity_ref ?? null,
|
|
358
|
+
created_by: cur.created_by ?? null,
|
|
359
|
+
} as JobEvent;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
prev = next;
|
|
364
|
+
} else {
|
|
365
|
+
first = false;
|
|
366
|
+
prev = new Map(snaps.map((s) => [s.job_id, s] as const));
|
|
367
|
+
}
|
|
368
|
+
await sleepOrAbort(POLL_INTERVAL_FALLBACK_MS, signal);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function sleepOrAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
373
|
+
return new Promise((resolve) => {
|
|
374
|
+
if (signal?.aborted) return resolve();
|
|
375
|
+
const t = setTimeout(() => {
|
|
376
|
+
signal?.removeEventListener("abort", onAbort);
|
|
377
|
+
resolve();
|
|
378
|
+
}, ms);
|
|
379
|
+
const onAbort = () => {
|
|
380
|
+
clearTimeout(t);
|
|
381
|
+
resolve();
|
|
382
|
+
};
|
|
383
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* re-export close-reason helpers and the polling constant so consumers
|
|
389
|
+
* can import a single "streaming utilities" surface from transport.ts.
|
|
390
|
+
*/
|
|
391
|
+
export type { CloseReason };
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// url helpers
|
|
2
|
+
//
|
|
3
|
+
// pure URL builders for resources. these don't make network requests —
|
|
4
|
+
// they just return the URL string for use in <audio src>, <img src>, etc.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* get the url for a media resource (audio file, image)
|
|
8
|
+
* use this in <audio src={...}>, <img src={...}>, or for direct downloads
|
|
9
|
+
*/
|
|
10
|
+
export function getMediaUrl(baseUrl: string, mediaId: string): string {
|
|
11
|
+
return `${baseUrl}/api/blobs/${mediaId}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* get the metadata endpoint url for a media resource
|
|
16
|
+
*/
|
|
17
|
+
export function getMediaMetadataUrl(baseUrl: string, mediaId: string): string {
|
|
18
|
+
return `${baseUrl}/api/blobs/${mediaId}/metadata`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* get the url for a playlist by id
|
|
23
|
+
* note: this returns the api endpoint url, not a streaming url
|
|
24
|
+
*/
|
|
25
|
+
export function getPlaylistUrl(baseUrl: string, playlistId: string): string {
|
|
26
|
+
return `${baseUrl}/api/music/playlists/${playlistId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* get the url for a fetch job by id
|
|
31
|
+
*/
|
|
32
|
+
export function getFetchJobUrl(baseUrl: string, jobId: string): string {
|
|
33
|
+
return `${baseUrl}/api/music/fetch/${jobId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// TODO: refactor getPlaylistEtag to use Transport
|
|
38
|
+
//
|
|
39
|
+
// this function bypasses Transport and uses fetch() directly because:
|
|
40
|
+
// 1. it needs response headers (etag), but Transport only returns {status, body}
|
|
41
|
+
// 2. it's a HEAD request, which Transport.request() could handle but headers can't
|
|
42
|
+
//
|
|
43
|
+
// to fix this properly:
|
|
44
|
+
// - extend TransportResponse to include headers: Record<string, string>
|
|
45
|
+
// - update HttpTransport.request() to capture and return headers
|
|
46
|
+
// - move this to client.music.getPlaylistEtag() (route already exists)
|
|
47
|
+
// - delete this function
|
|
48
|
+
//
|
|
49
|
+
// until then, this is HTTP-only and won't work with other transports.
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* get a playlist's etag (content hash for sync detection)
|
|
54
|
+
* uses HEAD request to /api/music/playlists/{id}/etag
|
|
55
|
+
* returns null if the playlist doesn't exist or request fails
|
|
56
|
+
*
|
|
57
|
+
* @deprecated use via Transport when headers support is added
|
|
58
|
+
*/
|
|
59
|
+
export async function getPlaylistEtag(
|
|
60
|
+
baseUrl: string,
|
|
61
|
+
playlistId: string,
|
|
62
|
+
): Promise<string | null> {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(`${baseUrl}/api/music/playlists/${playlistId}/etag`, {
|
|
65
|
+
method: "HEAD",
|
|
66
|
+
credentials: "include",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// etag is in the response headers
|
|
74
|
+
const etag = response.headers.get("etag");
|
|
75
|
+
return etag ? etag.replace(/"/g, "") : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/webauthn.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// webauthn helper utilities for base64/arraybuffer conversion
|
|
2
|
+
// these handle the conversion between server format (base64url strings)
|
|
3
|
+
// and browser webauthn api format (Uint8Array/ArrayBuffer)
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
// zod schemas for webauthn credential types (server format with camelCase)
|
|
8
|
+
export const AuthenticatorAttestationResponseSchema = z.object({
|
|
9
|
+
attestationObject: z.string(),
|
|
10
|
+
clientDataJSON: z.string(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const RegisterPublicKeyCredentialSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
rawId: z.string(),
|
|
16
|
+
type: z.literal("public-key"),
|
|
17
|
+
response: AuthenticatorAttestationResponseSchema,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const AuthenticatorAssertionResponseSchema = z.object({
|
|
21
|
+
authenticatorData: z.string(),
|
|
22
|
+
clientDataJSON: z.string(),
|
|
23
|
+
signature: z.string(),
|
|
24
|
+
userHandle: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const AuthenticationPublicKeyCredentialSchema = z.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
rawId: z.string(),
|
|
30
|
+
type: z.literal("public-key"),
|
|
31
|
+
response: AuthenticatorAssertionResponseSchema,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// types derived from schemas
|
|
35
|
+
export type RegisterPublicKeyCredential = z.infer<
|
|
36
|
+
typeof RegisterPublicKeyCredentialSchema
|
|
37
|
+
>;
|
|
38
|
+
export type AuthenticationPublicKeyCredential = z.infer<
|
|
39
|
+
typeof AuthenticationPublicKeyCredentialSchema
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* convert base64url string to Uint8Array
|
|
44
|
+
* handles both standard base64 and base64url encoding
|
|
45
|
+
*/
|
|
46
|
+
export function base64ToUint8Array(base64: string): Uint8Array {
|
|
47
|
+
// convert base64url to standard base64
|
|
48
|
+
const standardBase64 = base64.replace(/-/g, "+").replace(/_/g, "/");
|
|
49
|
+
const binaryString = atob(standardBase64);
|
|
50
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
51
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
52
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* convert Uint8Array to base64url string
|
|
59
|
+
* uses base64url encoding (no padding, - instead of +, _ instead of /)
|
|
60
|
+
*/
|
|
61
|
+
export function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
|
62
|
+
let binaryString = "";
|
|
63
|
+
for (let i = 0; i < uint8Array.length; i++) {
|
|
64
|
+
binaryString += String.fromCharCode(uint8Array[i]);
|
|
65
|
+
}
|
|
66
|
+
return btoa(binaryString)
|
|
67
|
+
.replace(/\+/g, "-")
|
|
68
|
+
.replace(/\//g, "_")
|
|
69
|
+
.replace(/=/g, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* convert ArrayBuffer to base64url string
|
|
74
|
+
* convenience wrapper around uint8ArrayToBase64
|
|
75
|
+
*/
|
|
76
|
+
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
77
|
+
return uint8ArrayToBase64(new Uint8Array(buffer));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* convert server's registration challenge response to browser-compatible format
|
|
82
|
+
* converts base64-encoded fields to Uint8Array for navigator.credentials.create()
|
|
83
|
+
*/
|
|
84
|
+
export function prepareRegistrationOptions(
|
|
85
|
+
serverResponse: any,
|
|
86
|
+
): CredentialCreationOptions {
|
|
87
|
+
return {
|
|
88
|
+
publicKey: {
|
|
89
|
+
...serverResponse.publicKey,
|
|
90
|
+
challenge: base64ToUint8Array(serverResponse.publicKey.challenge),
|
|
91
|
+
user: {
|
|
92
|
+
...serverResponse.publicKey.user,
|
|
93
|
+
id: base64ToUint8Array(serverResponse.publicKey.user.id),
|
|
94
|
+
},
|
|
95
|
+
excludeCredentials: serverResponse.publicKey.excludeCredentials?.map(
|
|
96
|
+
(cred: any) => ({
|
|
97
|
+
...cred,
|
|
98
|
+
id: base64ToUint8Array(cred.id),
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* convert server's login challenge response to browser-compatible format
|
|
107
|
+
* converts base64-encoded fields to Uint8Array for navigator.credentials.get()
|
|
108
|
+
*/
|
|
109
|
+
export function prepareAuthenticationOptions(
|
|
110
|
+
serverResponse: any,
|
|
111
|
+
): CredentialRequestOptions {
|
|
112
|
+
return {
|
|
113
|
+
publicKey: {
|
|
114
|
+
...serverResponse.publicKey,
|
|
115
|
+
challenge: base64ToUint8Array(serverResponse.publicKey.challenge),
|
|
116
|
+
allowCredentials: serverResponse.publicKey.allowCredentials?.map(
|
|
117
|
+
(cred: any) => ({
|
|
118
|
+
...cred,
|
|
119
|
+
id: base64ToUint8Array(cred.id),
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* convert browser registration credential to server-compatible format
|
|
128
|
+
* converts ArrayBuffer fields to base64url strings for register_finish endpoint
|
|
129
|
+
*/
|
|
130
|
+
export function serializeRegistrationCredential(
|
|
131
|
+
credential: PublicKeyCredential,
|
|
132
|
+
): RegisterPublicKeyCredential {
|
|
133
|
+
const response = credential.response as AuthenticatorAttestationResponse;
|
|
134
|
+
const serialized = {
|
|
135
|
+
id: credential.id,
|
|
136
|
+
rawId: arrayBufferToBase64(credential.rawId),
|
|
137
|
+
type: "public-key" as const,
|
|
138
|
+
response: {
|
|
139
|
+
attestationObject: arrayBufferToBase64(response.attestationObject),
|
|
140
|
+
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// validate with schema
|
|
145
|
+
return RegisterPublicKeyCredentialSchema.parse(serialized);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* convert browser authentication assertion to server-compatible format
|
|
150
|
+
* converts ArrayBuffer fields to base64url strings for login_finish endpoint
|
|
151
|
+
*/
|
|
152
|
+
export function serializeAuthenticationCredential(
|
|
153
|
+
credential: PublicKeyCredential,
|
|
154
|
+
): AuthenticationPublicKeyCredential {
|
|
155
|
+
const response = credential.response as AuthenticatorAssertionResponse;
|
|
156
|
+
const serialized = {
|
|
157
|
+
id: credential.id,
|
|
158
|
+
rawId: arrayBufferToBase64(credential.rawId),
|
|
159
|
+
type: "public-key" as const,
|
|
160
|
+
response: {
|
|
161
|
+
authenticatorData: arrayBufferToBase64(response.authenticatorData),
|
|
162
|
+
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
|
163
|
+
signature: arrayBufferToBase64(response.signature),
|
|
164
|
+
userHandle: response.userHandle
|
|
165
|
+
? arrayBufferToBase64(response.userHandle)
|
|
166
|
+
: undefined,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// validate with schema
|
|
171
|
+
return AuthenticationPublicKeyCredentialSchema.parse(serialized);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* high-level helper: perform complete registration flow
|
|
176
|
+
* calls register_start, prompts user for credential, calls register_finish
|
|
177
|
+
*/
|
|
178
|
+
export async function registerWithPasskey(
|
|
179
|
+
registerStartFn: () => Promise<any>,
|
|
180
|
+
registerFinishFn: (credential: any) => Promise<any>,
|
|
181
|
+
): Promise<any> {
|
|
182
|
+
// get challenge from server
|
|
183
|
+
const challengeResponse = await registerStartFn();
|
|
184
|
+
|
|
185
|
+
// prepare options for browser
|
|
186
|
+
const options = prepareRegistrationOptions(challengeResponse);
|
|
187
|
+
|
|
188
|
+
// prompt user to create credential
|
|
189
|
+
const credential = (await navigator.credentials.create(
|
|
190
|
+
options,
|
|
191
|
+
)) as PublicKeyCredential;
|
|
192
|
+
|
|
193
|
+
if (!credential) {
|
|
194
|
+
throw new Error("failed to create credential");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// serialize and send to server
|
|
198
|
+
const serialized = serializeRegistrationCredential(credential);
|
|
199
|
+
return await registerFinishFn(serialized);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* high-level helper: perform complete login flow
|
|
204
|
+
* calls login_start, prompts user for assertion, calls login_finish
|
|
205
|
+
*/
|
|
206
|
+
export async function loginWithPasskey(
|
|
207
|
+
loginStartFn: () => Promise<any>,
|
|
208
|
+
loginFinishFn: (assertion: any) => Promise<any>,
|
|
209
|
+
): Promise<any> {
|
|
210
|
+
// get challenge from server
|
|
211
|
+
const challengeResponse = await loginStartFn();
|
|
212
|
+
|
|
213
|
+
// prepare options for browser
|
|
214
|
+
const options = prepareAuthenticationOptions(challengeResponse);
|
|
215
|
+
|
|
216
|
+
// prompt user to authenticate
|
|
217
|
+
const assertion = (await navigator.credentials.get(
|
|
218
|
+
options,
|
|
219
|
+
)) as PublicKeyCredential;
|
|
220
|
+
|
|
221
|
+
if (!assertion) {
|
|
222
|
+
throw new Error("failed to get assertion");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// serialize and send to server
|
|
226
|
+
const serialized = serializeAuthenticationCredential(assertion);
|
|
227
|
+
return await loginFinishFn(serialized);
|
|
228
|
+
}
|