mr-magic-mcp-server 0.2.5 → 0.3.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.
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * push_musixmatch_token.mjs
4
+ *
5
+ * Seed the Musixmatch token to all configured storage backends (Upstash Redis,
6
+ * Cloudflare KV, and/or on-disk cache) WITHOUT opening a browser.
7
+ *
8
+ * Use this when you already have a token value — e.g. captured once locally via
9
+ * `npm run fetch:musixmatch-token` — and need to push it to a headless server,
10
+ * ephemeral deployment (Render), or CI/CD pipeline where a browser is unavailable.
11
+ *
12
+ * Usage (env var — recommended for Render / build/start commands):
13
+ * MUSIXMATCH_DIRECT_TOKEN='{"message":...}' npm run push:musixmatch-token
14
+ *
15
+ * Usage (CLI flag):
16
+ * npm run push:musixmatch-token -- --token '{"message":...}'
17
+ *
18
+ * The token value must be the full musixmatchUserToken JSON payload (the same
19
+ * object that `fetch:musixmatch-token` captures and prints after sign-in).
20
+ * A raw string token is also accepted.
21
+ *
22
+ * Exit codes:
23
+ * 0 — token pushed successfully (or no token provided, no-op)
24
+ * 1 — token was provided but a push failure occurred (KV write error, etc.)
25
+ *
26
+ * Render example (build command or start command):
27
+ * MUSIXMATCH_DIRECT_TOKEN='...' npm run push:musixmatch-token && npm run server:mcp:http
28
+ */
29
+
30
+ import { mkdir, writeFile } from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import { parseArgs } from 'node:util';
33
+
34
+ import '../utils/config.js';
35
+ import { describeKvBackend, isKvConfigured, kvSet } from '../utils/kv-store.js';
36
+
37
+ // ─── Argument parsing ─────────────────────────────────────────────────────────
38
+
39
+ const { values } = parseArgs({
40
+ args: process.argv.slice(2),
41
+ options: {
42
+ token: { type: 'string', short: 't' },
43
+ help: { type: 'boolean', short: 'h' },
44
+ },
45
+ strict: false,
46
+ });
47
+
48
+ if (values.help) {
49
+ console.log(`
50
+ push_musixmatch_token — seed Musixmatch token to all configured backends
51
+
52
+ Usage:
53
+ MUSIXMATCH_DIRECT_TOKEN='<json_or_string>' npm run push:musixmatch-token
54
+ npm run push:musixmatch-token -- --token '<json_or_string>'
55
+
56
+ The token value is the full musixmatchUserToken JSON payload captured by
57
+ fetch:musixmatch-token, or a raw string token.
58
+
59
+ Backends written (if configured):
60
+ • Upstash Redis — UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN
61
+ • Cloudflare KV — CF_API_TOKEN + CF_ACCOUNT_ID + CF_KV_NAMESPACE_ID
62
+ • On-disk cache — .cache/musixmatch-token.json (or MUSIXMATCH_TOKEN_CACHE)
63
+
64
+ If MUSIXMATCH_DIRECT_TOKEN is not set and --token is not supplied, the
65
+ script exits 0 with no output (safe to chain in build/start commands).
66
+ `);
67
+ process.exit(0);
68
+ }
69
+
70
+ // ─── Main ─────────────────────────────────────────────────────────────────────
71
+
72
+ async function main() {
73
+ const rawToken = values.token || process.env.MUSIXMATCH_DIRECT_TOKEN;
74
+
75
+ // If no token is provided, exit silently so this can be safely chained in
76
+ // build/start commands when the token hasn't been set yet.
77
+ if (!rawToken) {
78
+ return;
79
+ }
80
+
81
+ // Parse as JSON if possible; otherwise treat as a raw string token.
82
+ let parsedToken;
83
+ try {
84
+ parsedToken = JSON.parse(rawToken);
85
+ } catch {
86
+ parsedToken = rawToken;
87
+ }
88
+
89
+ console.log('Pushing Musixmatch token to configured backends...');
90
+ let anyFailed = false;
91
+
92
+ // ─── On-disk cache ───────────────────────────────────────────────────────
93
+ const cachePath =
94
+ process.env.MUSIXMATCH_TOKEN_CACHE || path.resolve('.cache', 'musixmatch-token.json');
95
+ try {
96
+ await mkdir(path.dirname(cachePath), { recursive: true });
97
+ await writeFile(cachePath, JSON.stringify({ token: parsedToken }, null, 2), 'utf8');
98
+ console.log(` ✓ Disk cache: ${cachePath}`);
99
+ } catch (err) {
100
+ console.warn(` ✗ Disk cache write failed (${err.message}) — continuing.`);
101
+ // Not fatal; remote hosts may not have a writable FS.
102
+ }
103
+
104
+ // ─── KV store ─────────────────────────────────────────────────────────────
105
+ if (isKvConfigured()) {
106
+ const kvKey = process.env.MUSIXMATCH_TOKEN_KV_KEY || 'mr-magic:musixmatch-token';
107
+ const kvTtl = parseInt(process.env.MUSIXMATCH_TOKEN_KV_TTL_SECONDS || '2592000', 10);
108
+ const payload = JSON.stringify({ token: parsedToken });
109
+ try {
110
+ await kvSet(kvKey, payload, kvTtl);
111
+ console.log(` ✓ KV store (${describeKvBackend()}): key="${kvKey}", ttl=${kvTtl}s`);
112
+ } catch (err) {
113
+ console.error(` ✗ KV store write failed: ${err.message}`);
114
+ anyFailed = true;
115
+ }
116
+ } else {
117
+ console.log(' — KV store: not configured (set UPSTASH_REDIS_REST_URL/TOKEN or CF_* vars to enable)');
118
+ }
119
+
120
+ if (anyFailed) {
121
+ console.error('\n✗ One or more backends failed — see errors above.');
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log('\n✓ Token pushed. The server will read it from the available backend on startup.');
126
+ }
127
+
128
+ main().catch((err) => {
129
+ console.error('Unexpected error:', err.message);
130
+ process.exit(1);
131
+ });
@@ -84,12 +84,25 @@ export function startHttpServer(options = {}) {
84
84
 
85
85
  if (req.method === 'GET' && req.url.startsWith('/downloads/')) {
86
86
  const segments = req.url.split('/');
87
- const [, , downloadId, ...rest] = segments;
88
- // Strip trailing filename segment (e.g. "artist-song.srt") when present so the
89
- // Redis key lookup uses only the extension portion of the path.
90
- const hasFilename = rest.length > 1 && rest[rest.length - 1].includes('.');
91
- const extensionParts = hasFilename ? rest.slice(0, -1) : rest;
92
- const extension = extensionParts.join('/') || '';
87
+ const [, , idSegment, ...rest] = segments;
88
+
89
+ // Support two URL formats:
90
+ // flat: /downloads/export-xxx.srt → id=export-xxx, extension=srt
91
+ // compound: /downloads/export-xxx/romanized.srt → id=export-xxx, extension=romanized.srt
92
+ let downloadId, extension;
93
+ if (rest.length === 0 && idSegment && idSegment.includes('.')) {
94
+ // Flat format – extension is appended to the id with a dot.
95
+ const dotIdx = idSegment.lastIndexOf('.');
96
+ downloadId = idSegment.slice(0, dotIdx);
97
+ extension = idSegment.slice(dotIdx + 1);
98
+ } else {
99
+ // Compound / path-segment format.
100
+ downloadId = idSegment;
101
+ // Strip trailing human-readable filename (e.g. "artist-song.srt") when present.
102
+ const hasFilename = rest.length > 1 && rest[rest.length - 1].includes('.');
103
+ const extensionParts = hasFilename ? rest.slice(0, -1) : rest;
104
+ extension = extensionParts.join('/') || '';
105
+ }
93
106
  if (!downloadId || !extension) {
94
107
  logger.warn('Invalid download path', {
95
108
  context: 'http-download',
@@ -134,8 +134,8 @@ export async function startMcpHttpServer(options = {}) {
134
134
  });
135
135
 
136
136
  // ── Export download endpoint ──────────────────────────────────────────────────
137
- app.get('/downloads/:downloadId/:extension', async (req, res) => {
138
- const { downloadId, extension } = req.params;
137
+ // Helper shared by both download route handlers.
138
+ async function serveDownload(downloadId, extension, res, url) {
139
139
  if (!downloadId || !extension) {
140
140
  res.status(400).json({ error: 'Invalid download path' });
141
141
  return;
@@ -163,9 +163,28 @@ export async function startMcpHttpServer(options = {}) {
163
163
  bytes: Buffer.byteLength(content)
164
164
  });
165
165
  } catch (error) {
166
- logger.error('Download lookup failed', { error, url: req.originalUrl });
166
+ logger.error('Download lookup failed', { error, url });
167
167
  res.status(500).json({ error: 'Failed to fetch export' });
168
168
  }
169
+ }
170
+
171
+ // Flat format: /downloads/export-xxx.srt → id=export-xxx, extension=srt
172
+ app.get('/downloads/:idWithExt', async (req, res) => {
173
+ const { idWithExt } = req.params;
174
+ if (!idWithExt || !idWithExt.includes('.')) {
175
+ res.status(400).json({ error: 'Invalid download path' });
176
+ return;
177
+ }
178
+ const dotIdx = idWithExt.lastIndexOf('.');
179
+ const downloadId = idWithExt.slice(0, dotIdx);
180
+ const extension = idWithExt.slice(dotIdx + 1);
181
+ await serveDownload(downloadId, extension, res, req.originalUrl);
182
+ });
183
+
184
+ // Compound format: /downloads/export-xxx/romanized.srt → id=export-xxx, extension=romanized.srt
185
+ app.get('/downloads/:downloadId/:extension', async (req, res) => {
186
+ const { downloadId, extension } = req.params;
187
+ await serveDownload(downloadId, extension, res, req.originalUrl);
169
188
  });
170
189
 
171
190
  // ── Streamable HTTP transport (/mcp) ──────────────────────────────────────────
@@ -435,11 +435,10 @@ export async function handleMcpTool(name, args = {}) {
435
435
 
436
436
  if (name === 'runtime_status') {
437
437
  const CREDENTIAL_KEYS = [
438
- 'GENIUS_ACCESS_TOKEN',
438
+ 'GENIUS_DIRECT_TOKEN',
439
439
  'GENIUS_CLIENT_ID',
440
440
  'GENIUS_CLIENT_SECRET',
441
- 'MUSIXMATCH_ALT_USER_TOKEN',
442
- 'MUSIXMATCH_FALLBACK_TOKEN',
441
+ 'MUSIXMATCH_DIRECT_TOKEN',
443
442
  'MELON_COOKIE'
444
443
  ];
445
444
  return {
@@ -37,7 +37,7 @@ async function logGeniusStatus(context) {
37
37
  context,
38
38
  provider: 'genius',
39
39
  clientCredentialsPresent: diagnostics.clientCredentialsPresent,
40
- fallbackTokenPresent: diagnostics.fallbackTokenPresent,
40
+ directTokenPresent: diagnostics.directTokenPresent,
41
41
  cacheTokenPresent: diagnostics.cacheTokenPresent,
42
42
  cacheTokenExpired: diagnostics.cacheTokenExpired
43
43
  });
@@ -73,7 +73,7 @@ async function logGeniusStatus(context) {
73
73
  logger.warn('Genius credentials missing', {
74
74
  context,
75
75
  provider: 'genius',
76
- details: 'set GENIUS_CLIENT_ID/SECRET or GENIUS_ACCESS_TOKEN'
76
+ details: 'set GENIUS_CLIENT_ID/SECRET or GENIUS_DIRECT_TOKEN'
77
77
  });
78
78
  }
79
79
  }
@@ -86,8 +86,7 @@ async function logMusixmatchStatus(context) {
86
86
  context,
87
87
  provider: 'musixmatch',
88
88
  cachePath: diagnostics.cachePath,
89
- userEnvPresent: diagnostics.userEnvPresent,
90
- envPresent: diagnostics.envPresent,
89
+ directEnvPresent: diagnostics.directEnvPresent,
91
90
  runtimeTokenCached: diagnostics.runtimeTokenCached,
92
91
  lastLoadedFrom: diagnostics.lastLoadedFrom
93
92
  });
@@ -117,7 +116,7 @@ async function logMusixmatchStatus(context) {
117
116
  context,
118
117
  provider: 'musixmatch',
119
118
  details:
120
- 'run npm run fetch:musixmatch-token to capture the cache token, or set MUSIXMATCH_FALLBACK_TOKEN as a fallback token for ephemeral deployments'
119
+ 'run npm run fetch:musixmatch-token to capture the cache token, or set MUSIXMATCH_DIRECT_TOKEN as a direct token for ephemeral deployments'
121
120
  });
122
121
  }
123
122
  }
@@ -23,7 +23,7 @@ export function getEnvValue(name) {
23
23
  return process.env[name] ?? null;
24
24
  }
25
25
 
26
- const DEFAULT_REQUIRED = ['GENIUS_ACCESS_TOKEN'];
26
+ const DEFAULT_REQUIRED = ['GENIUS_DIRECT_TOKEN'];
27
27
  const warnedMissingEnvCache = new Set();
28
28
 
29
29
  function getMissingEnvVars(requiredVars = DEFAULT_REQUIRED) {
@@ -30,8 +30,11 @@ export default class RedisStorage {
30
30
 
31
31
  const expiresAt = new Date(Date.now() + this.ttl * 1000).toISOString();
32
32
  const base = this.downloadBaseUrl?.replace(/[\/]+$/, '') || '';
33
- const bareExt = extension.includes('.') ? extension.split('.').pop() : extension;
34
- const url = `${base}/downloads/${id}/${extension}/${baseName}.${bareExt}`;
33
+ // Compound extensions (e.g. "romanized.srt") become a path segment;
34
+ // simple extensions (e.g. "srt") are appended directly to the id.
35
+ const url = extension.includes('.')
36
+ ? `${base}/downloads/${id}/${extension}`
37
+ : `${base}/downloads/${id}.${extension}`;
35
38
  return new ExportStorageResult({ url, expiresAt, skipped: false });
36
39
  }
37
40
  }
@@ -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 — GENIUS_CLIENT_ID + GENIUS_CLIENT_SECRET env vars.
14
- // The server calls the Genius OAuth client_credentials endpoint
15
- // at runtime and keeps the token refreshed in memory automatically.
16
- // This is the recommended approach for all deployments: no disk,
17
- // no scripts, and no manual token copying needed.
18
- // Fallback token GENIUS_ACCESS_TOKEN env var. A static bearer token set directly
19
- // in the environment. Does not auto-refresh; redeploy when expired.
20
- // Use this only when client_credentials are unavailable.
21
- // Cache token — on-disk .cache/genius-token.json written by the fetch script.
22
- // Only reliable when a persistent, writable filesystem is available
23
- // (i.e. local development). Ephemeral hosts (Render free tier, etc.)
24
- // should use the auto-refresh or fallback token paths instead.
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('GENIUS_ACCESS_TOKEN');
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
- * 3. GENIUS_ACCESS_TOKEN env var fallback token
99
- * 4. On-disk .cache/genius-token.jsoncache token, local dev only
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. Fallback token from env var (static, no auto-refresh)
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 fallback token from GENIUS_ACCESS_TOKEN env var');
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 = 'env_access_token';
184
+ lastAuthMode = 'env_direct_token';
119
185
  return cachedToken;
120
186
  }
121
187
 
122
- // 4. Cache token from disk (local dev convenience only)
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 'env_access_token';
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 fallback = getFallbackToken();
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
- fallbackTokenPresent: Boolean(fallback),
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(),