@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.
Files changed (45) hide show
  1. package/auth-test.html +651 -0
  2. package/package.json +42 -0
  3. package/src/AdminClient.ts +154 -0
  4. package/src/CharnelLocalTransport.ts +598 -0
  5. package/src/CharnelTransport.ts +616 -0
  6. package/src/FreqholeClient.ts +261 -0
  7. package/src/WasmTransport.ts +1077 -0
  8. package/src/automerge/IrohNetworkAdapter.ts +727 -0
  9. package/src/automerge/index.ts +14 -0
  10. package/src/codegen/admin_commands.ts +61 -0
  11. package/src/codegen/routes.ts +213 -0
  12. package/src/codegen/schema.ts +6721 -0
  13. package/src/domains/admin.ts +97 -0
  14. package/src/domains/app.ts +98 -0
  15. package/src/domains/auth.ts +191 -0
  16. package/src/domains/favorites.types.ts +52 -0
  17. package/src/domains/jobs.ts +48 -0
  18. package/src/domains/music.ts +1935 -0
  19. package/src/domains/types.ts +19 -0
  20. package/src/domains/upload.ts +214 -0
  21. package/src/index.ts +48 -0
  22. package/src/permissions.ts +468 -0
  23. package/src/playlistz/convert.ts +267 -0
  24. package/src/playlistz/index.ts +6 -0
  25. package/src/playlistz/mutations.ts +181 -0
  26. package/src/playlistz/protocol.ts +222 -0
  27. package/src/playlistz/schema.ts +138 -0
  28. package/src/playlistz/shareLink.ts +76 -0
  29. package/src/storage/blobs.ts +272 -0
  30. package/src/storage/identity.ts +190 -0
  31. package/src/storage/index.ts +5 -0
  32. package/src/storage/webLocks.ts +92 -0
  33. package/src/test/automerge.ts +796 -0
  34. package/src/test/coverage.ts +105 -0
  35. package/src/test/fixtures.ts +460 -0
  36. package/src/test/integration.ts +320 -0
  37. package/src/test/playlistz.ts +631 -0
  38. package/src/test/stateful.ts +561 -0
  39. package/src/test/storage.ts +443 -0
  40. package/src/test.ts +75 -0
  41. package/src/transport.ts +391 -0
  42. package/src/utils.ts +79 -0
  43. package/src/webauthn.ts +228 -0
  44. package/tsconfig.json +22 -0
  45. package/vite.config.js +9 -0
@@ -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
+ }
@@ -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
+ }