mr-magic-mcp-server 0.2.6 → 0.3.1
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/.env.example +53 -24
- package/README.md +439 -143
- package/package.json +4 -2
- package/src/providers/genius.js +1 -1
- package/src/providers/musixmatch.js +2 -3
- package/src/scripts/fetch_genius_token.mjs +21 -4
- package/src/scripts/fetch_musixmatch_token.mjs +169 -23
- package/src/scripts/push_musixmatch_token.mjs +133 -0
- package/src/transport/mcp-tools.js +2 -3
- package/src/transport/token-startup-log.js +4 -5
- package/src/utils/config.js +1 -1
- package/src/utils/kv-store.js +157 -0
- package/src/utils/tokens/genius-token-manager.js +97 -22
- package/src/utils/tokens/musixmatch-token-manager.js +77 -36
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency KV store abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects the backend from environment variables. Both backends call
|
|
5
|
+
* their respective REST APIs using the built-in `fetch()` — no extra packages
|
|
6
|
+
* required.
|
|
7
|
+
*
|
|
8
|
+
* Supported backends (in priority order — first configured wins):
|
|
9
|
+
*
|
|
10
|
+
* 1. Upstash Redis (recommended for ephemeral/serverless deployments)
|
|
11
|
+
* UPSTASH_REDIS_REST_URL — e.g. https://xxxxx.upstash.io
|
|
12
|
+
* UPSTASH_REDIS_REST_TOKEN — Upstash REST bearer token
|
|
13
|
+
* → Also used by the export backend; set once, used everywhere.
|
|
14
|
+
* → Takes precedence over Cloudflare KV if both are configured.
|
|
15
|
+
*
|
|
16
|
+
* 2. Cloudflare KV
|
|
17
|
+
* CF_API_TOKEN — Cloudflare API token with KV:Edit permission
|
|
18
|
+
* CF_ACCOUNT_ID — Cloudflare account ID
|
|
19
|
+
* CF_KV_NAMESPACE_ID — KV namespace ID
|
|
20
|
+
*
|
|
21
|
+
* If neither backend is configured, all operations are silent no-ops.
|
|
22
|
+
* If both are configured, Upstash Redis is used and Cloudflare KV is ignored.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ─── Backend detection ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function getUpstashConfig() {
|
|
28
|
+
const url = (process.env.UPSTASH_REDIS_REST_URL || '').replace(/\/$/, '');
|
|
29
|
+
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
30
|
+
if (!url || !token) return null;
|
|
31
|
+
return { url, token };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getCfConfig() {
|
|
35
|
+
const apiToken = process.env.CF_API_TOKEN;
|
|
36
|
+
const accountId = process.env.CF_ACCOUNT_ID;
|
|
37
|
+
const namespaceId = process.env.CF_KV_NAMESPACE_ID;
|
|
38
|
+
if (!apiToken || !accountId || !namespaceId) return null;
|
|
39
|
+
return { apiToken, accountId, namespaceId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isKvConfigured() {
|
|
43
|
+
return Boolean(getUpstashConfig() || getCfConfig());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returns a human-readable label for the active KV backend. */
|
|
47
|
+
export function describeKvBackend() {
|
|
48
|
+
if (getUpstashConfig()) return 'upstash-redis';
|
|
49
|
+
if (getCfConfig()) return 'cloudflare-kv';
|
|
50
|
+
return 'none';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get a value from the KV store.
|
|
57
|
+
* Returns the stored string, or `null` if the key is missing, not configured,
|
|
58
|
+
* or an error occured.
|
|
59
|
+
* @param {string} key
|
|
60
|
+
* @returns {Promise<string|null>}
|
|
61
|
+
*/
|
|
62
|
+
export async function kvGet(key) {
|
|
63
|
+
const upstash = getUpstashConfig();
|
|
64
|
+
if (upstash) return upstashGet(upstash, key);
|
|
65
|
+
|
|
66
|
+
const cf = getCfConfig();
|
|
67
|
+
if (cf) return cfGet(cf, key);
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Store a string value in the KV store. No-op if no backend is configured.
|
|
74
|
+
* @param {string} key
|
|
75
|
+
* @param {string} value
|
|
76
|
+
* @param {number} [ttlSeconds] — optional TTL; defaults to no expiry if omitted
|
|
77
|
+
* @returns {Promise<void>}
|
|
78
|
+
*/
|
|
79
|
+
export async function kvSet(key, value, ttlSeconds) {
|
|
80
|
+
const upstash = getUpstashConfig();
|
|
81
|
+
if (upstash) return upstashSet(upstash, key, value, ttlSeconds);
|
|
82
|
+
|
|
83
|
+
const cf = getCfConfig();
|
|
84
|
+
if (cf) return cfSet(cf, key, value, ttlSeconds);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Upstash Redis backend ────────────────────────────────────────────────────
|
|
88
|
+
// Uses the Upstash Redis REST API (POST / with a Redis command array).
|
|
89
|
+
|
|
90
|
+
async function upstashCmd({ url, token }, command) {
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${token}`,
|
|
95
|
+
'Content-Type': 'application/json'
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(command)
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const text = await res.text().catch(() => '');
|
|
101
|
+
throw new Error(`Upstash Redis ${command[0]} failed (${res.status}): ${text}`);
|
|
102
|
+
}
|
|
103
|
+
return res.json();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function upstashGet(cfg, key) {
|
|
107
|
+
try {
|
|
108
|
+
const json = await upstashCmd(cfg, ['GET', key]);
|
|
109
|
+
return json?.result ?? null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function upstashSet(cfg, key, value, ttlSeconds) {
|
|
116
|
+
const cmd = ttlSeconds
|
|
117
|
+
? ['SET', key, value, 'EX', String(Math.round(ttlSeconds))]
|
|
118
|
+
: ['SET', key, value];
|
|
119
|
+
await upstashCmd(cfg, cmd);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Cloudflare KV backend ────────────────────────────────────────────────────
|
|
123
|
+
// Uses the Cloudflare Workers KV REST API.
|
|
124
|
+
|
|
125
|
+
function cfValuesUrl({ accountId, namespaceId }, key) {
|
|
126
|
+
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function cfGet(cfg, key) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(cfValuesUrl(cfg, key), {
|
|
132
|
+
headers: { Authorization: `Bearer ${cfg.apiToken}` }
|
|
133
|
+
});
|
|
134
|
+
if (res.status === 404) return null;
|
|
135
|
+
if (!res.ok) return null;
|
|
136
|
+
return res.text();
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function cfSet(cfg, key, value, ttlSeconds) {
|
|
143
|
+
const url =
|
|
144
|
+
cfValuesUrl(cfg, key) + (ttlSeconds ? `?expiration_ttl=${Math.round(ttlSeconds)}` : '');
|
|
145
|
+
const res = await fetch(url, {
|
|
146
|
+
method: 'PUT',
|
|
147
|
+
headers: {
|
|
148
|
+
Authorization: `Bearer ${cfg.apiToken}`,
|
|
149
|
+
'Content-Type': 'text/plain'
|
|
150
|
+
},
|
|
151
|
+
body: value
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const text = await res.text().catch(() => '');
|
|
155
|
+
throw new Error(`Cloudflare KV PUT failed (${res.status}): ${text}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -5,23 +5,32 @@ import axios from 'axios';
|
|
|
5
5
|
|
|
6
6
|
import { getEnvValue, getProjectRoot } from '../config.js';
|
|
7
7
|
import { createLogger } from '../logger.js';
|
|
8
|
+
import { describeKvBackend, isKvConfigured, kvGet, kvSet } from '../kv-store.js';
|
|
8
9
|
|
|
9
10
|
const GENIUS_TOKEN_ENDPOINT = 'https://api.genius.com/oauth/token';
|
|
10
11
|
const logger = createLogger('genius-token-manager');
|
|
11
12
|
|
|
12
13
|
// Token source terminology used throughout this module:
|
|
13
|
-
// • Auto-refresh
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
14
|
+
// • Auto-refresh — GENIUS_CLIENT_ID + GENIUS_CLIENT_SECRET env vars.
|
|
15
|
+
// The server calls the Genius OAuth client_credentials endpoint
|
|
16
|
+
// at runtime and keeps the token refreshed in memory automatically.
|
|
17
|
+
// This is the recommended approach for all deployments: no disk,
|
|
18
|
+
// no scripts, and no manual token copying needed.
|
|
19
|
+
// On success the token is also persisted to KV + disk cache.
|
|
20
|
+
// • Direct token — GENIUS_DIRECT_TOKEN env var. A static bearer token set directly
|
|
21
|
+
// in the environment. Does not auto-refresh; redeploy when expired.
|
|
22
|
+
// Use this only when client_credentials are unavailable.
|
|
23
|
+
// • KV token — stored in a remote KV store (Upstash Redis or Cloudflare KV).
|
|
24
|
+
// Written automatically when client_credentials refresh succeeds.
|
|
25
|
+
// Ideal for ephemeral/serverless deployments and npx installs.
|
|
26
|
+
// • Cache token — on-disk .cache/genius-token.json written by the fetch script or
|
|
27
|
+
// by a successful client_credentials refresh. Only reliable when
|
|
28
|
+
// a persistent, writable filesystem is available (local dev).
|
|
29
|
+
// Ephemeral hosts should use auto-refresh, direct token, or KV.
|
|
30
|
+
|
|
31
|
+
// KV key and TTL — configurable via env vars.
|
|
32
|
+
const KV_KEY = process.env.GENIUS_TOKEN_KV_KEY || 'mr-magic:genius-token';
|
|
33
|
+
const KV_TTL_SECONDS = parseInt(process.env.GENIUS_TOKEN_KV_TTL_SECONDS || '3600', 10); // 1 hour
|
|
25
34
|
|
|
26
35
|
// Token cache path — must match the path used by src/scripts/fetch_genius_token.mjs.
|
|
27
36
|
const TOKEN_CACHE_PATH =
|
|
@@ -32,7 +41,7 @@ let cachedExpiry = 0;
|
|
|
32
41
|
let lastAuthMode = 'unknown';
|
|
33
42
|
|
|
34
43
|
function getFallbackToken() {
|
|
35
|
-
return getEnvValue('
|
|
44
|
+
return getEnvValue('GENIUS_DIRECT_TOKEN');
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
function tokenExpired() {
|
|
@@ -56,6 +65,59 @@ async function readCachedToken() {
|
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
async function writeCachedToken(accessToken, expiresIn) {
|
|
69
|
+
if (!accessToken) return;
|
|
70
|
+
try {
|
|
71
|
+
await fs.mkdir(path.dirname(TOKEN_CACHE_PATH), { recursive: true });
|
|
72
|
+
await fs.writeFile(
|
|
73
|
+
TOKEN_CACHE_PATH,
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
access_token: accessToken,
|
|
76
|
+
expires_at: Date.now() + (Number(expiresIn) || 3600) * 1000
|
|
77
|
+
}),
|
|
78
|
+
'utf8'
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.warn('Failed to persist Genius token cache', { error: error?.message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── KV store helpers ─────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function readKvToken() {
|
|
88
|
+
if (!isKvConfigured()) return null;
|
|
89
|
+
try {
|
|
90
|
+
const raw = await kvGet(KV_KEY);
|
|
91
|
+
if (!raw) return null;
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (parsed?.access_token) {
|
|
94
|
+
cachedToken = parsed.access_token;
|
|
95
|
+
cachedExpiry = parsed.expires_at ?? Date.now() + 3_600_000;
|
|
96
|
+
lastAuthMode = `kv:${describeKvBackend()}`;
|
|
97
|
+
return cachedToken;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// KV read error — not fatal; fall through to disk cache
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function writeKvToken(accessToken, expiresIn) {
|
|
106
|
+
if (!isKvConfigured() || !accessToken) return;
|
|
107
|
+
try {
|
|
108
|
+
const payload = JSON.stringify({
|
|
109
|
+
access_token: accessToken,
|
|
110
|
+
expires_at: Date.now() + (Number(expiresIn) || 3600) * 1000
|
|
111
|
+
});
|
|
112
|
+
await kvSet(KV_KEY, payload, KV_TTL_SECONDS);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger.warn('Failed to persist Genius token to KV store', {
|
|
115
|
+
backend: describeKvBackend(),
|
|
116
|
+
error: error?.message
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
59
121
|
async function fetchClientCredentialsToken() {
|
|
60
122
|
const clientId = getEnvValue('GENIUS_CLIENT_ID');
|
|
61
123
|
const clientSecret = getEnvValue('GENIUS_CLIENT_SECRET');
|
|
@@ -82,6 +144,8 @@ async function fetchClientCredentialsToken() {
|
|
|
82
144
|
cachedExpiry = Date.now() + ttl * 1000;
|
|
83
145
|
lastAuthMode = 'client_credentials';
|
|
84
146
|
logger.info('Genius token refreshed', { ttlSeconds: ttl });
|
|
147
|
+
// Persist to durable backends in parallel so ephemeral hosts survive restarts.
|
|
148
|
+
await Promise.allSettled([writeCachedToken(accessToken, ttl), writeKvToken(accessToken, ttl)]);
|
|
85
149
|
return cachedToken;
|
|
86
150
|
} catch (error) {
|
|
87
151
|
logger.error('Failed to refresh Genius token', {
|
|
@@ -95,8 +159,10 @@ async function fetchClientCredentialsToken() {
|
|
|
95
159
|
* Resolve the Genius token using the following priority order:
|
|
96
160
|
* 1. In-memory runtime cache (already resolved this session)
|
|
97
161
|
* 2. Auto-refresh via GENIUS_CLIENT_ID + GENIUS_CLIENT_SECRET (client_credentials)
|
|
98
|
-
*
|
|
99
|
-
*
|
|
162
|
+
* → on success, writes to KV store + disk cache
|
|
163
|
+
* 3. GENIUS_DIRECT_TOKEN env var — static bearer token override
|
|
164
|
+
* 4. KV store — Upstash Redis or Cloudflare KV (ephemeral/npx)
|
|
165
|
+
* 5. On-disk .cache/genius-token.json — cache token, local dev only
|
|
100
166
|
*/
|
|
101
167
|
export async function getGeniusToken({ forceRefresh = false } = {}) {
|
|
102
168
|
if (!forceRefresh && !tokenExpired()) {
|
|
@@ -109,17 +175,21 @@ export async function getGeniusToken({ forceRefresh = false } = {}) {
|
|
|
109
175
|
return token;
|
|
110
176
|
}
|
|
111
177
|
|
|
112
|
-
// 3.
|
|
178
|
+
// 3. Direct token from env var (static override, no auto-refresh)
|
|
113
179
|
const fallback = getFallbackToken();
|
|
114
180
|
if (fallback && fallback !== cachedToken) {
|
|
115
|
-
logger.warn('Using Genius
|
|
181
|
+
logger.warn('Using Genius direct token from GENIUS_DIRECT_TOKEN env var');
|
|
116
182
|
cachedToken = fallback;
|
|
117
183
|
cachedExpiry = Date.now() + 86_400_000; // 1 day placeholder
|
|
118
|
-
lastAuthMode = '
|
|
184
|
+
lastAuthMode = 'env_direct_token';
|
|
119
185
|
return cachedToken;
|
|
120
186
|
}
|
|
121
187
|
|
|
122
|
-
// 4.
|
|
188
|
+
// 4. KV store — ideal for ephemeral deployments and npx installs
|
|
189
|
+
const kvToken = await readKvToken();
|
|
190
|
+
if (kvToken) return kvToken;
|
|
191
|
+
|
|
192
|
+
// 5. Cache token from disk (local dev convenience only)
|
|
123
193
|
const cached = await readCachedToken();
|
|
124
194
|
if (cached) {
|
|
125
195
|
logger.info('Using Genius cache token from disk', { cachePath: TOKEN_CACHE_PATH });
|
|
@@ -156,7 +226,10 @@ export function describeGeniusAuthMode() {
|
|
|
156
226
|
return 'client_credentials';
|
|
157
227
|
}
|
|
158
228
|
if (getFallbackToken()) {
|
|
159
|
-
return '
|
|
229
|
+
return 'env_direct_token';
|
|
230
|
+
}
|
|
231
|
+
if (isKvConfigured()) {
|
|
232
|
+
return 'kv_store';
|
|
160
233
|
}
|
|
161
234
|
return 'none';
|
|
162
235
|
}
|
|
@@ -164,12 +237,14 @@ export function describeGeniusAuthMode() {
|
|
|
164
237
|
export async function getGeniusDiagnostics() {
|
|
165
238
|
const clientId = getEnvValue('GENIUS_CLIENT_ID');
|
|
166
239
|
const clientSecret = getEnvValue('GENIUS_CLIENT_SECRET');
|
|
167
|
-
const
|
|
240
|
+
const directToken = getFallbackToken();
|
|
168
241
|
const ttlMs = Math.max(cachedExpiry - Date.now(), 0);
|
|
169
242
|
|
|
170
243
|
const diagnostics = {
|
|
171
244
|
clientCredentialsPresent: Boolean(clientId && clientSecret),
|
|
172
|
-
|
|
245
|
+
directTokenPresent: Boolean(directToken),
|
|
246
|
+
kvConfigured: isKvConfigured(),
|
|
247
|
+
kvBackend: describeKvBackend(),
|
|
173
248
|
runtimeTokenCached: Boolean(cachedToken),
|
|
174
249
|
runtimeTokenExpiresInMs: cachedToken ? ttlMs : 0,
|
|
175
250
|
lastAuthMode: describeGeniusAuthMode(),
|
|
@@ -3,20 +3,28 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { getEnvValue, getProjectRoot } from '../config.js';
|
|
5
5
|
import { createLogger } from '../logger.js';
|
|
6
|
+
import { describeKvBackend, isKvConfigured, kvGet, kvSet } from '../kv-store.js';
|
|
6
7
|
|
|
7
8
|
const logger = createLogger('musixmatch-token-manager');
|
|
8
9
|
|
|
9
10
|
// Token source terminology used throughout this module:
|
|
10
|
-
// •
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// •
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
11
|
+
// • Direct token — MUSIXMATCH_DIRECT_TOKEN env var. A static bearer token set
|
|
12
|
+
// directly in the environment. Recommended for production and
|
|
13
|
+
// remote deployments where the filesystem cannot be relied upon
|
|
14
|
+
// for persistence. Highest priority after in-memory cache.
|
|
15
|
+
// • KV token — stored in a remote KV store (Upstash Redis or Cloudflare KV).
|
|
16
|
+
// Ideal for ephemeral/serverless deployments and npx installs
|
|
17
|
+
// where there is no local filesystem at all.
|
|
18
|
+
// • Cache token — loaded from the on-disk cache file written by the fetch script.
|
|
19
|
+
// Only reliable when a persistent, writable filesystem is available
|
|
20
|
+
// (i.e. local development). Ephemeral hosts (Render free tier, etc.)
|
|
21
|
+
// may not have a writable FS, so the cache token is unavailable there.
|
|
22
|
+
|
|
23
|
+
// KV key and TTL — configurable via env vars.
|
|
24
|
+
const KV_KEY = process.env.MUSIXMATCH_TOKEN_KV_KEY || 'mr-magic:musixmatch-token';
|
|
25
|
+
const KV_TTL_SECONDS = parseInt(process.env.MUSIXMATCH_TOKEN_KV_TTL_SECONDS || '2592000', 10); // 30 days
|
|
18
26
|
const TOKEN_CACHE_PATH =
|
|
19
|
-
process.env.
|
|
27
|
+
process.env.MUSIXMATCH_TOKEN_CACHE ||
|
|
20
28
|
path.join(getProjectRoot(), '.cache', 'musixmatch-token.json');
|
|
21
29
|
|
|
22
30
|
let cachedToken = null;
|
|
@@ -60,7 +68,7 @@ async function writeCachedToken(token, desktopCookie) {
|
|
|
60
68
|
if (!dirOk) {
|
|
61
69
|
logger.warn(
|
|
62
70
|
'Musixmatch token cache directory unavailable (read-only or restricted filesystem). ' +
|
|
63
|
-
'Token was NOT persisted to disk. Set
|
|
71
|
+
'Token was NOT persisted to disk. Set MUSIXMATCH_DIRECT_TOKEN as an environment variable ' +
|
|
64
72
|
'to ensure the token survives restarts in remote/ephemeral deployments.',
|
|
65
73
|
{ cachePath: TOKEN_CACHE_PATH }
|
|
66
74
|
);
|
|
@@ -75,36 +83,65 @@ async function writeCachedToken(token, desktopCookie) {
|
|
|
75
83
|
}
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
// ─── KV store helpers ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async function readKvToken() {
|
|
89
|
+
if (!isKvConfigured()) return null;
|
|
90
|
+
try {
|
|
91
|
+
const raw = await kvGet(KV_KEY);
|
|
92
|
+
if (!raw) return null;
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (parsed?.token) {
|
|
95
|
+
cachedToken = parsed.token;
|
|
96
|
+
cachedDesktopCookie = parsed.desktopCookie || null;
|
|
97
|
+
lastLoadedFrom = `kv:${describeKvBackend()}`;
|
|
98
|
+
return cachedToken;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// KV read error — not fatal; fall through to disk cache
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeKvToken(token, desktopCookie) {
|
|
107
|
+
if (!isKvConfigured() || !token) return;
|
|
108
|
+
try {
|
|
109
|
+
const payload = JSON.stringify({ token, ...(desktopCookie ? { desktopCookie } : {}) });
|
|
110
|
+
await kvSet(KV_KEY, payload, KV_TTL_SECONDS);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.warn('Failed to persist Musixmatch token to KV store', {
|
|
113
|
+
backend: describeKvBackend(),
|
|
114
|
+
error: error?.message
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
/**
|
|
79
120
|
* Resolve the Musixmatch token using the following priority order:
|
|
80
121
|
* 1. In-memory runtime cache (already resolved this session)
|
|
81
|
-
* 2.
|
|
82
|
-
* 3.
|
|
83
|
-
* 4. On-disk cache file
|
|
122
|
+
* 2. MUSIXMATCH_DIRECT_TOKEN env var — direct/static bearer token (highest env priority)
|
|
123
|
+
* 3. KV store — Upstash Redis or Cloudflare KV (ephemeral/npx)
|
|
124
|
+
* 4. On-disk cache file — local dev / persistent server only
|
|
84
125
|
*/
|
|
85
126
|
export async function getMusixmatchToken() {
|
|
86
127
|
if (cachedToken) {
|
|
87
128
|
return cachedToken;
|
|
88
129
|
}
|
|
89
130
|
|
|
90
|
-
//
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
cachedToken =
|
|
94
|
-
lastLoadedFrom = 'env:
|
|
131
|
+
// 2. Direct token from env var — survives restarts on ephemeral hosts without any external service
|
|
132
|
+
const directToken = getEnvValue('MUSIXMATCH_DIRECT_TOKEN');
|
|
133
|
+
if (directToken) {
|
|
134
|
+
cachedToken = directToken;
|
|
135
|
+
lastLoadedFrom = 'env:MUSIXMATCH_DIRECT_TOKEN';
|
|
95
136
|
cachedDesktopCookie = null;
|
|
96
137
|
return cachedToken;
|
|
97
138
|
}
|
|
98
139
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
lastLoadedFrom = 'env:MUSIXMATCH_ALT_USER_TOKEN';
|
|
103
|
-
cachedDesktopCookie = null;
|
|
104
|
-
return cachedToken;
|
|
105
|
-
}
|
|
140
|
+
// 3. KV store — ideal for ephemeral deployments and npx installs with no local filesystem
|
|
141
|
+
const kvToken = await readKvToken();
|
|
142
|
+
if (kvToken) return kvToken;
|
|
106
143
|
|
|
107
|
-
//
|
|
144
|
+
// 4. On-disk cache — local dev / persistent hosts with a writable filesystem
|
|
108
145
|
return readCachedToken();
|
|
109
146
|
}
|
|
110
147
|
|
|
@@ -113,10 +150,16 @@ export async function setMusixmatchToken(token, { desktopCookie } = {}) {
|
|
|
113
150
|
cachedToken = token;
|
|
114
151
|
lastLoadedFrom = 'runtime';
|
|
115
152
|
cachedDesktopCookie = desktopCookie || null;
|
|
116
|
-
|
|
153
|
+
// Write to both storage backends in parallel; failures are logged, not thrown.
|
|
154
|
+
await Promise.allSettled([
|
|
155
|
+
writeCachedToken(token, desktopCookie),
|
|
156
|
+
writeKvToken(token, desktopCookie)
|
|
157
|
+
]);
|
|
117
158
|
logger.info('Musixmatch token updated', {
|
|
118
159
|
source: 'runtime',
|
|
119
|
-
desktopCookiePresent: Boolean(desktopCookie)
|
|
160
|
+
desktopCookiePresent: Boolean(desktopCookie),
|
|
161
|
+
kvConfigured: isKvConfigured(),
|
|
162
|
+
kvBackend: describeKvBackend()
|
|
120
163
|
});
|
|
121
164
|
}
|
|
122
165
|
|
|
@@ -129,8 +172,7 @@ export function describeMusixmatchTokenSource() {
|
|
|
129
172
|
}
|
|
130
173
|
|
|
131
174
|
export async function getMusixmatchTokenDiagnostics() {
|
|
132
|
-
const
|
|
133
|
-
const envToken = getEnvValue('MUSIXMATCH_ALT_USER_TOKEN');
|
|
175
|
+
const directEnvToken = getEnvValue('MUSIXMATCH_DIRECT_TOKEN');
|
|
134
176
|
|
|
135
177
|
const diagnostics = {
|
|
136
178
|
cachePath: TOKEN_CACHE_PATH,
|
|
@@ -140,8 +182,9 @@ export async function getMusixmatchTokenDiagnostics() {
|
|
|
140
182
|
cacheBytes: 0,
|
|
141
183
|
cacheTokenPresent: false,
|
|
142
184
|
cacheError: null,
|
|
143
|
-
|
|
144
|
-
|
|
185
|
+
directEnvPresent: Boolean(directEnvToken),
|
|
186
|
+
kvConfigured: isKvConfigured(),
|
|
187
|
+
kvBackend: describeKvBackend(),
|
|
145
188
|
runtimeTokenCached: Boolean(cachedToken),
|
|
146
189
|
lastLoadedFrom,
|
|
147
190
|
resolvedSource: 'none'
|
|
@@ -160,10 +203,8 @@ export async function getMusixmatchTokenDiagnostics() {
|
|
|
160
203
|
|
|
161
204
|
if (cachedToken) {
|
|
162
205
|
diagnostics.resolvedSource = lastLoadedFrom;
|
|
163
|
-
} else if (
|
|
164
|
-
diagnostics.resolvedSource = 'env:
|
|
165
|
-
} else if (envToken) {
|
|
166
|
-
diagnostics.resolvedSource = 'env:MUSIXMATCH_ALT_USER_TOKEN';
|
|
206
|
+
} else if (directEnvToken) {
|
|
207
|
+
diagnostics.resolvedSource = 'env:MUSIXMATCH_DIRECT_TOKEN';
|
|
167
208
|
} else if (diagnostics.cacheTokenPresent) {
|
|
168
209
|
diagnostics.resolvedSource = 'cache';
|
|
169
210
|
} else {
|