@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.1</p>
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.0 is the published package; `npm install --global @audiofool/moondog` installs it.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audiofool/moondog",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Local-first personal AI music agent.",
6
6
  "license": "Apache-2.0",
@@ -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 (!credential.refreshAttempt && usable(credential) &&
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. Run /spotify login if the refresh was interrupted.",
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, transaction) => {
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
- if (typeof transaction?.checkpoint !== "function") {
765
- throw safeError("spotify_refresh_coordination_unavailable", "Spotify refresh requires durable coordinated credential storage.");
766
- }
767
- // Persist intent before dispatch. Do not abandon rotation merely
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 persisted. Run /spotify login if the refresh was interrupted.");
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 (!credential.refreshAttempt && credential.accessExpiresAt > now() + ACCESS_TOKEN_REFRESH_WINDOW_MS) {
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
- let current = document.credentials[providerId];
672
- const persist = async (value) => {
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
- await persist(next);
702
- return structuredClone(current);
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
  }