startgg-oauth2-full 0.2.0 → 0.2.2

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 (60) hide show
  1. package/README.md +325 -51
  2. package/dist/auth/StartGGOAuth2.d.ts +85 -0
  3. package/dist/auth/StartGGOAuth2.js +306 -0
  4. package/dist/constants.d.ts +10 -0
  5. package/dist/constants.js +16 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/package.json +63 -22
  9. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -18
  10. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -13
  11. package/.github/pull_request_template.md +0 -30
  12. package/.github/workflows/ci.yml +0 -23
  13. package/.github/workflows/release.yml +0 -60
  14. package/AGENTS.md +0 -46
  15. package/CONTRIBUTING.md +0 -36
  16. package/STARTGG_OAUTH_SETUP.md +0 -41
  17. package/__tests__/authorize-url.test.ts +0 -62
  18. package/__tests__/bearer-token.test.ts +0 -21
  19. package/__tests__/handler.test.ts +0 -111
  20. package/__tests__/pkce.test.ts +0 -17
  21. package/examples/browser/README.md +0 -20
  22. package/examples/browser/index.html +0 -55
  23. package/examples/browser/package.json +0 -17
  24. package/examples/browser/src/main.ts +0 -105
  25. package/examples/browser/tsconfig.json +0 -11
  26. package/examples/browser/vite.config.ts +0 -8
  27. package/examples/discordjs/.env.example +0 -9
  28. package/examples/discordjs/README.md +0 -36
  29. package/examples/discordjs/package.json +0 -23
  30. package/examples/discordjs/src/bot.ts +0 -202
  31. package/examples/discordjs/tsconfig.json +0 -12
  32. package/examples/nextjs/.env.example +0 -7
  33. package/examples/nextjs/README.md +0 -33
  34. package/examples/nextjs/app/api/startgg/auth-url/route.ts +0 -36
  35. package/examples/nextjs/app/api/startgg/callback/route.ts +0 -55
  36. package/examples/nextjs/app/globals.css +0 -48
  37. package/examples/nextjs/app/layout.tsx +0 -15
  38. package/examples/nextjs/app/page.tsx +0 -93
  39. package/examples/nextjs/lib/pendingStore.ts +0 -37
  40. package/examples/nextjs/lib/startgg.ts +0 -28
  41. package/examples/nextjs/next-env.d.ts +0 -5
  42. package/examples/nextjs/next.config.mjs +0 -6
  43. package/examples/nextjs/package.json +0 -25
  44. package/examples/nextjs/tsconfig.json +0 -21
  45. package/examples/node/.env.example +0 -4
  46. package/examples/node/README.md +0 -27
  47. package/examples/node/package.json +0 -17
  48. package/examples/node/src/index.ts +0 -57
  49. package/examples/node/src/server.ts +0 -120
  50. package/examples/node/tsconfig.json +0 -12
  51. package/examples/vite/README.md +0 -22
  52. package/examples/vite/index.html +0 -41
  53. package/examples/vite/package.json +0 -18
  54. package/examples/vite/src/main.ts +0 -38
  55. package/examples/vite/tsconfig.json +0 -11
  56. package/examples/vite/vite.config.ts +0 -8
  57. package/jest.config.ts +0 -15
  58. package/jest.setup.ts +0 -29
  59. package/src/auth/StartGGOAuth2.ts +0 -378
  60. package/tsconfig.json +0 -27
package/README.md CHANGED
@@ -7,6 +7,9 @@
7
7
  <a href="https://www.npmjs.com/package/startgg-oauth2-full">
8
8
  <img alt="npm" src="https://img.shields.io/npm/v/startgg-oauth2-full">
9
9
  </a>
10
+ <a href="https://github.com/0xabadbabe-ops/startgg-oauth2-full/pkgs/npm/startgg-oauth2-full">
11
+ <img alt="GitHub Packages" src="https://img.shields.io/github/package-json/v/0xabadbabe-ops/startgg-oauth2-full?label=github%20pkg">
12
+ </a>
10
13
  <a href="./LICENSE">
