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.
@@ -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
+ }
@@ -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
+ }