{"_id":"@antitamper/oauth","name":"@antitamper/oauth","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@antitamper/oauth","version":"0.1.0","description":"TypeScript SDK for EasyAntiTamper OAuth 2.1 — PKCE, device flow, client credentials, token management, typed API client, and webhook verification.","license":"MIT","author":{"name":"EasyAntiTamper"},"homepage":"https://antitamper.co","repository":{"type":"git","url":"git+https://github.com/MagmaVRC/EasyAntiTamper.git"},"keywords":["oauth","oauth2","pkce","antitamper","licensing","easyantitamper"],"type":"module","sideEffects":false,"types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"engines":{"node":">=22"},"scripts":{"build":"tsup src/index.ts --format esm --dts --clean --minify --target node22","test":"vitest run","typecheck":"tsc --noEmit","prepublishOnly":"npm run typecheck && npm run test && npm run build"},"publishConfig":{"access":"public"},"devDependencies":{"tsup":"^8.3.5","typescript":"^5.7.2","vitest":"^2.1.8"},"gitHead":"a10aba31e2d88b5faba7c053d464f4d260576218","_id":"@antitamper/oauth@0.1.0","bugs":{"url":"https://github.com/MagmaVRC/EasyAntiTamper/issues"},"_nodeVersion":"25.8.2","_npmVersion":"11.11.1","dist":{"integrity":"sha512-TuUPaY+5J08xzFPIE0IbG7VSyeRyA4Vj87oAgPV3+JNpxvS9DbuvtW4R6hukBoKxdgYmLzqNVodkDdgC9xGu0g==","shasum":"5733fb16bfdba13ade777c983e8f0d150de4db48","tarball":"https://registry.npmjs.org/@antitamper/oauth/-/oauth-0.1.0.tgz","fileCount":4,"unpackedSize":40974,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIFCATVHn3f8NcDml2/cKbbKxUXeINoBAG/AhwBa37CPmAiArYCDLcb7OlhrLPtUlMN3NJ1sbCI0BJuqsydVWiYVu/g=="}]},"_npmUser":{"name":"antitamper","email":"admin@antitamper.co"},"directories":{},"maintainers":[{"name":"antitamper","email":"admin@antitamper.co"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/oauth_0.1.0_1783299320596_0.29673189169212977"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-06T00:55:20.439Z","0.1.0":"2026-07-06T00:55:20.755Z","modified":"2026-07-06T00:55:20.944Z"},"maintainers":[{"name":"antitamper","email":"admin@antitamper.co"}],"description":"TypeScript SDK for EasyAntiTamper OAuth 2.1 — PKCE, device flow, client credentials, token management, typed API client, and webhook verification.","homepage":"https://antitamper.co","keywords":["oauth","oauth2","pkce","antitamper","licensing","easyantitamper"],"repository":{"type":"git","url":"git+https://github.com/MagmaVRC/EasyAntiTamper.git"},"author":{"name":"EasyAntiTamper"},"bugs":{"url":"https://github.com/MagmaVRC/EasyAntiTamper/issues"},"license":"MIT","readme":"# @antitamper/oauth\n\nTypeScript SDK for the [EasyAntiTamper](https://antitamper.co) OAuth 2.1 API. Zero runtime dependencies - works on Node 22+, Bun, and modern browsers (uses `fetch` and WebCrypto). Ships as minified ESM (Node 22+ can `require()` it too).\n\n```\nnpm install @antitamper/oauth\n```\n\nFull API reference: https://api4.antitamper.co (docs at https://cloud4.antitamper.co/docs)\n\n## Authorization code + PKCE (SPAs, desktop apps)\n\nPublic clients need no secret - PKCE (S256) is enforced.\n\n```ts\nimport { EatOAuth, TokenManager, EatApiClient } from '@antitamper/oauth';\n\nconst oauth = new EatOAuth({ clientId: 'your-client-id' });\n\n// 1. Send the user to the consent page\nconst auth = await oauth.buildAuthorizationUrl({\n  redirectUri: 'https://yourapp.example/callback',\n  scopes: ['identity', 'email', 'offline_access'],\n});\nsessionStorage.setItem('verifier', auth.codeVerifier);\nsessionStorage.setItem('state', auth.state);\nlocation.href = auth.url;\n\n// 2. On your callback page - verify state, then exchange the code\nconst params = new URLSearchParams(location.search);\nif (params.get('state') !== sessionStorage.getItem('state')) throw new Error('state mismatch');\nconst tokens = await oauth.exchangeCode({\n  code: params.get('code')!,\n  redirectUri: 'https://yourapp.example/callback',\n  codeVerifier: sessionStorage.getItem('verifier')!,\n});\n\n// 3. Call the API with auto-refresh\nconst manager = new TokenManager(oauth);\nawait manager.setFromResponse(tokens);\nconst api = new EatApiClient({ token: manager.getAccessToken });\nconst me = await api.userinfo();\n```\n\nRefresh tokens are only issued when you request the `offline_access` scope, and they **rotate on every refresh** - `TokenManager` handles storing the new one (plug in your own `TokenStorage` to persist beyond memory).\n\n## Client credentials (your backend)\n\nConfidential clients only. No refresh token is issued; request a new one when it expires (`TokenManager` does this for you if you wrap `clientCredentials` yourself, or just call it per batch).\n\n```ts\nconst oauth = new EatOAuth({ clientId: 'id', clientSecret: process.env.EAT_CLIENT_SECRET! });\nconst tokens = await oauth.clientCredentials(['db:read', 'db:write']);\nconst api = new EatApiClient({ token: tokens.access_token });\n\nconst db = api.database(productId);\nconst top = await db.all('SELECT * FROM scores ORDER BY score DESC LIMIT 10');\n```\n\nAllowed scopes for this grant: `db:read`, `db:write`, `r2:read`, `r2:write`, `storage:read`, `storage:write`, `products:read`, `licenses:read`, `analytics:read`.\n\n## SQLite databases\n\nEvery product gets a server-hosted SQLite database, plus one private database per\nlicensed user. `database(productId)` is the shared product store (any token);\n`userDatabase(productId)` is the calling user's own store (user tokens with an\nactive license - the server rejects machine tokens there). Scopes `db:read` / `db:write`.\n\n```ts\nconst db = api.database(productId);\n\nawait db.run('CREATE TABLE IF NOT EXISTS scores (user TEXT, score INTEGER)');\nawait db.run('INSERT INTO scores VALUES (?, ?)', ['alice', 100]);\n\n// Tagged template - interpolations become bind params, never string-spliced\nconst rows = await db.sql`SELECT * FROM scores WHERE score > ${50}`;\n\nconst top = await db.all<{ user: string; score: number }>('SELECT * FROM scores ORDER BY score DESC');\nconst one = await db.get('SELECT * FROM scores WHERE user = ?', ['alice']); // first row or null\nconst { size_bytes, quota_bytes } = await db.info(); // product db only\n\nconst mine = api.userDatabase(productId);\nawait mine.run('INSERT INTO saves VALUES (?)', [saveBlob]);\n```\n\nServer rules: one statement per call, positional `?` params only, max 8192 bytes of\nSQL, `SELECT`/`INSERT`/`UPDATE`/`DELETE`/`CREATE|DROP TABLE|INDEX` only, results capped\nat 1000 rows / 1 MB (`query()` exposes the `truncated` flag). No cross-request\ntransactions. `wipe()` deletes the database (owner-only for product databases).\n\n## R2 object storage\n\nEvery product also gets an R2 object bucket, plus one private bucket per licensed\nuser. `bucket(productId)` is the shared product store (any token);\n`userBucket(productId)` is the calling user's own store (user tokens with an\nactive license - the server rejects machine tokens there). Scopes `r2:read` /\n`r2:write`. Objects are raw bytes with a content type; keys may contain `/` to\nmimic folders. The per-object ceiling is 100 MB and total usage is quota-capped.\n\n```ts\nconst bucket = api.bucket(productId);\n\nawait bucket.put('config.json', JSON.stringify({ level: 3 }), 'application/json');\nawait bucket.put('saves/slot1.dat', saveBytes); // defaults to application/octet-stream\n\nconst bytes = await bucket.get('saves/slot1.dat'); // Uint8Array\nconst text = await bucket.getText('config.json'); // decoded UTF-8\n\nconst { objects, used_bytes, quota_bytes } = await bucket.list();\nconst { used_bytes: used } = await bucket.usage(); // counters only\n\nawait bucket.delete('saves/slot1.dat');\nawait bucket.wipe(); // owner-only for product buckets; removes all objects\n\nconst mine = api.userBucket(productId);\nawait mine.put('profile.png', pngBytes, 'image/png');\n```\n\n`put()` streams the raw body (`string | Uint8Array | ArrayBuffer | Blob`) - it is\nnot JSON-wrapped. Missing keys throw `ApiError` `object_not_found`; over-quota or\noversized uploads throw `quota_exceeded` / `object_too_large` (both 413).\n\n### Direct transfers (presigned)\n\n`put()`/`get()` proxy bytes through the API. For large or frequent objects, transfer\nstraight to R2 with a short-lived presigned URL instead - the API only signs, it never\nsees the bytes:\n\n```ts\nawait bucket.putDirect('build.zip', zipBytes, 'application/zip'); // presign + PUT to R2\nconst bytes = await bucket.getDirect('build.zip');                // presign + GET from R2\n```\n\n`putDirect` signs the exact byte length, so R2 rejects a body of any other size. If you\nwant the URL yourself (e.g. to hand to another process), call `presignUpload(key, size)`\nor `presignDownload(key)`. Direct transfers throw `ApiError` `not_configured` (501) when\nthe server has no R2 S3 credentials - fall back to `put()`/`get()`. In a browser,\n`putDirect` needs CORS configured on the R2 bucket; the proxied `put()` does not.\n\n## Device flow (CLIs, games, consoles)\n\n```ts\nconst device = await oauth.deviceAuthorize(['identity', 'licenses:read']);\nconsole.log(`Visit ${device.verification_uri} and enter ${device.user_code}`);\nconst tokens = await oauth.pollDeviceToken(device); // resolves when the user approves\n```\n\nPolling respects the server's interval and `slow_down`; pass an `AbortSignal` to cancel.\n\n## Webhooks\n\nDeliveries are signed with HMAC-SHA256 over the **raw** body, sent as `X-Eat-Signature: sha256=<hex>`, with the event type in `X-Eat-Event` (`grant.revoked`, `token.revoked`). Verify before parsing:\n\n```ts\nimport { verifyWebhookSignature } from '@antitamper/oauth';\n\nconst ok = await verifyWebhookSignature({\n  body: rawBody,                                // string, exactly as received\n  signature: req.headers['x-eat-signature'],\n  secret: process.env.EAT_WEBHOOK_SECRET!,      // shown once when you set the webhook URL\n});\nif (!ok) return res.status(401).end();\n```\n\n## API client surface\n\n| Method | Endpoint | Scope |\n|---|---|---|\n| `userinfo()` | `GET /oauth/userinfo` | `identity` (+`email`) |\n| `tokenInfo()` | `GET /oauth/token/info` | any |\n| `listLicenses()` / `checkLicense(pid)` | `GET /licenses/...` | `licenses:read` |\n| `redeemLicense(key)` | `POST /licenses/redeem` | `licenses:redeem` |\n| `migrateHwid(body)` | `POST /licenses/migrate-hwid` | `licenses:redeem` |\n| `joinSupport(pid)` | `POST /licenses/join-support` | `licenses:read` |\n| `listStorage/getStorage/putStorage/deleteStorage` | `/storage/{pid}[/{key}]` | `storage:read` / `storage:write` |\n| `database(pid)` / `userDatabase(pid)` | `/db/product|me/{pid}...` | `db:read` / `db:write` |\n| `bucket(pid)` / `userBucket(pid)` | `/r2/product|me/{pid}...` | `r2:read` / `r2:write` |\n| `listProducts()` / `getProduct(pid)` / `updateProduct(pid, patch)` | `/publisher/products...` | `products:read` / `products:write` |\n| `listKeys(pid)` / `createKeys(pid, body)` | `/publisher/products/{pid}/keys` | `keys:read` / `keys:write` |\n| `getAnalytics(pid)` | `.../analytics` | `analytics:read` |\n| `listOrganizations()` / `getOrganization(id)` | `/publisher/organizations[/{id}]` | `orgs:read` |\n| `request(method, path, body?)` | anything else | - |\n\n`EatOAuth` also exposes `discovery()` (RFC 8414 metadata) and `publicKeys()` (Ed25519 keys for offline verification of `v4.public` tokens).\n\nErrors: OAuth endpoints throw `OAuthError` (`.code` = the server's `error` field); resource endpoints throw `ApiError` (`.code`, `.status`, `.retryAfter` on 429). Rate limits are per-IP (token endpoint 15/min, most resources 60/min).\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-411c7427aa4e9d41c392ff7a4ff1f479"}