@bzenky/spoti 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/app.d.ts +14 -0
  4. package/dist/app.js +122 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/auth/callback.d.ts +7 -0
  7. package/dist/auth/callback.js +79 -0
  8. package/dist/auth/callback.js.map +1 -0
  9. package/dist/auth/config.d.ts +7 -0
  10. package/dist/auth/config.js +31 -0
  11. package/dist/auth/config.js.map +1 -0
  12. package/dist/auth/index.d.ts +8 -0
  13. package/dist/auth/index.js +5 -0
  14. package/dist/auth/index.js.map +1 -0
  15. package/dist/auth/pkce.d.ts +7 -0
  16. package/dist/auth/pkce.js +15 -0
  17. package/dist/auth/pkce.js.map +1 -0
  18. package/dist/auth/spotify-auth-client.d.ts +38 -0
  19. package/dist/auth/spotify-auth-client.js +152 -0
  20. package/dist/auth/spotify-auth-client.js.map +1 -0
  21. package/dist/cli.d.ts +2 -0
  22. package/dist/cli.js +25 -0
  23. package/dist/cli.js.map +1 -0
  24. package/dist/services/auth.service.d.ts +36 -0
  25. package/dist/services/auth.service.js +127 -0
  26. package/dist/services/auth.service.js.map +1 -0
  27. package/dist/services/models.d.ts +20 -0
  28. package/dist/services/models.js +2 -0
  29. package/dist/services/models.js.map +1 -0
  30. package/dist/services/player.service.d.ts +12 -0
  31. package/dist/services/player.service.js +45 -0
  32. package/dist/services/player.service.js.map +1 -0
  33. package/dist/services/search.service.d.ts +7 -0
  34. package/dist/services/search.service.js +28 -0
  35. package/dist/services/search.service.js.map +1 -0
  36. package/dist/spotify/client.d.ts +27 -0
  37. package/dist/spotify/client.js +113 -0
  38. package/dist/spotify/client.js.map +1 -0
  39. package/dist/spotify/scopes.d.ts +1 -0
  40. package/dist/spotify/scopes.js +5 -0
  41. package/dist/spotify/scopes.js.map +1 -0
  42. package/dist/spotify/types.d.ts +50 -0
  43. package/dist/spotify/types.js +2 -0
  44. package/dist/spotify/types.js.map +1 -0
  45. package/dist/storage/credentials.d.ts +28 -0
  46. package/dist/storage/credentials.js +94 -0
  47. package/dist/storage/credentials.js.map +1 -0
  48. package/dist/storage/index.d.ts +2 -0
  49. package/dist/storage/index.js +2 -0
  50. package/dist/storage/index.js.map +1 -0
  51. package/dist/ui/output.d.ts +8 -0
  52. package/dist/ui/output.js +21 -0
  53. package/dist/ui/output.js.map +1 -0
  54. package/dist/ui/prompts.d.ts +2 -0
  55. package/dist/ui/prompts.js +22 -0
  56. package/dist/ui/prompts.js.map +1 -0
  57. package/dist/utils/errors.d.ts +26 -0
  58. package/dist/utils/errors.js +45 -0
  59. package/dist/utils/errors.js.map +1 -0
  60. package/dist/utils/time.d.ts +2 -0
  61. package/dist/utils/time.js +13 -0
  62. package/dist/utils/time.js.map +1 -0
  63. package/package.json +60 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spoti contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # spoti
