musix-box 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.
- package/README.md +72 -0
- package/package.json +34 -0
- package/src/index.ts +225 -0
- package/src/providers/media_provider.ts +152 -0
- package/src/providers/spotify/spotify_api_client.ts +220 -0
- package/src/providers/spotify/spotify_oauth.ts +197 -0
- package/src/providers/spotify/spotify_provider.ts +159 -0
- package/src/providers/spotify/spotify_session.ts +95 -0
- package/src/spotify_handler.ts +128 -0
- package/src/tool_errors.ts +180 -0
- package/src/types.ts +32 -0
- package/src/worker.ts +41 -0
- package/tests/providers/media_provider.test.ts +43 -0
- package/tests/providers/spotify/spotify_oauth.test.ts +106 -0
- package/tests/providers/spotify/spotify_provider.test.ts +218 -0
- package/tests/providers/spotify/spotify_session.test.ts +78 -0
- package/tests/secret_hygiene.test.ts +93 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +39 -0
- package/wrangler.example.jsonc +51 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getAccessToken,
|
|
5
|
+
NoSpotifySessionError,
|
|
6
|
+
storeSession,
|
|
7
|
+
} from "../../../src/providers/spotify/spotify_session";
|
|
8
|
+
import type { SpotifyTokens } from "../../../src/providers/spotify/spotify_oauth";
|
|
9
|
+
|
|
10
|
+
/** A minimal in-memory stand-in for the one KV method pair this module calls. */
|
|
11
|
+
function fakeKv(): KVNamespace {
|
|
12
|
+
const store = new Map<string, string>();
|
|
13
|
+
return {
|
|
14
|
+
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
|
15
|
+
put: vi.fn(async (key: string, value: string) => {
|
|
16
|
+
store.set(key, value);
|
|
17
|
+
}),
|
|
18
|
+
} as unknown as KVNamespace;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TOKENS: SpotifyTokens = {
|
|
22
|
+
accessToken: "access-1",
|
|
23
|
+
refreshToken: "refresh-1",
|
|
24
|
+
expiresAt: 1_000_000,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("getAccessToken", () => {
|
|
28
|
+
test("throws NoSpotifySessionError when nothing is stored", async () => {
|
|
29
|
+
const kv = fakeKv();
|
|
30
|
+
await expect(
|
|
31
|
+
getAccessToken(kv, "user-1", { clientId: "client", now: () => 0 }),
|
|
32
|
+
).rejects.toThrow(NoSpotifySessionError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns the stored access token when it is still valid", async () => {
|
|
36
|
+
const kv = fakeKv();
|
|
37
|
+
await storeSession(kv, "user-1", TOKENS);
|
|
38
|
+
|
|
39
|
+
const token = await getAccessToken(kv, "user-1", {
|
|
40
|
+
clientId: "client",
|
|
41
|
+
now: () => 0,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(token).toBe("access-1");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("refreshes and re-stores when the token is within the refresh margin", async () => {
|
|
48
|
+
const kv = fakeKv();
|
|
49
|
+
await storeSession(kv, "user-1", TOKENS);
|
|
50
|
+
|
|
51
|
+
globalThis.fetch = vi.fn(async () =>
|
|
52
|
+
new Response(
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
access_token: "access-2",
|
|
55
|
+
refresh_token: "refresh-2",
|
|
56
|
+
expires_in: 3600,
|
|
57
|
+
}),
|
|
58
|
+
{ status: 200 },
|
|
59
|
+
),
|
|
60
|
+
) as unknown as typeof fetch;
|
|
61
|
+
|
|
62
|
+
// 999_500 is inside the 60_000ms margin before TOKENS.expiresAt (1_000_000).
|
|
63
|
+
const token = await getAccessToken(kv, "user-1", {
|
|
64
|
+
clientId: "client",
|
|
65
|
+
now: () => 999_500,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(token).toBe("access-2");
|
|
69
|
+
|
|
70
|
+
// The refreshed token must have been written back, or the next call
|
|
71
|
+
// would refresh again unnecessarily on every request.
|
|
72
|
+
const second = await getAccessToken(kv, "user-1", {
|
|
73
|
+
clientId: "client",
|
|
74
|
+
now: () => 999_500,
|
|
75
|
+
});
|
|
76
|
+
expect(second).toBe("access-2");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* These tests check the repository rather than the code.
|
|
9
|
+
*
|
|
10
|
+
* A secret scanner looks for things that resemble credentials. It would not
|
|
11
|
+
* have caught the failure that prompted these tests, which was a repository
|
|
12
|
+
* with no `.gitignore` at all — there was no secret to find yet, only a
|
|
13
|
+
* missing floor for one to fall through. That is a structural property, and
|
|
14
|
+
* structure is what is asserted here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const repositoryRoot = join(import.meta.dirname, "..");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Files that must never be committed, whatever they happen to contain today.
|
|
21
|
+
*
|
|
22
|
+
* These are the conventional homes for credentials in this project: `.env`
|
|
23
|
+
* for shared keys, `.dev.vars` for the Workers runtime, `wrangler.jsonc` for
|
|
24
|
+
* a deployment's own configuration.
|
|
25
|
+
*/
|
|
26
|
+
const MUST_BE_IGNORED = [".env", ".dev.vars"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run a git command in this repository.
|
|
30
|
+
*
|
|
31
|
+
* @param args - Arguments passed to git.
|
|
32
|
+
* @returns The command's standard output, trimmed.
|
|
33
|
+
*/
|
|
34
|
+
function git(args: string[]): string {
|
|
35
|
+
return execFileSync("git", args, {
|
|
36
|
+
cwd: repositoryRoot,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
}).trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("secret hygiene", () => {
|
|
42
|
+
test("a .gitignore exists", () => {
|
|
43
|
+
// The floor. Without it every other guarantee here is accidental.
|
|
44
|
+
expect(existsSync(join(repositoryRoot, ".gitignore"))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test.each(MUST_BE_IGNORED)("%s is ignored", (path) => {
|
|
48
|
+
// `check-ignore` exits non-zero when the path is not ignored, which
|
|
49
|
+
// execFileSync turns into a throw.
|
|
50
|
+
expect(() => git(["check-ignore", "--quiet", path])).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("nothing that should be ignored is already tracked", () => {
|
|
54
|
+
// A .gitignore does nothing for a file that was committed before it was
|
|
55
|
+
// written, and that is the case most likely to go unnoticed.
|
|
56
|
+
const tracked = git(["ls-files"]).split("\n").filter(Boolean);
|
|
57
|
+
const shouldNotBeTracked = tracked.filter((path) => {
|
|
58
|
+
const name = path.split("/").pop() ?? "";
|
|
59
|
+
return (
|
|
60
|
+
name === ".env" ||
|
|
61
|
+
name.startsWith(".env.") ||
|
|
62
|
+
name === ".dev.vars" ||
|
|
63
|
+
name.endsWith(".pem") ||
|
|
64
|
+
name.endsWith(".key")
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
expect(shouldNotBeTracked).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("no tracked file contains a credential", () => {
|
|
71
|
+
// A blunt pattern check over the working tree. gitleaks in the pre-commit
|
|
72
|
+
// hook is the thorough version and reads the whole history; this catches
|
|
73
|
+
// the same thing from inside the test suite, so a repository set up
|
|
74
|
+
// without the hook is not left with nothing.
|
|
75
|
+
const credentialPattern =
|
|
76
|
+
/(ghp_[A-Za-z0-9]{20}|github_pat_[A-Za-z0-9_]{20}|sk-[A-Za-z0-9]{32}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/;
|
|
77
|
+
|
|
78
|
+
const tracked = git(["ls-files"]).split("\n").filter(Boolean);
|
|
79
|
+
const offenders = tracked.filter((path) => {
|
|
80
|
+
const fullPath = join(repositoryRoot, path);
|
|
81
|
+
if (!existsSync(fullPath)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// This test file necessarily contains the patterns it searches for.
|
|
85
|
+
if (path.endsWith("secret_hygiene.test.ts")) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return credentialPattern.test(readFileSync(fullPath, "utf8"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(offenders).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2021",
|
|
4
|
+
"lib": ["es2021"],
|
|
5
|
+
"module": "es2022",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"types": ["@cloudflare/workers-types", "node"],
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"checkJs": false,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"strict": true,
|
|
15
|
+
"skipLibCheck": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
|
18
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
|
|
2
|
+
import { defineConfig } from "vitest/config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Two kinds of test, matching `other-memory`'s split minus its third
|
|
6
|
+
* project: `worker` holds the logic tests, running inside workerd so
|
|
7
|
+
* `crypto.subtle` (needed for PKCE) behaves exactly as it does in
|
|
8
|
+
* production. `repository` holds tests that inspect the repo itself.
|
|
9
|
+
*
|
|
10
|
+
* No `integration` project yet — those would need a real Spotify account
|
|
11
|
+
* and OAuth app to run against, which this package does not have configured.
|
|
12
|
+
* Add it the same way `other-memory`'s exists once that credential is set up.
|
|
13
|
+
*/
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
test: {
|
|
16
|
+
projects: [
|
|
17
|
+
{
|
|
18
|
+
extends: true,
|
|
19
|
+
plugins: [
|
|
20
|
+
cloudflareTest({
|
|
21
|
+
wrangler: { configPath: "./wrangler.jsonc" },
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
24
|
+
test: {
|
|
25
|
+
name: "worker",
|
|
26
|
+
include: ["tests/**/*.test.ts"],
|
|
27
|
+
exclude: ["tests/**/*.source.test.ts", "tests/secret_hygiene.test.ts"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
test: {
|
|
32
|
+
name: "repository",
|
|
33
|
+
environment: "node",
|
|
34
|
+
include: ["tests/secret_hygiene.test.ts", "tests/**/*.source.test.ts"],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
|
|
4
|
+
// Copy this to wrangler.jsonc and fill in the marked values.
|
|
5
|
+
// wrangler.jsonc is gitignored, so your account details stay yours.
|
|
6
|
+
|
|
7
|
+
"name": "musix-box",
|
|
8
|
+
"main": "src/worker.ts",
|
|
9
|
+
"compatibility_date": "2025-03-10",
|
|
10
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
11
|
+
|
|
12
|
+
"migrations": [
|
|
13
|
+
{
|
|
14
|
+
"tag": "v1",
|
|
15
|
+
"new_sqlite_classes": ["MusixBoxMCP"]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"durable_objects": {
|
|
19
|
+
"bindings": [
|
|
20
|
+
{
|
|
21
|
+
"name": "MCP_OBJECT",
|
|
22
|
+
"class_name": "MusixBoxMCP"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
"kv_namespaces": [
|
|
28
|
+
{
|
|
29
|
+
"binding": "OAUTH_KV",
|
|
30
|
+
// From `npx wrangler kv namespace create OAUTH_KV`.
|
|
31
|
+
"id": "0000000000000000000000000000test"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"binding": "SPOTIFY_TOKENS",
|
|
35
|
+
// From `npx wrangler kv namespace create SPOTIFY_TOKENS`.
|
|
36
|
+
"id": "0000000000000000000000000000test"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
|
|
40
|
+
// Not secret. Secrets are set with `wrangler secret put` — see the README.
|
|
41
|
+
"vars": {
|
|
42
|
+
// Only this Spotify account may use the server. An authenticated
|
|
43
|
+
// stranger is still a stranger. Find it via the /me endpoint or the
|
|
44
|
+
// Spotify account overview page.
|
|
45
|
+
"ALLOWED_SPOTIFY_USER_ID": "example"
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
"observability": {
|
|
49
|
+
"enabled": true
|
|
50
|
+
}
|
|
51
|
+
}
|