11
14
  <img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green.svg">
12
15
  </a>
@@ -33,19 +36,26 @@ Cooked for *you* by 0xabadbabe - using a lot of 💜 and few lines of code.
33
36
  ... with hope tha this would help for any dev struggling with oauth2 start.gg specific.
34
37
 
35
38
  ```fish
36
- startgg-oauth2-full@0.1.0 test
37
- > jest --runInBand pkce
39
+ ┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:08:50]─[G:main =]
40
+ ╰─>$ npm test
41
+
42
+ > startgg-oauth2-full@0.2.2 test
43
+ > jest --runInBand
38
44
 
39
- PASS __tests__/pkce.test.ts
40
- PKCE helpers
41
- ✓ generateCodeVerifier length bounds (4 ms)
42
- ✓ computeCodeChallengeS256 deterministic (3 ms)
45
+ PASS __tests__/pkce.test.ts
46
+ PASS __tests__/handler.test.ts
47
+ PASS __tests__/authorize-url.test.ts
48
+ PASS __tests__/constants.test.ts
49
+ PASS __tests__/bearer-token.test.ts
43
50
 
44
- Test Suites: 1 passed, 1 total
45
- Tests: 2 passed, 2 total
51
+ Test Suites: 5 passed, 5 total
52
+ Tests: 28 passed, 28 total
46
53
  Snapshots: 0 total
47
- Time: 1.121 s, estimated 2 s
48
- Ran all test suites matching /pkce/i.
54
+ Time: 0.859 s, estimated 1 s
55
+ Ran all test suites.
56
+ ┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:10:09]─[G:main =]
57
+ ╰─>$
58
+ [0] 0:fish* "~/d/startgg-oauth2-fu" 21:10 26-wrz-26
49
59
  ```
50
60
 
51
61
  ## Installation
@@ -73,53 +83,245 @@ npm install @0xabadbabe-ops/startgg-oauth2-full
73
83
 
74
84
  ---
75
85
 
86
+ ## Start.gg Specifics
87
+
88
+ The library itself is spec-pure PKCE (RFC 7636) and never touches a client secret. Start.gg's deployment deviates from that spec in ways you must plan for:
89
+
90
+ - **`client_secret` is required at the token endpoint — even with PKCE.** Every Start.gg OAuth app is a confidential client. Keep the secret server-side and add it to the token request where you run the exchange. Every example in this repo shows the pattern (a small `exchangeTokenWithSecret` helper or a same-origin dev relay).
91
+ - **The token endpoint sends no CORS headers.** A browser page can never exchange the code directly against `api.start.gg`. Exchange on your server (Node, Next.js route handler, bot backend) or through a same-origin relay — see `examples/browser` for the relay approach.
92
+ - **Redirect URIs must match the registered callback exactly** — scheme, host, port, and path.
93
+ - **Authorize host.** The constants use `https://api.start.gg/oauth/authorize`. If that host ever answers the authorize request with a JSON login 404, issue the identical request (same parameters) against `https://start.gg/oauth/authorize` — it renders the login/consent screen and completes the same flow.
94
+
95
+ See [STARTGG_OAUTH_SETUP.md](./STARTGG_OAUTH_SETUP.md) for app registration, per-example redirect URIs, and environment variables.
96
+
97
+ ---
98
+
76
99
  ## Quick Start
77
100
 
78
- ### Browser (PKCE → Exchange)
101
+ ### 1. Build the authorize URL (browser or server)
79
102
 