2
+
3
+ A local-first command-line client for controlling Spotify through Spotify Connect.
4
+
5
+ > `spoti` is an independent project and is not affiliated with, endorsed by, or sponsored by Spotify AB.
6
+
7
+ `spoti` controls playback on an existing Spotify client or Connect device; it does not stream audio itself.
8
+
9
+ ## Requirements
10
+
11
+ - Node.js 20 or newer
12
+ - A Spotify developer application and client ID
13
+ - Spotify Premium for playback-control commands
14
+ - A Spotify client or Connect device available for playback
15
+
16
+ ## Spotify application setup
17
+
18
+ Create an application in the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and register this exact redirect URI:
19
+
20
+ ```text
21
+ http://127.0.0.1:43821/callback
22
+ ```
23
+
24
+ Export the application's client ID before running authentication:
25
+
26
+ ```bash
27
+ export SPOTIFY_CLIENT_ID="your-client-id"
28
+ ```
29
+
30
+ To use a different local callback, register it in the same Spotify application and set:
31
+
32
+ ```bash
33
+ export SPOTIFY_REDIRECT_URI="http://127.0.0.1:5000/callback"
34
+ ```
35
+
36
+ The redirect URI must use HTTPS except for local development, where an explicit `http://127.0.0.1` URI is allowed. Do not use `localhost` or wildcard redirect URIs. No client secret is needed or accepted by `spoti`; authentication uses Authorization Code with PKCE.
37
+
38
+ ## Installation
39
+
40
+ Once published to npm:
41
+
42
+ ```bash
43
+ npm install --global @bzenky/spoti
44
+ spoti --help
45
+ ```
46
+
47
+ ## Install for development
48
+
49
+ ```bash
50
+ npm install
51
+ npm run build
52
+ npm link
53
+ ```
54
+
55
+ Then verify the executable:
56
+
57
+ ```bash
58
+ spoti --help
59
+ ```
60
+
61
+ You can also run commands without linking:
62
+
63
+ ```bash
64
+ npm run dev -- --help
65
+ npm run dev -- status
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ Authenticate once through Spotify's browser authorization page:
71
+
72
+ ```bash
73
+ spoti login
74
+ spoti status
75
+ ```
76
+
77
+ Control playback:
78
+
79
+ ```bash
80
+ spoti now
81
+ spoti play "Numb"
82
+ spoti play "Numb" --first
83
+ spoti pause
84
+ spoti resume
85
+ spoti next
86
+ spoti previous
87
+ ```
88
+
89
+ Search without starting playback:
90
+
91
+ ```bash
92
+ spoti search "Breaking the Habit"
93
+ spoti search "Breaking the Habit" --limit 5
94
+ ```
95
+
96
+ Remove local credentials:
97
+
98
+ ```bash
99
+ spoti logout
100
+ ```
101
+
102
+ When attached to an interactive terminal, `spoti play <query>` asks you to select a result. In non-interactive usage it chooses the first result automatically; `--first` makes that behavior explicit.
103
+
104
+ ## Credentials
105
+
106
+ Credentials are stored locally in:
107
+
108
+ ```text
109
+ $XDG_CONFIG_HOME/spoti/credentials.json
110
+ ```
111
+
112
+ or, when `XDG_CONFIG_HOME` is not set:
113
+
114
+ ```text
115
+ ~/.config/spoti/credentials.json
116
+ ```
117
+
118
+ The credentials file is created with user-only permissions (`0600`). Access tokens refresh automatically. Keep `SPOTIFY_CLIENT_ID` available in the environment because Spotify requires it during token refresh. Never provide or store a Spotify client secret in `spoti`.
119
+
120
+ ## Spotify API policy
121
+
122
+ `spoti` uses Spotify data only for immediate command output and playback control. It does not persist Spotify catalog content, use Spotify data for machine-learning training, or download audio. Spotify content and links remain attributed to Spotify.
123
+
124
+ Endpoint work must be checked against Spotify's [official OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml) and [Developer Terms](https://developer.spotify.com/terms).
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ npm run typecheck
130
+ npm run lint
131
+ npm test
132
+ npm run build
133
+ ```
134
+
135
+ ## Current scope
136
+
137
+ Version `0.1.0` supports authentication, current playback, track search, direct track playback, pause/resume, and next/previous controls. Queue, volume, seek, shuffle, repeat, device switching, library features, JSON output, and a TUI are planned for later releases.
package/dist/app.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { Command } from 'commander';
2
+ import type { AuthService } from './services/auth.service.js';
3
+ import type { PlayerService } from './services/player.service.js';
4
+ import type { SearchService } from './services/search.service.js';
5
+ import { type Output } from './ui/output.js';
6
+ import { selectTrack } from './ui/prompts.js';
7
+ export interface AppDependencies {
8
+ auth: AuthService;
9
+ player: PlayerService;
10
+ search: SearchService;
11
+ output: Output;
12
+ chooseTrack?: typeof selectTrack;
13
+ }
14
+ export declare function createProgram(dependencies: AppDependencies): Command;
package/dist/app.js ADDED
@@ -0,0 +1,122 @@
1
+ import { Command } from 'commander';
2
+ import { formatPlayback, formatTrack } from './ui/output.js';
3
+ import { selectTrack } from './ui/prompts.js';
4
+ export function createProgram(dependencies) {
5
+ const program = new Command();
6
+ const chooseTrack = dependencies.chooseTrack ?? selectTrack;
7
+ program
8
+ .name('spoti')
9
+ .description('Control Spotify from your terminal')
10
+ .version('0.1.0');
11
+ program
12
+ .command('login')
13
+ .description('Log in to Spotify')
14
+ .action(async () => {
15
+ dependencies.output.log('Opening Spotify authorization in your browser…');
16
+ const user = await dependencies.auth.login();
17
+ dependencies.output.log(`✓ Logged in as ${user.displayName}`);
18
+ });
19
+ program
20
+ .command('logout')
21
+ .description('Remove locally stored Spotify credentials')
22
+ .action(async () => {
23
+ await dependencies.auth.logout();
24
+ dependencies.output.log('✓ Logged out');
25
+ });
26
+ program
27
+ .command('status')
28
+ .description('Show authentication status')
29
+ .action(async () => {
30
+ if (!(await dependencies.auth.isAuthenticated())) {
31
+ dependencies.output.log('Not logged in.\n\nRun: spoti login');
32
+ return;
33
+ }
34
+ const user = await dependencies.auth.getCurrentUser();
35
+ dependencies.output.log(`✓ Logged in as ${user.displayName}`);
36
+ });
37
+ program
38
+ .command('now')
39
+ .description('Show the current Spotify playback')
40
+ .action(async () => {
41
+ const playback = await dependencies.player.getCurrentPlayback();
42
+ dependencies.output.log(playback ? formatPlayback(playback) : 'Nothing is currently playing.');
43
+ });
44
+ program
45
+ .command('pause')
46
+ .description('Pause playback')
47
+ .action(async () => {
48
+ await dependencies.player.pause();
49
+ dependencies.output.log('⏸ Paused');
50
+ });
51
+ program
52
+ .command('resume')
53
+ .description('Resume playback')
54
+ .action(async () => {
55
+ await dependencies.player.resume();
56
+ dependencies.output.log('▶ Resumed');
57
+ });
58
+ program
59
+ .command('next')
60
+ .alias('n')
61
+ .description('Skip to the next track')
62
+ .action(async () => {
63
+ await dependencies.player.next();
64
+ dependencies.output.log('⏭ Skipped to next track');
65
+ });
66
+ program
67
+ .command('previous')
68
+ .alias('prev')
69
+ .description('Return to the previous track')
70
+ .action(async () => {
71
+ await dependencies.player.previous();
72
+ dependencies.output.log('⏮ Returned to previous track');
73
+ });
74
+ program
75
+ .command('search')
76
+ .description('Search Spotify tracks')
77
+ .argument('<query...>', 'track name to search for')
78
+ .option('-l, --limit <number>', 'maximum number of results', parseLimit, 10)
79
+ .action(async (queryParts, options) => {
80
+ const query = queryParts.join(' ');
81
+ const tracks = await dependencies.search.searchTracks(query, options.limit);
82
+ if (tracks.length === 0) {
83
+ dependencies.output.log(`No tracks found for "${query}".`);
84
+ return;
85
+ }
86
+ dependencies.output.log(tracks.map((track, index) => formatTrack(track, index)).join('\n'));
87
+ });
88
+ program
89
+ .command('play')
90
+ .description('Search for and play a track, or resume with no query')
91
+ .argument('[query...]', 'track name to search for')
92
+ .option('--first', 'play the first search result without prompting')
93
+ .action(async (queryParts, options) => {
94
+ const query = queryParts.join(' ').trim();
95
+ if (!query) {
96
+ await dependencies.player.resume();
97
+ dependencies.output.log('▶ Resumed');
98
+ return;
99
+ }
100
+ const tracks = await dependencies.search.searchTracks(query);
101
+ if (tracks.length === 0) {
102
+ dependencies.output.log(`No tracks found for "${query}".`);
103
+ return;
104
+ }
105
+ const track = options.first ? tracks[0] : await chooseTrack(tracks);
106
+ if (!track) {
107
+ dependencies.output.log('Selection cancelled.');
108
+ return;
109
+ }
110
+ await dependencies.player.playTrack(track.uri);
111
+ dependencies.output.log(`▶ Playing ${track.name} — ${track.artists.join(', ')}`);
112
+ });
113
+ return program;
114
+ }
115
+ function parseLimit(value) {
116
+ const limit = /^\d+$/.test(value) ? Number(value) : Number.NaN;
117
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
118
+ throw new Error('Search limit must be an integer between 1 and 10.');
119
+ }
120
+ return limit;
121
+ }
122
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ type BrowserOpener = (url: string) => Promise<unknown>;
2
+ export interface AuthorizationCallbackOptions {
3
+ timeoutMs?: number;
4
+ openBrowser?: BrowserOpener;
5
+ }
6
+ export declare function requestAuthorizationCode(authorizationUrl: string, redirectUri: string, expectedState: string, options?: AuthorizationCallbackOptions): Promise<string>;
7
+ export {};
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+ import open from 'open';
3
+ import { AppError, AuthorizationDeniedError, toError, } from '../utils/errors.js';
4
+ import { validateRedirectUri } from './config.js';
5
+ const DEFAULT_TIMEOUT_MS = 120_000;
6
+ export function requestAuthorizationCode(authorizationUrl, redirectUri, expectedState, options = {}) {
7
+ const callbackUrl = validateRedirectUri(redirectUri);
8
+ const port = Number(callbackUrl.port);
9
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
10
+ const openBrowser = options.openBrowser ?? open;
11
+ return new Promise((resolve, reject) => {
12
+ let settled = false;
13
+ let timer;
14
+ const server = createServer((request, response) => {
15
+ const requestUrl = new URL(request.url ?? '/', callbackUrl.origin);
16
+ if (request.method !== 'GET' || requestUrl.pathname !== callbackUrl.pathname) {
17
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
18
+ response.end('Not found');
19
+ return;
20
+ }
21
+ const finish = (error, code) => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ if (timer)
26
+ clearTimeout(timer);
27
+ server.close();
28
+ if (error)
29
+ reject(error);
30
+ else if (code)
31
+ resolve(code);
32
+ else
33
+ reject(new AppError('Spotify authorization returned no code.'));
34
+ };
35
+ if (requestUrl.searchParams.get('state') !== expectedState) {
36
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
37
+ response.end('Authorization state mismatch. You can close this window.');
38
+ return;
39
+ }
40
+ const authorizationError = requestUrl.searchParams.get('error');
41
+ if (authorizationError) {
42
+ const description = requestUrl.searchParams.get('error_description');
43
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
44
+ response.end('Spotify authorization was denied. You can close this window.');
45
+ finish(new AuthorizationDeniedError(description || `Spotify authorization failed: ${authorizationError}`));
46
+ return;
47
+ }
48
+ const code = requestUrl.searchParams.get('code');
49
+ if (!code) {
50
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
51
+ response.end('No authorization code was received. You can close this window.');
52
+ finish(new AppError('Spotify authorization returned no code.'));
53
+ return;
54
+ }
55
+ response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
56
+ response.end('Authorization complete. You can close this window.');
57
+ finish(null, code);
58
+ });
59
+ const fail = (error) => {
60
+ if (settled)
61
+ return;
62
+ settled = true;
63
+ if (timer)
64
+ clearTimeout(timer);
65
+ if (server.listening)
66
+ server.close();
67
+ reject(toError(error));
68
+ };
69
+ server.once('error', fail);
70
+ server.listen(port, '127.0.0.1', () => {
71
+ timer = setTimeout(() => {
72
+ fail(new AuthorizationDeniedError('Spotify authorization timed out after 2 minutes.'));
73
+ }, timeoutMs);
74
+ timer.unref();
75
+ void openBrowser(authorizationUrl).catch(fail);
76
+ });
77
+ });
78
+ }
79
+ //# sourceMappingURL=callback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callback.js","sourceRoot":"","sources":["../../src/auth/callback.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ export declare const DEFAULT_REDIRECT_URI = "http://127.0.0.1:43821/callback";
2
+ export interface AuthConfig {
3
+ clientId: string;
4
+ redirectUri: string;
5
+ }
6
+ export declare function loadAuthConfig(environment?: NodeJS.ProcessEnv): AuthConfig;
7
+ export declare function validateRedirectUri(redirectUri: string): URL;
@@ -0,0 +1,31 @@
1
+ import { ConfigurationError } from '../utils/errors.js';
2
+ export const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:43821/callback';
3
+ export function loadAuthConfig(environment = process.env) {
4
+ const clientId = environment.SPOTIFY_CLIENT_ID?.trim();
5
+ if (!clientId) {
6
+ throw new ConfigurationError('SPOTIFY_CLIENT_ID is required. Set it to your Spotify application client ID.');
7
+ }
8
+ const redirectUri = environment.SPOTIFY_REDIRECT_URI?.trim() || DEFAULT_REDIRECT_URI;
9
+ validateRedirectUri(redirectUri);
10
+ return { clientId, redirectUri };
11
+ }
12
+ export function validateRedirectUri(redirectUri) {
13
+ let url;
14
+ try {
15
+ url = new URL(redirectUri);
16
+ }
17
+ catch {
18
+ throw new ConfigurationError('SPOTIFY_REDIRECT_URI must be a valid URL.');
19
+ }
20
+ if (url.protocol !== 'http:' ||
21
+ url.hostname !== '127.0.0.1' ||
22
+ !url.port ||
23
+ url.username ||
24
+ url.password ||
25
+ url.search ||
26
+ url.hash) {
27
+ throw new ConfigurationError('SPOTIFY_REDIRECT_URI must be an HTTP URL on 127.0.0.1 with an explicit port, path, and no query or fragment.');
28
+ }
29
+ return url;
30
+ }
31
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/auth/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAExD,MAAM,CAAC,MAAM,oBAAoB,GAAG,iCAAiC,CAAC;AAOtE,MAAM,UAAU,cAAc,CAC5B,cAAiC,OAAO,CAAC,GAAG;IAE5C,MAAM,QAAQ,GAAG,WAAW,CAAC,iBAAiB,EAAE,IAAI,EAAE,CAAC;IACvD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,kBAAkB,CAC1B,8EAA8E,CAC/E,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG,WAAW,CAAC,oBAAoB,EAAE,IAAI,EAAE,IAAI,oBAAoB,CAAC;IACrF,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAEjC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,WAAmB;IACrD,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,kBAAkB,CAAC,2CAA2C,CAAC,CAAC;IAC5E,CAAC;IAED,IACE,GAAG,CAAC,QAAQ,KAAK,OAAO;QACxB,GAAG,CAAC,QAAQ,KAAK,WAAW;QAC5B,CAAC,GAAG,CAAC,IAAI;QACT,GAAG,CAAC,QAAQ;QACZ,GAAG,CAAC,QAAQ;QACZ,GAAG,CAAC,MAAM;QACV,GAAG,CAAC,IAAI,EACR,CAAC;QACD,MAAM,IAAI,kBAAkB,CAC1B,8GAA8G,CAC/G,CAAC;IACJ,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,8 @@
1
+ export { requestAuthorizationCode } from './callback.js';
2
+ export type { AuthorizationCallbackOptions } from './callback.js';
3
+ export { DEFAULT_REDIRECT_URI, loadAuthConfig, validateRedirectUri, } from './config.js';
4
+ export type { AuthConfig } from './config.js';
5
+ export { createCodeChallenge, generateOAuthState, generatePkcePair, } from './pkce.js';
6
+ export type { PkcePair } from './pkce.js';
7
+ export { SpotifyAuthClient } from './spotify-auth-client.js';
8
+ export type { ExchangeCodeInput, RefreshTokenInput, SpotifyAuthApi, TokenSet, } from './spotify-auth-client.js';
@@ -0,0 +1,5 @@
1
+ export { requestAuthorizationCode } from './callback.js';
2
+ export { DEFAULT_REDIRECT_URI, loadAuthConfig, validateRedirectUri, } from './config.js';
3
+ export { createCodeChallenge, generateOAuthState, generatePkcePair, } from './pkce.js';
4
+ export { SpotifyAuthClient } from './spotify-auth-client.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AAEzD,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,mBAAmB,GACpB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,7 @@
1
+ export interface PkcePair {
2
+ verifier: string;
3
+ challenge: string;
4
+ }
5
+ export declare function createCodeChallenge(verifier: string): string;
6
+ export declare function generatePkcePair(): PkcePair;
7
+ export declare function generateOAuthState(): string;
@@ -0,0 +1,15 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ export function createCodeChallenge(verifier) {
3
+ return createHash('sha256').update(verifier, 'ascii').digest('base64url');
4
+ }
5
+ export function generatePkcePair() {
6
+ const verifier = randomBytes(64).toString('base64url');
7
+ return {
8
+ verifier,
9
+ challenge: createCodeChallenge(verifier),
10
+ };
11
+ }
12
+ export function generateOAuthState() {
13
+ return randomBytes(32).toString('base64url');
14
+ }
15
+ //# sourceMappingURL=pkce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pkce.js","sourceRoot":"","sources":["../../src/auth/pkce.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAOtD,MAAM,UAAU,mBAAmB,CAAC,QAAgB;IAClD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAC5E,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,MAAM,QAAQ,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IACvD,OAAO;QACL,QAAQ;QACR,SAAS,EAAE,mBAAmB,CAAC,QAAQ,CAAC;KACzC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,kBAAkB;IAChC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,38 @@
1
+ import type { UserProfile } from '../services/models.js';
2
+ import { AppError } from '../utils/errors.js';
3
+ type Sleep = (milliseconds: number) => Promise<void>;
4
+ export interface TokenSet {
5
+ accessToken: string;
6
+ refreshToken?: string;
7
+ expiresIn: number;
8
+ }
9
+ export interface ExchangeCodeInput {
10
+ clientId: string;
11
+ code: string;
12
+ redirectUri: string;
13
+ codeVerifier: string;
14
+ }
15
+ export interface RefreshTokenInput {
16
+ clientId: string;
17
+ refreshToken: string;
18
+ }
19
+ export declare class OAuthTokenError extends AppError {
20
+ readonly code: string;
21
+ constructor(code: string, message: string);
22
+ }
23
+ export interface SpotifyAuthApi {
24
+ exchangeCode(input: ExchangeCodeInput): Promise<TokenSet>;
25
+ refreshAccessToken(input: RefreshTokenInput): Promise<TokenSet>;
26
+ getCurrentUser(accessToken: string): Promise<UserProfile>;
27
+ }
28
+ export declare class SpotifyAuthClient implements SpotifyAuthApi {
29
+ private readonly fetchImplementation;
30
+ private readonly sleeper;
31
+ constructor(fetchImplementation?: typeof globalThis.fetch, sleeper?: Sleep);
32
+ exchangeCode(input: ExchangeCodeInput): Promise<TokenSet>;
33
+ refreshAccessToken(input: RefreshTokenInput): Promise<TokenSet>;
34
+ getCurrentUser(accessToken: string): Promise<UserProfile>;
35
+ private requestToken;
36
+ private fetchWithTimeout;
37
+ }
38
+ export {};