@audiofool/moondog 0.2.1 → 0.2.2
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/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Moondog</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center">Version 0.2.
|
|
7
|
+
<p align="center">Version 0.2.2</p>
|
|
8
8
|
|
|
9
9
|
<p align="center"><strong>A listening room that shows its work.</strong></p>
|
|
10
10
|
|
|
@@ -33,7 +33,7 @@ npm ci
|
|
|
33
33
|
npm start
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
[@audiofool/moondog](https://www.npmjs.com/package/@audiofool/moondog) 0.2.
|
|
36
|
+
[@audiofool/moondog](https://www.npmjs.com/package/@audiofool/moondog) 0.2.1 is the published package; `npm install --global @audiofool/moondog` installs it.
|
|
37
37
|
To see the agent work through fictional data, with no sign-in at all, run `npm run demo` from the checkout.
|
|
38
38
|
|
|
39
39
|
The source version supports `moondog update` and `/update`, with stable (`latest`) and preview (`beta`) channels.
|
package/package.json
CHANGED
|
@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import {
|
|
3
3
|
createHash,
|
|
4
4
|
randomBytes,
|
|
5
|
-
randomUUID,
|
|
6
5
|
timingSafeEqual,
|
|
7
6
|
} from "node:crypto";
|
|
8
7
|
import { createServer } from "node:http";
|
|
@@ -245,6 +244,7 @@ function validateStoredCredential(value) {
|
|
|
245
244
|
value.tokenType !== "Bearer" ||
|
|
246
245
|
typeof value.scope !== "string" ||
|
|
247
246
|
value.scope.length === 0 ||
|
|
247
|
+
// Versions up to 0.2.1 left this marker after a failed refresh; it is read and ignored.
|
|
248
248
|
(value.refreshAttempt !== undefined && (
|
|
249
249
|
!value.refreshAttempt ||
|
|
250
250
|
!/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/u.test(value.refreshAttempt.id) ||
|
|
@@ -285,6 +285,7 @@ async function requestToken(
|
|
|
285
285
|
fetchImpl,
|
|
286
286
|
signal,
|
|
287
287
|
failureCode,
|
|
288
|
+
rejectedGrant,
|
|
288
289
|
fallbackRefreshToken,
|
|
289
290
|
fallbackRefreshExpiresAt,
|
|
290
291
|
fallbackScopes,
|
|
@@ -307,6 +308,14 @@ async function requestToken(
|
|
|
307
308
|
throw safeError(failureCode, "Spotify token request failed.");
|
|
308
309
|
}
|
|
309
310
|
if (!response?.ok) {
|
|
311
|
+
// Only invalid_grant means the grant itself is gone; outages and rate limits are worth a retry.
|
|
312
|
+
if (rejectedGrant) {
|
|
313
|
+
let reason;
|
|
314
|
+
try {
|
|
315
|
+
reason = (await response.json())?.error;
|
|
316
|
+
} catch {}
|
|
317
|
+
if (reason === "invalid_grant") throw rejectedGrant();
|
|
318
|
+
}
|
|
310
319
|
throw safeError(failureCode, "Spotify token request failed.");
|
|
311
320
|
}
|
|
312
321
|
let payload;
|
|
@@ -478,6 +487,10 @@ export async function refreshSpotifyAccessToken({
|
|
|
478
487
|
fetchImpl,
|
|
479
488
|
signal,
|
|
480
489
|
failureCode: "spotify_token_refresh_failed",
|
|
490
|
+
rejectedGrant: () => safeError(
|
|
491
|
+
"spotify_refresh_rejected",
|
|
492
|
+
"Spotify no longer accepts this sign-in. Run /spotify login to connect again.",
|
|
493
|
+
),
|
|
481
494
|
fallbackRefreshToken: refreshToken,
|
|
482
495
|
fallbackRefreshExpiresAt: refreshExpiresAt,
|
|
483
496
|
fallbackScopes: requestedScopes,
|
|
@@ -731,7 +744,7 @@ export function createSpotifyAuthentication({
|
|
|
731
744
|
const credential = await readCredential({ signal });
|
|
732
745
|
if (!credential) throw safeError("spotify_auth_not_configured", "Spotify is not authenticated.");
|
|
733
746
|
const usable = (value) => value.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS;
|
|
734
|
-
if (
|
|
747
|
+
if (usable(credential) &&
|
|
735
748
|
((rejectedAccessToken && rejectedAccessToken !== credential.accessToken) || proactive)) {
|
|
736
749
|
return credential.accessToken;
|
|
737
750
|
}
|
|
@@ -743,32 +756,24 @@ export function createSpotifyAuthentication({
|
|
|
743
756
|
const task = { controller: new AbortController(), waiters: 0, generation, started: false, credential, rejectedAccessToken };
|
|
744
757
|
const sharedSignal = task.controller.signal;
|
|
745
758
|
const timer = setTimeout(() => task.controller.abort(safeError(
|
|
746
|
-
"spotify_refresh_timeout", "Spotify refresh timed out
|
|
759
|
+
"spotify_refresh_timeout", "Spotify refresh timed out; the next request tries again.",
|
|
747
760
|
)), refreshTimeoutMs);
|
|
748
761
|
task.promise = mutateCredential(async () => {
|
|
749
762
|
try {
|
|
750
|
-
const stored = await credentialStore.modify(async (value
|
|
763
|
+
const stored = await credentialStore.modify(async (value) => {
|
|
751
764
|
assertNotAborted(sharedSignal);
|
|
752
765
|
if (!value) throw safeError("spotify_auth_not_configured", "Spotify is not authenticated.");
|
|
753
766
|
const current = validateStoredCredential(value);
|
|
754
|
-
// This check precedes *all* token reuse. A durable marker left
|
|
755
|
-
// after crash/timeout fences every process until explicit login.
|
|
756
|
-
if (current.refreshAttempt) {
|
|
757
|
-
throw safeError("spotify_refresh_uncertain", "A previous Spotify refresh was interrupted. Run /spotify login to authorize again; no refresh was retried.");
|
|
758
|
-
}
|
|
759
767
|
const mayReuse = rejectedAccessToken
|
|
760
768
|
? current.accessToken !== rejectedAccessToken
|
|
761
769
|
: proactive || !credentialMatches(current, credential);
|
|
762
770
|
if (usable(current) && mayReuse) return undefined;
|
|
763
771
|
if (generation !== task.generation) throw safeError("spotify_auth_changed", "Spotify authentication changed during refresh.");
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
//
|
|
768
|
-
// because the last caller stops waiting: drain under the deadline.
|
|
772
|
+
// An exchange that failed, timed out or died with its process leaves the stored
|
|
773
|
+
// credential as it was, and the next request tries it again. If Spotify did rotate
|
|
774
|
+
// the refresh token, it answers invalid_grant, and only that asks for a new sign-in.
|
|
775
|
+
// Do not abandon rotation merely because the last caller stops waiting: drain under the deadline.
|
|
769
776
|
task.started = true;
|
|
770
|
-
await transaction.checkpoint({ ...current, refreshAttempt: { id: randomUUID(), startedAt: now() } });
|
|
771
|
-
assertNotAborted(sharedSignal);
|
|
772
777
|
const refreshed = await waitForExchange(refreshSpotifyAccessToken({
|
|
773
778
|
clientId, refreshToken: current.refreshToken,
|
|
774
779
|
refreshExpiresAt: current.refreshExpiresAt, scopes: current.scope,
|
|
@@ -782,7 +787,7 @@ export function createSpotifyAuthentication({
|
|
|
782
787
|
} catch (error) {
|
|
783
788
|
if (sharedSignal.aborted) throw sharedSignal.reason ?? abortError();
|
|
784
789
|
if (error instanceof SpotifyAuthenticationError) throw error;
|
|
785
|
-
throw safeError("spotify_credential_store_failed", "Spotify credential refresh could not be
|
|
790
|
+
throw safeError("spotify_credential_store_failed", "Spotify credential refresh could not be saved.");
|
|
786
791
|
}
|
|
787
792
|
}).finally(() => { clearTimeout(timer); if (refreshTask === task) refreshTask = null; });
|
|
788
793
|
refreshTask = task;
|
|
@@ -896,7 +901,7 @@ export function createSpotifyAuthentication({
|
|
|
896
901
|
"Spotify is not authenticated.",
|
|
897
902
|
);
|
|
898
903
|
}
|
|
899
|
-
if (
|
|
904
|
+
if (credential.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS) {
|
|
900
905
|
return credential.accessToken;
|
|
901
906
|
}
|
|
902
907
|
return forceRefreshAccessToken({ signal, proactive: true });
|
|
@@ -668,38 +668,16 @@ export class PersistentCredentialStore {
|
|
|
668
668
|
const release = await this.acquireLock(options);
|
|
669
669
|
try {
|
|
670
670
|
const document = await this.readDocument(options);
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
const validated = validateCredential(providerId, value);
|
|
674
|
-
document.credentials[providerId] = validated;
|
|
675
|
-
await this.writeDocument(document, options);
|
|
676
|
-
current = validated;
|
|
677
|
-
};
|
|
678
|
-
let active = true;
|
|
679
|
-
let pending = Promise.resolve();
|
|
680
|
-
// A checkpoint is durable even if the callback later throws. Its
|
|
681
|
-
// lifetime is fenced to this transaction; all started writes drain
|
|
682
|
-
// before the lock is released, including accidentally unawaited ones.
|
|
683
|
-
const checkpoint = (value) => {
|
|
684
|
-
if (!active) throw safeError("credential_store_transaction_closed", "The credential transaction has ended.");
|
|
685
|
-
const validated = validateCredential(providerId, value);
|
|
686
|
-
pending = pending.then(() => persist(validated));
|
|
687
|
-
pending.catch(() => {});
|
|
688
|
-
return pending;
|
|
689
|
-
};
|
|
690
|
-
let next;
|
|
691
|
-
try {
|
|
692
|
-
next = await fn(current ? structuredClone(current) : undefined, { checkpoint });
|
|
693
|
-
} finally {
|
|
694
|
-
active = false;
|
|
695
|
-
await pending;
|
|
696
|
-
}
|
|
671
|
+
const current = document.credentials[providerId];
|
|
672
|
+
const next = await fn(current ? structuredClone(current) : undefined);
|
|
697
673
|
assertNotAborted(options.signal);
|
|
698
674
|
if (next === undefined) {
|
|
699
675
|
return current ? structuredClone(current) : undefined;
|
|
700
676
|
}
|
|
701
|
-
|
|
702
|
-
|
|
677
|
+
const validated = validateCredential(providerId, next);
|
|
678
|
+
document.credentials[providerId] = validated;
|
|
679
|
+
await this.writeDocument(document, options);
|
|
680
|
+
return structuredClone(validated);
|
|
703
681
|
} finally {
|
|
704
682
|
await release();
|
|
705
683
|
}
|