80
103
  ```ts
81
- import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-full';
104
+ import { buildAuthorizeUrl, StartGGScope, STARTGG_ENDPOINTS } from 'startgg-oauth2-full';
82
105
 
83
106
  const cfg = {
84
107
  clientId: '<client-id>',
85
- authEndpoint: 'https://api.start.gg/oauth/authorize',
86
- redirectUri: 'https://your.app/callback',
108
+ authEndpoint: STARTGG_ENDPOINTS.authorize,
109
+ redirectUri: 'https://your.app/api/startgg/callback',
87
110
  };
88
111
 
112
+ const state = crypto.randomUUID();
89
113
  const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
90
114
  scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
91
- state: crypto.randomUUID(),
115
+ state,
92
116
  });
93
117
 
118
+ // Keep both tied together for the callback (session, or a server-side state store)
94
119
  sessionStorage.setItem('pkce:verifier', codeVerifier);
95
- sessionStorage.setItem('oauth:state', '<same-state>');
120
+ sessionStorage.setItem('oauth:state', state);
96
121
  location.href = url;
97
122
  ```
98
123
 
99
- ### Callback (Exchange + Bearer)
124
+ ### 2. Exchange the code server-side
125
+
126
+ Start.gg requires `client_secret` and sends no CORS headers, so the exchange happens on your server (the snippet every example in this repo uses):
127
+
128
+ ```ts
129
+ // e.g. app/api/startgg/callback — Node / Next.js route handler
130
+ import { BearerToken } from 'startgg-oauth2-full';
131
+
132
+ export async function GET(req: Request) {
133
+ const { searchParams } = new URL(req.url);
134
+ const code = searchParams.get('code')!;
135
+ // verify `state`, then load the code_verifier you stored for it
136
+
137
+ const body = new URLSearchParams({
138
+ grant_type: 'authorization_code',
139
+ code,
140
+ redirect_uri: process.env.STARTGG_REDIRECT_URI!,
141
+ code_verifier: storedVerifier,
142
+ client_id: process.env.STARTGG_CLIENT_ID!,
143
+ client_secret: process.env.STARTGG_CLIENT_SECRET!, // required by Start.gg even with PKCE
144
+ });
145
+
146
+ const res = await fetch('https://api.start.gg/oauth/access_token', {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/x-www-form-urlencoded',
150
+ Accept: 'application/json',
151
+ },
152
+ body,
153
+ });
154
+ if (!res.ok) return new Response(`Token exchange failed: HTTP ${res.status}`, { status: 502 });
155
+
156
+ const bearer = BearerToken.fromOAuthResponse(await res.json());
157
+ // store bearer per user; never expose the raw token or your secret to the page
158
+ }
159
+ ```
160
+
161
+ Against a provider *without* Start.gg's secret requirement, `createStartGGAuth2Handler(cfg).exchangeToken(code, codeVerifier, scopes)` performs the same spec-pure POST for you — see [Advanced Usage](#advanced-usage).
162
+
163
+ ### Using Constants for Start.gg Endpoints & Scopes
164
+
165
+ ```ts
166
+ import { STARTGG_ENDPOINTS, STARTGG_SCOPES, STARTGG_GQL_AUTH_HEADER, isValidStartGGScope } from 'startgg-oauth2-full';
167
+
168
+ // All official Start.gg endpoints
169
+ console.log(STARTGG_ENDPOINTS.authorize); // https://api.start.gg/oauth/authorize
170
+ console.log(STARTGG_ENDPOINTS.token); // https://api.start.gg/oauth/access_token
171
+ console.log(STARTGG_ENDPOINTS.gql); // https://api.start.gg/gql/alpha
172
+
173
+ // Valid scopes
174
+ console.log(STARTGG_SCOPES); // ['user.identity', 'user.email', 'tournament.manager', 'tournament.reporter']
175
+ console.log(STARTGG_GQL_AUTH_HEADER); // 'Bearer'
176
+
177
+ // Scope validation helper
178
+ const userInput = 'user.identity';
179
+ if (isValidStartGGScope(userInput)) {
180
+ // TypeScript narrows to StartGGScopeValue
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Advanced Usage
187
+
188
+ ### Refresh Token Rotation
100
189
 
101
190
  ```ts
102
191
  import { createStartGGAuth2Handler, BearerToken, StartGGScope } from 'startgg-oauth2-full';
103
192
 
