@audiofool/moondog 0.1.0

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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,791 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ createHash,
4
+ randomBytes,
5
+ timingSafeEqual,
6
+ } from "node:crypto";
7
+ import { createServer } from "node:http";
8
+
9
+ const SPOTIFY_AUTHORIZE_URL = "https://accounts.spotify.com/authorize";
10
+ const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
11
+ const ACCESS_TOKEN_DEFAULT_LIFETIME_SECONDS = 60 * 60;
12
+ const ACCESS_TOKEN_REFRESH_WINDOW_MS = 30_000;
13
+
14
+ export const SPOTIFY_DEFAULT_REDIRECT_URI =
15
+ "http://127.0.0.1:43821/callback";
16
+
17
+ export const SPOTIFY_DEFAULT_SCOPES = Object.freeze([
18
+ "user-read-playback-state",
19
+ "user-read-currently-playing",
20
+ "user-read-private",
21
+ "user-modify-playback-state",
22
+ "playlist-read-private",
23
+ "playlist-modify-private",
24
+ "user-library-read",
25
+ "user-library-modify",
26
+ "user-read-recently-played",
27
+ ]);
28
+
29
+ export class SpotifyAuthenticationError extends Error {
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = "SpotifyAuthenticationError";
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ function safeError(code, message) {
38
+ return new SpotifyAuthenticationError(code, message);
39
+ }
40
+
41
+ function abortError() {
42
+ return safeError(
43
+ "spotify_auth_aborted",
44
+ "Spotify authentication was cancelled.",
45
+ );
46
+ }
47
+
48
+ function assertNotAborted(signal) {
49
+ if (signal?.aborted) throw abortError();
50
+ }
51
+
52
+ function assertNonEmptyString(value, code, message) {
53
+ if (typeof value !== "string" || value.trim().length === 0) {
54
+ throw safeError(code, message);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function normalizeScopes(scopes) {
60
+ const values = Array.isArray(scopes)
61
+ ? scopes
62
+ : typeof scopes === "string"
63
+ ? scopes.split(/\s+/u).filter(Boolean)
64
+ : [];
65
+ if (values.length === 0) {
66
+ throw safeError(
67
+ "spotify_scope_invalid",
68
+ "Spotify authentication requires at least one scope.",
69
+ );
70
+ }
71
+ const normalized = [];
72
+ for (const scope of values) {
73
+ if (typeof scope !== "string" || scope.trim().length === 0) {
74
+ throw safeError(
75
+ "spotify_scope_invalid",
76
+ "Spotify authentication received an invalid scope.",
77
+ );
78
+ }
79
+ const value = scope.trim();
80
+ if (!normalized.includes(value)) normalized.push(value);
81
+ }
82
+ return normalized.sort().join(" ");
83
+ }
84
+
85
+ function normalizeRedirectUri(value) {
86
+ let redirect;
87
+ try {
88
+ redirect = new URL(value);
89
+ } catch {
90
+ throw safeError(
91
+ "spotify_redirect_uri_invalid",
92
+ "Spotify authentication requires a valid loopback callback URL.",
93
+ );
94
+ }
95
+ if (
96
+ redirect.protocol !== "http:" ||
97
+ redirect.hostname !== "127.0.0.1" ||
98
+ redirect.port.length === 0 ||
99
+ redirect.pathname !== "/callback" ||
100
+ redirect.username.length > 0 ||
101
+ redirect.password.length > 0 ||
102
+ redirect.search.length > 0 ||
103
+ redirect.hash.length > 0
104
+ ) {
105
+ throw safeError(
106
+ "spotify_redirect_uri_invalid",
107
+ "Spotify authentication requires an exact 127.0.0.1 loopback callback URL.",
108
+ );
109
+ }
110
+ return redirect;
111
+ }
112
+
113
+ function sameOpaqueValue(actual, expected) {
114
+ if (typeof actual !== "string" || typeof expected !== "string") return false;
115
+ const actualBytes = Buffer.from(actual);
116
+ const expectedBytes = Buffer.from(expected);
117
+ return (
118
+ actualBytes.length === expectedBytes.length &&
119
+ timingSafeEqual(actualBytes, expectedBytes)
120
+ );
121
+ }
122
+
123
+ function secondsFromPayload(payload, names) {
124
+ for (const name of names) {
125
+ if (payload[name] === undefined) continue;
126
+ if (!Number.isFinite(payload[name]) || payload[name] <= 0) {
127
+ throw safeError(
128
+ "spotify_token_response_invalid",
129
+ "Spotify returned invalid token expiry metadata.",
130
+ );
131
+ }
132
+ return payload[name];
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function scopesFromPayload(payload, fallbackScopes) {
138
+ if (payload.scope === undefined) return fallbackScopes;
139
+ if (typeof payload.scope !== "string") {
140
+ throw safeError(
141
+ "spotify_token_response_invalid",
142
+ "Spotify returned invalid token scope metadata.",
143
+ );
144
+ }
145
+ const values = payload.scope.split(/\s+/u).filter(Boolean);
146
+ return values.length > 0 ? normalizeScopes(values) : fallbackScopes;
147
+ }
148
+
149
+ function addCalendarMonths(timestamp, count) {
150
+ const value = new Date(timestamp);
151
+ const day = value.getUTCDate();
152
+ value.setUTCDate(1);
153
+ value.setUTCMonth(value.getUTCMonth() + count);
154
+ const nextMonth = new Date(value.getTime());
155
+ nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
156
+ nextMonth.setUTCDate(0);
157
+ value.setUTCDate(Math.min(day, nextMonth.getUTCDate()));
158
+ return value.getTime();
159
+ }
160
+
161
+ function credentialFromTokenPayload(
162
+ payload,
163
+ {
164
+ fallbackRefreshToken,
165
+ fallbackRefreshExpiresAt = null,
166
+ fallbackScopes = [],
167
+ now,
168
+ },
169
+ ) {
170
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
171
+ throw safeError(
172
+ "spotify_token_response_invalid",
173
+ "Spotify returned an invalid token response.",
174
+ );
175
+ }
176
+ const accessToken = assertNonEmptyString(
177
+ payload.access_token,
178
+ "spotify_token_response_invalid",
179
+ "Spotify returned an invalid access token.",
180
+ );
181
+ const refreshToken = payload.refresh_token ?? fallbackRefreshToken;
182
+ assertNonEmptyString(
183
+ refreshToken,
184
+ "spotify_token_response_invalid",
185
+ "Spotify returned an invalid refresh token.",
186
+ );
187
+ if (
188
+ payload.token_type !== undefined &&
189
+ (typeof payload.token_type !== "string" ||
190
+ payload.token_type.toLowerCase() !== "bearer")
191
+ ) {
192
+ throw safeError(
193
+ "spotify_token_response_invalid",
194
+ "Spotify returned an unsupported token type.",
195
+ );
196
+ }
197
+
198
+ const issuedAt = now();
199
+ if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0) {
200
+ throw safeError(
201
+ "spotify_clock_invalid",
202
+ "Spotify authentication could not read the current time.",
203
+ );
204
+ }
205
+ const accessLifetime =
206
+ secondsFromPayload(payload, ["expires_in"]) ??
207
+ ACCESS_TOKEN_DEFAULT_LIFETIME_SECONDS;
208
+ const refreshLifetime = secondsFromPayload(payload, [
209
+ "refresh_expires_in",
210
+ "refresh_token_expires_in",
211
+ ]);
212
+
213
+ return {
214
+ type: "oauth",
215
+ accessToken,
216
+ refreshToken,
217
+ accessExpiresAt: issuedAt + Math.round(accessLifetime * 1_000),
218
+ refreshExpiresAt:
219
+ refreshLifetime === undefined
220
+ ? (fallbackRefreshExpiresAt ?? addCalendarMonths(issuedAt, 6))
221
+ : issuedAt + Math.round(refreshLifetime * 1_000),
222
+ tokenType: "Bearer",
223
+ scope: scopesFromPayload(payload, fallbackScopes),
224
+ };
225
+ }
226
+
227
+ function validateStoredCredential(value) {
228
+ if (
229
+ !value ||
230
+ typeof value !== "object" ||
231
+ Array.isArray(value) ||
232
+ value.type !== "oauth" ||
233
+ typeof value.accessToken !== "string" ||
234
+ value.accessToken.length === 0 ||
235
+ typeof value.refreshToken !== "string" ||
236
+ value.refreshToken.length === 0 ||
237
+ !Number.isSafeInteger(value.accessExpiresAt) ||
238
+ value.accessExpiresAt <= 0 ||
239
+ !Number.isSafeInteger(value.refreshExpiresAt) ||
240
+ value.refreshExpiresAt <= 0 ||
241
+ value.tokenType !== "Bearer" ||
242
+ typeof value.scope !== "string" ||
243
+ value.scope.length === 0
244
+ ) {
245
+ throw safeError(
246
+ "spotify_credential_invalid",
247
+ "The stored Spotify credential is invalid.",
248
+ );
249
+ }
250
+ return structuredClone(value);
251
+ }
252
+
253
+ function publicStatus(credential) {
254
+ if (!credential) {
255
+ return {
256
+ provider: "spotify",
257
+ state: "not_configured",
258
+ type: null,
259
+ accessExpiresAt: null,
260
+ refreshExpiresAt: null,
261
+ };
262
+ }
263
+ return {
264
+ provider: "spotify",
265
+ state: "stored",
266
+ type: "oauth",
267
+ accessExpiresAt: credential.accessExpiresAt,
268
+ refreshExpiresAt: credential.refreshExpiresAt,
269
+ scopes: credential.scope,
270
+ };
271
+ }
272
+
273
+ async function requestToken(
274
+ body,
275
+ {
276
+ fetchImpl,
277
+ signal,
278
+ failureCode,
279
+ fallbackRefreshToken,
280
+ fallbackRefreshExpiresAt,
281
+ fallbackScopes,
282
+ now,
283
+ },
284
+ ) {
285
+ assertNotAborted(signal);
286
+ let response;
287
+ try {
288
+ response = await fetchImpl(SPOTIFY_TOKEN_URL, {
289
+ method: "POST",
290
+ headers: {
291
+ "content-type": "application/x-www-form-urlencoded",
292
+ },
293
+ body,
294
+ signal,
295
+ });
296
+ } catch {
297
+ if (signal?.aborted) throw abortError();
298
+ throw safeError(failureCode, "Spotify token request failed.");
299
+ }
300
+ if (!response?.ok) {
301
+ throw safeError(failureCode, "Spotify token request failed.");
302
+ }
303
+ let payload;
304
+ try {
305
+ payload = await response.json();
306
+ } catch {
307
+ throw safeError(
308
+ "spotify_token_response_invalid",
309
+ "Spotify returned an invalid token response.",
310
+ );
311
+ }
312
+ return credentialFromTokenPayload(payload, {
313
+ fallbackRefreshToken,
314
+ fallbackRefreshExpiresAt,
315
+ fallbackScopes,
316
+ now,
317
+ });
318
+ }
319
+
320
+ export function createSpotifyPkcePair() {
321
+ const verifier = randomBytes(32).toString("base64url");
322
+ return {
323
+ verifier,
324
+ challenge: createHash("sha256").update(verifier).digest("base64url"),
325
+ };
326
+ }
327
+
328
+ export function createSpotifyAuthorizationUrl({
329
+ clientId,
330
+ redirectUri = SPOTIFY_DEFAULT_REDIRECT_URI,
331
+ scopes = SPOTIFY_DEFAULT_SCOPES,
332
+ state,
333
+ codeChallenge,
334
+ showDialog = false,
335
+ }) {
336
+ assertNonEmptyString(
337
+ clientId,
338
+ "spotify_client_id_invalid",
339
+ "Spotify authentication requires a client ID.",
340
+ );
341
+ assertNonEmptyString(
342
+ state,
343
+ "spotify_state_invalid",
344
+ "Spotify authentication requires an OAuth state value.",
345
+ );
346
+ assertNonEmptyString(
347
+ codeChallenge,
348
+ "spotify_pkce_invalid",
349
+ "Spotify authentication requires a PKCE challenge.",
350
+ );
351
+ const redirect = normalizeRedirectUri(redirectUri);
352
+ const requestedScopes = normalizeScopes(scopes);
353
+ const url = new URL(SPOTIFY_AUTHORIZE_URL);
354
+ url.searchParams.set("client_id", clientId);
355
+ url.searchParams.set("response_type", "code");
356
+ url.searchParams.set("redirect_uri", redirect.href);
357
+ url.searchParams.set("scope", requestedScopes);
358
+ url.searchParams.set("state", state);
359
+ url.searchParams.set("code_challenge_method", "S256");
360
+ url.searchParams.set("code_challenge", codeChallenge);
361
+ if (showDialog) url.searchParams.set("show_dialog", "true");
362
+ return url.href;
363
+ }
364
+
365
+ export async function exchangeSpotifyAuthorizationCode({
366
+ clientId,
367
+ code,
368
+ codeVerifier,
369
+ redirectUri = SPOTIFY_DEFAULT_REDIRECT_URI,
370
+ scopes = SPOTIFY_DEFAULT_SCOPES,
371
+ fetchImpl = globalThis.fetch,
372
+ signal,
373
+ now = Date.now,
374
+ }) {
375
+ assertNonEmptyString(
376
+ clientId,
377
+ "spotify_client_id_invalid",
378
+ "Spotify authentication requires a client ID.",
379
+ );
380
+ assertNonEmptyString(
381
+ code,
382
+ "spotify_authorization_code_invalid",
383
+ "Spotify authentication requires an authorization code.",
384
+ );
385
+ assertNonEmptyString(
386
+ codeVerifier,
387
+ "spotify_pkce_invalid",
388
+ "Spotify authentication requires a PKCE verifier.",
389
+ );
390
+ const redirect = normalizeRedirectUri(redirectUri);
391
+ const requestedScopes = normalizeScopes(scopes);
392
+ if (typeof fetchImpl !== "function") {
393
+ throw safeError(
394
+ "spotify_fetch_unavailable",
395
+ "Spotify authentication requires an HTTP client.",
396
+ );
397
+ }
398
+
399
+ return requestToken(
400
+ new URLSearchParams({
401
+ client_id: clientId,
402
+ code,
403
+ code_verifier: codeVerifier,
404
+ grant_type: "authorization_code",
405
+ redirect_uri: redirect.href,
406
+ }),
407
+ {
408
+ fetchImpl,
409
+ signal,
410
+ failureCode: "spotify_token_exchange_failed",
411
+ fallbackRefreshToken: undefined,
412
+ fallbackRefreshExpiresAt: null,
413
+ fallbackScopes: requestedScopes,
414
+ now,
415
+ },
416
+ );
417
+ }
418
+
419
+ export async function refreshSpotifyAccessToken({
420
+ clientId,
421
+ refreshToken,
422
+ refreshExpiresAt = null,
423
+ scopes = SPOTIFY_DEFAULT_SCOPES,
424
+ fetchImpl = globalThis.fetch,
425
+ signal,
426
+ now = Date.now,
427
+ }) {
428
+ assertNonEmptyString(
429
+ clientId,
430
+ "spotify_client_id_invalid",
431
+ "Spotify authentication requires a client ID.",
432
+ );
433
+ assertNonEmptyString(
434
+ refreshToken,
435
+ "spotify_refresh_token_invalid",
436
+ "Spotify authentication requires a refresh token.",
437
+ );
438
+ const requestedScopes = normalizeScopes(scopes);
439
+ if (typeof fetchImpl !== "function") {
440
+ throw safeError(
441
+ "spotify_fetch_unavailable",
442
+ "Spotify authentication requires an HTTP client.",
443
+ );
444
+ }
445
+ assertNotAborted(signal);
446
+ if (
447
+ refreshExpiresAt !== null &&
448
+ (!Number.isSafeInteger(refreshExpiresAt) || refreshExpiresAt <= 0)
449
+ ) {
450
+ throw safeError(
451
+ "spotify_refresh_expiry_invalid",
452
+ "Spotify authentication received invalid refresh expiry metadata.",
453
+ );
454
+ }
455
+ if (refreshExpiresAt !== null && refreshExpiresAt <= now()) {
456
+ throw safeError(
457
+ "spotify_refresh_expired",
458
+ "Spotify authorization has expired and must be renewed.",
459
+ );
460
+ }
461
+
462
+ return requestToken(
463
+ new URLSearchParams({
464
+ client_id: clientId,
465
+ grant_type: "refresh_token",
466
+ refresh_token: refreshToken,
467
+ }),
468
+ {
469
+ fetchImpl,
470
+ signal,
471
+ failureCode: "spotify_token_refresh_failed",
472
+ fallbackRefreshToken: refreshToken,
473
+ fallbackRefreshExpiresAt: refreshExpiresAt,
474
+ fallbackScopes: requestedScopes,
475
+ now,
476
+ },
477
+ );
478
+ }
479
+
480
+ function openInDefaultBrowser(url) {
481
+ const [command, args] =
482
+ process.platform === "darwin"
483
+ ? ["open", [url]]
484
+ : process.platform === "win32"
485
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
486
+ : ["xdg-open", [url]];
487
+ return new Promise((resolve, reject) => {
488
+ const child = spawn(command, args, {
489
+ detached: true,
490
+ stdio: "ignore",
491
+ windowsHide: true,
492
+ });
493
+ child.once("error", reject);
494
+ child.once("spawn", () => {
495
+ child.unref();
496
+ resolve();
497
+ });
498
+ });
499
+ }
500
+
501
+ async function createCallbackListener({ redirectUri, expectedState, signal }) {
502
+ const redirect = normalizeRedirectUri(redirectUri);
503
+ let resolveCode;
504
+ let rejectCode;
505
+ let settled = false;
506
+ let closing;
507
+ const authorizationCode = new Promise((resolve, reject) => {
508
+ resolveCode = resolve;
509
+ rejectCode = reject;
510
+ });
511
+ authorizationCode.catch(() => {});
512
+
513
+ const server = createServer((request, response) => {
514
+ response.setHeader("connection", "close");
515
+ let callback;
516
+ try {
517
+ callback = new URL(request.url ?? "/", redirect);
518
+ } catch {
519
+ response.writeHead(400).end("Invalid Spotify callback.");
520
+ return;
521
+ }
522
+ if (request.method !== "GET" || callback.pathname !== redirect.pathname) {
523
+ response.writeHead(404).end("Not found.");
524
+ return;
525
+ }
526
+
527
+ const state = callback.searchParams.get("state");
528
+ if (!sameOpaqueValue(state, expectedState)) {
529
+ response.writeHead(400).end("Spotify callback rejected.");
530
+ finish(
531
+ safeError(
532
+ "spotify_oauth_state_mismatch",
533
+ "Spotify authentication rejected an invalid OAuth state.",
534
+ ),
535
+ );
536
+ return;
537
+ }
538
+ if (callback.searchParams.has("error")) {
539
+ response.writeHead(400).end("Spotify authorization was not completed.");
540
+ finish(
541
+ safeError(
542
+ "spotify_authorization_denied",
543
+ "Spotify authorization was not completed.",
544
+ ),
545
+ );
546
+ return;
547
+ }
548
+ const code = callback.searchParams.get("code");
549
+ if (!code) {
550
+ response.writeHead(400).end("Spotify callback rejected.");
551
+ finish(
552
+ safeError(
553
+ "spotify_authorization_code_missing",
554
+ "Spotify authentication did not receive an authorization code.",
555
+ ),
556
+ );
557
+ return;
558
+ }
559
+ response
560
+ .writeHead(200, { "content-type": "text/plain; charset=utf-8" })
561
+ .end("Spotify authorization complete. You can close this window.");
562
+ finish(undefined, code);
563
+ });
564
+
565
+ const close = () => {
566
+ if (closing) return closing;
567
+ signal?.removeEventListener("abort", onAbort);
568
+ if (!server.listening) return Promise.resolve();
569
+ closing = new Promise((resolve) => {
570
+ server.close(() => resolve());
571
+ });
572
+ return closing;
573
+ };
574
+
575
+ const finish = (error, code) => {
576
+ if (settled) return;
577
+ settled = true;
578
+ if (error) rejectCode(error);
579
+ else resolveCode(code);
580
+ void close();
581
+ };
582
+
583
+ const onAbort = () => finish(abortError());
584
+ signal?.addEventListener("abort", onAbort, { once: true });
585
+ server.on("error", () => {
586
+ finish(
587
+ safeError(
588
+ "spotify_callback_listener_failed",
589
+ "Spotify authentication could not receive the browser callback.",
590
+ ),
591
+ );
592
+ });
593
+
594
+ try {
595
+ await new Promise((resolve, reject) => {
596
+ const onStartupError = () => {
597
+ server.off("listening", onListening);
598
+ reject(
599
+ safeError(
600
+ "spotify_callback_listener_failed",
601
+ "Spotify authentication could not start its loopback callback.",
602
+ ),
603
+ );
604
+ };
605
+ const onListening = () => {
606
+ server.off("error", onStartupError);
607
+ resolve();
608
+ };
609
+ server.once("error", onStartupError);
610
+ server.once("listening", onListening);
611
+ server.listen(Number(redirect.port), "127.0.0.1");
612
+ });
613
+ } catch (error) {
614
+ await close();
615
+ throw error;
616
+ }
617
+ if (signal?.aborted) {
618
+ await close();
619
+ throw abortError();
620
+ }
621
+ return { authorizationCode, close };
622
+ }
623
+
624
+ function assertCredentialStore(store) {
625
+ if (
626
+ !store ||
627
+ typeof store.read !== "function" ||
628
+ typeof store.write !== "function" ||
629
+ typeof store.delete !== "function"
630
+ ) {
631
+ throw safeError(
632
+ "spotify_credential_store_invalid",
633
+ "Spotify authentication requires a credential store.",
634
+ );
635
+ }
636
+ }
637
+
638
+ export function createSpotifyAuthentication({
639
+ clientId,
640
+ credentialStore,
641
+ openBrowser = openInDefaultBrowser,
642
+ fetchImpl = globalThis.fetch,
643
+ redirectUri = SPOTIFY_DEFAULT_REDIRECT_URI,
644
+ scopes = SPOTIFY_DEFAULT_SCOPES,
645
+ now = Date.now,
646
+ } = {}) {
647
+ assertNonEmptyString(
648
+ clientId,
649
+ "spotify_client_id_invalid",
650
+ "Spotify authentication requires a client ID.",
651
+ );
652
+ assertCredentialStore(credentialStore);
653
+ if (typeof openBrowser !== "function") {
654
+ throw safeError(
655
+ "spotify_browser_opener_invalid",
656
+ "Spotify authentication requires a browser opener.",
657
+ );
658
+ }
659
+ if (typeof fetchImpl !== "function") {
660
+ throw safeError(
661
+ "spotify_fetch_unavailable",
662
+ "Spotify authentication requires an HTTP client.",
663
+ );
664
+ }
665
+ const redirect = normalizeRedirectUri(redirectUri);
666
+ const requestedScopes = normalizeScopes(scopes);
667
+
668
+ const readCredential = async (options = {}) => {
669
+ let value;
670
+ try {
671
+ value = await credentialStore.read(options);
672
+ } catch {
673
+ throw safeError(
674
+ "spotify_credential_store_failed",
675
+ "Spotify credential storage could not be read.",
676
+ );
677
+ }
678
+ return value === undefined ? undefined : validateStoredCredential(value);
679
+ };
680
+
681
+ const writeCredential = async (credential, options = {}) => {
682
+ try {
683
+ await credentialStore.write(structuredClone(credential), options);
684
+ } catch {
685
+ throw safeError(
686
+ "spotify_credential_store_failed",
687
+ "Spotify credential storage could not be updated.",
688
+ );
689
+ }
690
+ };
691
+
692
+ return {
693
+ async login({ signal, onAuthorizationUrl } = {}) {
694
+ assertNotAborted(signal);
695
+ if (
696
+ onAuthorizationUrl !== undefined &&
697
+ typeof onAuthorizationUrl !== "function"
698
+ ) {
699
+ throw safeError(
700
+ "spotify_authorization_callback_invalid",
701
+ "Spotify authentication received an invalid URL callback.",
702
+ );
703
+ }
704
+ const { verifier, challenge } = createSpotifyPkcePair();
705
+ const state = randomBytes(32).toString("base64url");
706
+ const listener = await createCallbackListener({
707
+ redirectUri: redirect.href,
708
+ expectedState: state,
709
+ signal,
710
+ });
711
+ const authorizationUrl = createSpotifyAuthorizationUrl({
712
+ clientId,
713
+ redirectUri: redirect.href,
714
+ scopes: requestedScopes,
715
+ state,
716
+ codeChallenge: challenge,
717
+ });
718
+
719
+ try {
720
+ try {
721
+ await onAuthorizationUrl?.(authorizationUrl);
722
+ await openBrowser(authorizationUrl);
723
+ } catch {
724
+ throw safeError(
725
+ "spotify_browser_open_failed",
726
+ "Spotify authentication could not open the authorization page.",
727
+ );
728
+ }
729
+ const code = await listener.authorizationCode;
730
+ assertNotAborted(signal);
731
+ const credential = await exchangeSpotifyAuthorizationCode({
732
+ clientId,
733
+ code,
734
+ codeVerifier: verifier,
735
+ redirectUri: redirect.href,
736
+ scopes: requestedScopes,
737
+ fetchImpl,
738
+ signal,
739
+ now,
740
+ });
741
+ await writeCredential(credential, { signal });
742
+ return publicStatus(credential);
743
+ } finally {
744
+ await listener.close();
745
+ }
746
+ },
747
+
748
+ async status({ signal } = {}) {
749
+ assertNotAborted(signal);
750
+ return publicStatus(await readCredential({ signal }));
751
+ },
752
+
753
+ async getAccessToken({ signal } = {}) {
754
+ assertNotAborted(signal);
755
+ const credential = await readCredential({ signal });
756
+ if (!credential) {
757
+ throw safeError(
758
+ "spotify_auth_not_configured",
759
+ "Spotify is not authenticated.",
760
+ );
761
+ }
762
+ if (credential.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS) {
763
+ return credential.accessToken;
764
+ }
765
+ const refreshed = await refreshSpotifyAccessToken({
766
+ clientId,
767
+ refreshToken: credential.refreshToken,
768
+ refreshExpiresAt: credential.refreshExpiresAt,
769
+ scopes: credential.scope,
770
+ fetchImpl,
771
+ signal,
772
+ now,
773
+ });
774
+ await writeCredential(refreshed, { signal });
775
+ return refreshed.accessToken;
776
+ },
777
+
778
+ async logout({ signal } = {}) {
779
+ assertNotAborted(signal);
780
+ try {
781
+ await credentialStore.delete({ signal });
782
+ } catch {
783
+ throw safeError(
784
+ "spotify_credential_store_failed",
785
+ "Spotify credential storage could not be updated.",
786
+ );
787
+ }
788
+ return publicStatus(undefined);
789
+ },
790
+ };
791
+ }