104
- const params = new URLSearchParams(location.search);
105
- const code = params.get('code')!;
106
- const state = params.get('state')!;
107
- if (state !== sessionStorage.getItem('oauth:state')) throw new Error('State mismatch');
193
+ const handler = createStartGGAuth2Handler({ clientId, redirectUri });
108
194
 
109
- const handler = createStartGGAuth2Handler({
110
- clientId: '<client-id>',
111
- redirectUri: 'https://your.app/callback',
112
- authEndpoint: 'https://api.start.gg/oauth/authorize',
113
- tokenEndpoint: 'https://api.start.gg/oauth/token',
195
+ // Initial exchange
196
+ const res = await handler.exchangeToken(code, codeVerifier, [StartGGScope.USER_IDENTITY]);
197
+ let bearer = BearerToken.fromOAuthResponse(res);
198
+
199
+ // Later: refresh when expired (skew-aware)
200
+ if (bearer.willExpireWithin(60)) { // expires within 60s
201
+ const newRes = await handler.refreshToken(bearer.refreshToken!, [StartGGScope.USER_IDENTITY]);
202
+ bearer = BearerToken.fromOAuthResponse(newRes); // preserves refresh_token if server omits
203
+ }
204
+ ```
205
+
206
+ ### Custom PKCE Pair (Pre-generated Verifier/Challenge)
207
+
208
+ ```ts
209
+ import { buildAuthorizeUrl, computeCodeChallengeS256, generateCodeVerifier } from 'startgg-oauth2-full';
210
+
211
+ // Generate once, store securely
212
+ const codeVerifier = generateCodeVerifier(64);
213
+ const codeChallenge = await computeCodeChallengeS256(codeVerifier);
214
+
215
+ // Later: build URL with pre-computed pair
216
+ const { url } = await buildAuthorizeUrl(cfg, {
217
+ scopes: [StartGGScope.USER_IDENTITY],
218
+ state: crypto.randomUUID(),
219
+ codeVerifier,
220
+ codeChallenge, // validated against verifier
221
+ });
222
+ ```
223
+
224
+ ### Server-Side (Node/Next.js) with Secure State Store
225
+
226
+ ```ts
227
+ // lib/startgg.ts (Next.js example)
228
+ import { createStartGGAuth2Handler } from 'startgg-oauth2-full';
229
+
230
+ export function getStartggHandler() {
231
+ return createStartGGAuth2Handler({
232
+ clientId: process.env.STARTGG_CLIENT_ID!,
233
+ redirectUri: process.env.STARTGG_REDIRECT_URI!,
234
+ // authEndpoint and tokenEndpoint default to STARTGG_ENDPOINTS
235
+ });
236
+ }
237
+
238
+ // app/api/startgg/callback/route.ts
239
+ import { getStartggHandler } from '@/lib/startgg';
240
+ import { consumePending } from '@/lib/pendingStore'; // your secure store
241
+
242
+ export async function GET(req: Request) {
243
+ const { searchParams } = new URL(req.url);
244
+ const code = searchParams.get('code')!;
245
+ const state = searchParams.get('state')!;
246
+
247
+ const pending = consumePending(state); // delete after use
248
+ if (!pending) return new Response('Invalid state', { status: 400 });
249
+
250
+ const res = await getStartggHandler().exchangeToken(code, pending.codeVerifier, pending.scopes);
251
+ return Response.json({ ok: true, scope: res.scope });
252
+ }
253
+ ```
254
+
255
+ ### GraphQL Calls with Bearer Token
256
+
257
+ ```ts
258
+ import { BearerToken, STARTGG_ENDPOINTS, STARTGG_GQL_AUTH_HEADER } from 'startgg-oauth2-full';
259
+
260
+ const bearer = BearerToken.fromOAuthResponse(tokenResponse);
261
+
262
+ const query = `
263
+ query GetUser { user { id, name, email } }
264
+ `;
265
+
266
+ const response = await fetch(STARTGG_ENDPOINTS.gql, {
267
+ method: 'POST',
268
+ headers: {
269
+ 'Content-Type': 'application/json',
270
+ [STARTGG_GQL_AUTH_HEADER]: bearer.toAuthHeader().Authorization,
271
+ },
272
+ body: JSON.stringify({ query }),
114
273
  });
115
274
 
116
- const res = await handler.exchangeToken(code, sessionStorage.getItem('pkce:verifier')!, [
117
- StartGGScope.USER_IDENTITY,
118
- StartGGScope.USER_EMAIL,
119
- ]);
275
+ const { data } = await response.json();
276
+ ```
277
+
278
+ ### Error Handling
120
279
 
121
- const bearer = BearerToken.fromOAuthResponse(res);
122
- fetch('https://api.start.gg/your-endpoint', { headers: bearer.toAuthHeader() });
280
+ ```ts
281
+ import { createStartGGAuth2Handler, OAuth2Error, ScopeValidationError, StartGGScope } from 'startgg-oauth2-full';
282
+
283
+ const handler = createStartGGAuth2Handler(cfg);
284
+
285
+ try {
286
+ const res = await handler.exchangeToken(code, verifier, [StartGGScope.USER_IDENTITY]);
287
+ } catch (err) {
288
+ if (err instanceof ScopeValidationError) {
289
+ console.error('Requested scopes:', err.requestedScopes); // ['user.identity', 'user.email']
290
+ console.error('Granted scopes:', err.grantedScopes); // ['user.identity'] (present when server returned scope)
291
+ } else if (err instanceof OAuth2Error) {
292
+ console.error('OAuth error:', err.code); // TOKEN_EXCHANGE_FAILED, INVALID_PKCE_PAIR, etc.
293
+ console.error('Details:', err.details); // parsed JSON or { raw: '...' }
294
+ } else {
295
+ throw err;
296
+ }
297
+ }
298
+ ```
299
+
300
+ ### Cloudflare Workers / Edge Runtime
301
+
302
+ ```ts
303
+ // Works in Cloudflare Workers, Vercel Edge, Deno, Bun
304
+ import { createStartGGAuth2Handler, StartGGScope } from 'startgg-oauth2-full';
305
+
306
+ export default {
307
+ async fetch(request: Request, env: Env): Promise<Response> {
308
+ const handler = createStartGGAuth2Handler({
309
+ clientId: env.STARTGG_CLIENT_ID,
310
+ redirectUri: new URL('/callback', request.url).href,
311
+ });
312
+
313
+ const url = new URL(request.url);
314
+ if (url.pathname === '/callback') {
315
+ const code = url.searchParams.get('code')!;
316
+ const state = url.searchParams.get('state')!;
317
+ // validate state from your KV/D1 store...
318
+ const res = await handler.exchangeToken(code, storedVerifier, [StartGGScope.USER_IDENTITY]);
319
+ return Response.redirect('/dashboard');
320
+ }
321
+
322
+ // ... rest of handler
323
+ },
324
+ };
123
325
  ```
124
326
 
125
327
  ---
@@ -127,11 +329,12 @@ fetch('https://api.start.gg/your-endpoint', { headers: bearer.toAuthHeader() });
127
329
  ## Scripts
128
330
 
129
331
  ```bash
130
- npm run build # tsc build
131
- npm test # Jest tests (needs ts-node installed)
332
+ npm run build # compile TypeScript (tsc)
333
+ npm test # full Jest suite (ts-jest)
334
+ npm test -- pkce # target a single spec
132
335
  ```
133
336
 
134
- Examples ship as their own workspaces—hop into each folder, install once, then use the local scripts:
337
+ Examples ship as their own workspaces — hop into each folder, install once, then use the local scripts. Every example performs a real user login against Start.gg:
135
338
 
136
339
  - Browser (Vite): `cd examples/browser && npm install && npm run dev`
137
340
  - Node CLI/server: `cd examples/node && npm install && npm run dev`
@@ -152,6 +355,8 @@ Examples ship as their own workspaces—hop into each folder, install once, then
152
355
  - `BearerToken`
153
356
  - `fromOAuthResponse(res, nowMs?, skewSeconds?)`
154
357
  - `isExpired()`, `willExpireWithin()`, `toAuthHeader()`, `assertUsable()`
358
+ - `STARTGG_ENDPOINTS`, `STARTGG_SCOPES`, `STARTGG_GQL_AUTH_HEADER`
359
+ - `isValidStartGGScope(value): boolean`
155
360
 
156
361
  ### Scopes
157
362
 
@@ -168,7 +373,7 @@ enum StartGGScope {
168
373
 
169
374
  ## Scope Semantics
170
375
 
171
- - If response **includes** `scope`, it’s validated; missing required → `ScopeValidationError`.
376
+ - If response **includes** `scope`, it's validated; missing required → `ScopeValidationError`.
172
377
  - If response **omits** `scope`, treat as unchanged (RFC 6749).
173
378
  - Refresh: preserve prior `refresh_token` if omitted by server.
174
379
 
@@ -181,17 +386,73 @@ class OAuth2Error extends Error {
181
386
  code?: string; // e.g., TOKEN_EXCHANGE_FAILED
182
387
  details?: unknown; // parsed JSON or { raw: string }
183
388
  }
389
+
390
+ class ScopeValidationError extends Error {
391
+ requestedScopes: string[]; // what you asked for
392
+ grantedScopes?: string[]; // what the server granted (when it returned scope)
393
+ }
184
394
  ```
185
395
 
186
396
  ---
187
397
 
188
398
  ## Examples
189
399
 
190
- - Browser (Vite SPA): `examples/browser/`
191
- - Node CLI + redirect catcher: `examples/node/`
192
- - Discord bot (discord.js v14): `examples/discordjs/`
193
- - Next.js (App Router): `examples/nextjs/`
194
- - Frontend Vite scaffold: `examples/vite/`
400
+ | Example | Stack | Token exchange |
401
+ | --- | --- | --- |
402
+ | [`examples/node/`](./examples/node) | Node CLI + local redirect server | Server-side POST with `client_secret` |
403
+ | [`examples/nextjs/`](./examples/nextjs) | Next.js App Router | Route handler POST with `client_secret` |
404
+ | [`examples/discordjs/`](./examples/discordjs) | discord.js v14 bot | Bot backend POST with `client_secret` |
405
+ | [`examples/browser/`](./examples/browser) | Vite SPA | Same-origin dev relay (`/startgg/token`) injects the secret |
406
+ | [`examples/vite/`](./examples/vite) | Minimal Vite scaffold | Same-origin dev relay (`/startgg/token`) injects the secret |
407
+
408
+ Each example documents its redirect URI and environment variables in its own README.
409
+
410
+ ---
411
+
412
+ ## Vercel Connect Integration
413
+
414
+ Use Start.gg with [Vercel Connect](https://vercel.com/docs/connect) for secure, short-lived tokens without storing credentials in your environment.
415
+
416
+ ### Quick Setup
417
+
418
+ ```bash
419
+ # 1. Create Start.gg OAuth app (redirect: https://connect.vercel.com/callback)
420
+ # 2. Create Custom OAuth connector in Vercel Connect
421
+ vercel connect create https://api.start.gg/oauth/authorize --name startgg
422
+ # 3. Attach to your project
423
+ vercel connect attach oauth/startgg
424
+ ```
425
+
426
+ ### Use in Your Code
427
+
428
+ ```bash
429
+ npm install @vercel/connect startgg-vercel-connect
430
+ ```
431
+
432
+ ```ts
433
+ import { getConnectorUid, getLoginScopes, createTokenParams } from 'startgg-vercel-connect';
434
+ import { getTokenResponse, UserAuthorizationRequiredError } from '@vercel/connect';
435
+
436
+ const token = await getTokenResponse(
437
+ getConnectorUid(),
438
+ createTokenParams({
439
+ subject: { type: 'user', id: 'user_123' },
440
+ scopes: getLoginScopes(true),
441
+ })
442
+ );
443
+
444
+ // Use with Start.gg GraphQL API
445
+ const response = await fetch('https://api.start.gg/gql/alpha', {
446
+ method: 'POST',
447
+ headers: {
448
+ 'Content-Type': 'application/json',
449
+ 'Authorization': `Bearer ${token.token}`,
450
+ },
451
+ body: JSON.stringify({ query: '{ viewer { id name } }' }),
452
+ });
453
+ ```
454
+
455
+ See [VERCEL_CONNECT_STARTGG.md](./VERCEL_CONNECT_STARTGG.md) for the complete guide and [packages/startgg-vercel-connect](./packages/startgg-vercel-connect) for the helper package.
195
456
 
196
457
  ---
197
458
 
@@ -207,27 +468,33 @@ GitHub Actions runs TypeScript build + Jest on push/PR (Node 18 & 20). See `.git
207
468
  startgg-oauth2-full/
208
469
  ├── README.md
209
470
  ├── AGENTS.md
471
+ ├── CONTRIBUTING.md
210
472
  ├── LICENSE
473
+ ├── STARTGG_OAUTH_SETUP.md # Start.gg app registration & env vars
474
+ ├── VERCEL_CONNECT_STARTGG.md # Vercel Connect guide
211
475
  ├── package.json
212
476
  ├── tsconfig.json
213
477
  ├── jest.config.ts
214
478
  ├── jest.setup.ts
215
- ├── .gitignore
216
- ├── .npmrc
217
479
  ├── src/
218
- │ └── auth/
219
- │ └── StartGGOAuth2.ts
480
+ │ ├── auth/
481
+ │ │ └── StartGGOAuth2.ts # PKCE + handler + BearerToken
482
+ │ ├── constants.ts # STARTGG_ENDPOINTS, scopes
483
+ │ └── index.ts
220
484
  ├── __tests__/
221
485
  │ ├── authorize-url.test.ts
222
486
  │ ├── bearer-token.test.ts
487
+ │ ├── constants.test.ts
223
488
  │ ├── handler.test.ts
224
489
  │ └── pkce.test.ts
225
490
  ├── examples/
226
- │ ├── browser/ # Vanilla browser Vite demo
227
- │ ├── node/ # CLI + local redirect server
228
- │ ├── discordjs/ # Discord bot OAuth flow
229
- │ ├── nextjs/ # Next.js App Router example
230
- │ └── vite/ # Minimal Vite SPA scaffold
491
+ │ ├── browser/ # Vite SPA — dev relay exchange
492
+ │ ├── node/ # CLI + local redirect server
493
+ │ ├── discordjs/ # Discord bot OAuth flow
494
+ │ ├── nextjs/ # Next.js App Router example
495
+ │ └── vite/ # Minimal Vite SPA scaffold
496
+ ├── packages/
497
+ │ └── startgg-vercel-connect/
231
498
  └── .github/
232
499
  ├── ISSUE_TEMPLATE/
233
500
  │ ├── bug_report.md
@@ -243,13 +510,20 @@ startgg-oauth2-full/
243
510
  - Use and verify `state`.
244
511
  - Keep `code_verifier` private.
245
512
  - Never log tokens; always HTTPS.
513
+ - Never ship `client_secret` to the browser — Start.gg requires it, so the exchange belongs on a server you control.
514
+
515
+ ---
516
+
517
+ ## Contributing
518
+
519
+ See [CONTRIBUTING.md](./CONTRIBUTING.md). PRs should include Jest coverage for new behaviour and list validation steps (`npm test`, demo transcripts for interactive flows).
246
520
 
247
521
  ---
248
522
 
249
523
  ## License
250
524
 
251
525
  **MIT License**
252
- Copyright © 2025 0xABADBABE-ops
526
+ Copyright © 2026 0xABADBABE-ops
253
527
 
254
528
  Permission is hereby granted, free of charge, to any person obtaining a copy
255
529
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,85 @@
1
+ export declare enum StartGGScope {
2
+ USER_IDENTITY = "user.identity",
3
+ USER_EMAIL = "user.email",
4
+ TOURNAMENT_MANAGER = "tournament.manager",
5
+ TOURNAMENT_REPORTER = "tournament.reporter"
6
+ }
7
+ export interface IOAuth2HandlerWithPKCE {
8
+ exchangeToken(code: string, codeVerifier: string, expectedScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
9
+ refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
10
+ }
11
+ export declare class OAuth2Error extends Error {
12
+ readonly code?: string;
13
+ readonly details?: unknown;
14
+ constructor(message: string, code?: string, details?: unknown);
15
+ }
16
+ export interface OAuth2TokenResponse {
17
+ access_token: string;
18
+ token_type: string;
19
+ expires_in?: number;
20
+ refresh_token?: string;
21
+ scope?: string;
22
+ [k: string]: unknown;
23
+ }
24
+ export declare class ScopeValidationError extends OAuth2Error {
25
+ readonly requestedScopes: string[];
26
+ readonly grantedScopes?: string[] | undefined;
27
+ constructor(message: string, requestedScopes: string[], grantedScopes?: string[] | undefined);
28
+ }
29
+ export declare function generateCodeVerifier(length?: number): string;
30
+ /** S256 challenge for a verifier. */
31
+ export declare function computeCodeChallengeS256(codeVerifier: string): Promise<string>;
32
+ export type AuthorizeUrlOptions = {
33
+ scopes: (StartGGScope | string)[];
34
+ state?: string;
35
+ prompt?: string;
36
+ codeVerifier?: string;
37
+ codeChallenge?: string;
38
+ extras?: Record<string, string | number | boolean | undefined>;
39
+ };
40
+ export type BuiltAuthorizeUrl = {
41
+ url: string;
42
+ codeVerifier: string;
43
+ codeChallenge: string;
44
+ };
45
+ /** Build an authorization URL with PKCE (S256). */
46
+ export declare function buildAuthorizeUrl(cfg: {
47
+ clientId: string;
48
+ authEndpoint: string;
49
+ redirectUri: string;
50
+ }, opts: AuthorizeUrlOptions): Promise<BuiltAuthorizeUrl>;
51
+ export declare class BearerToken {
52
+ readonly accessToken: string;
53
+ readonly tokenType: "Bearer";
54
+ readonly refreshToken?: string;
55
+ readonly expiresAt?: number;
56
+ private constructor();
57
+ static fromOAuthResponse(res: OAuth2TokenResponse, nowMs?: number, skewSeconds?: number): BearerToken;
58
+ isExpired(nowMs?: number): boolean;
59
+ willExpireWithin(seconds: number, nowMs?: number): boolean;
60
+ toAuthHeader(): Record<string, string>;
61
+ assertUsable(nowMs?: number): void;
62
+ }
63
+ export declare class StartGGOAuth2Handler implements IOAuth2HandlerWithPKCE {
64
+ private readonly config;
65
+ constructor(config: {
66
+ clientId: string;
67
+ redirectUri: string;
68
+ authEndpoint: string;
69
+ tokenEndpoint: string;
70
+ fetchTimeoutMs?: number;
71
+ });
72
+ /** Exchange authorization code for tokens (PKCE). */
73
+ exchangeToken(code: string, codeVerifier: string, expectedScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
74
+ /** Refresh access token; preserve prior refresh token if server omits rotation.
75
+ * Per RFC 6749, scope should only be included when requesting a subset of original scopes. */
76
+ refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
77
+ }
78
+ /** Factory */
79
+ export declare function createStartGGAuth2Handler(params: {
80
+ clientId: string;
81
+ redirectUri: string;
82
+ authEndpoint?: string;
83
+ tokenEndpoint?: string;
84
+ fetchTimeoutMs?: number;
85
+ }): StartGGOAuth2Handler;