diff --git a/sdk/README.md b/sdk/README.md index e944e7a48..2edd11fe6 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,17 +1,15 @@ -# @solfoundry/sdk +# SolFoundry TypeScript SDK -[![npm version](https://img.shields.io/npm/v/@solfoundry/sdk?color=orange)](https://www.npmjs.com/package/@solfoundry/sdk) -[![CI](https://github.com/SolFoundry/solfoundry/actions/workflows/ci.yml/badge.svg)](https://github.com/SolFoundry/solfoundry/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen)](https://github.com/SolFoundry/solfoundry/tree/main/sdk) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.4-blue)](https://www.typescriptlang.org/) +Production-ready TypeScript SDK for the SolFoundry bounty platform. It provides typed access to bounty management, submissions, users, authentication, retries, and rate-limit-aware request handling. -TypeScript SDK for the [SolFoundry](https://solfoundry.io) bounty marketplace on Solana. +## Features -**[๐Ÿ“– Full Docs](https://docs.solfoundry.io)** ยท **[๐Ÿ”ง CLI Reference](https://docs.solfoundry.io/guide/cli-commands)** ยท **[๐Ÿ“‹ Examples](https://docs.solfoundry.io/examples/)** ยท **[๐Ÿ”Œ API Reference](https://docs.solfoundry.io/api/)** - ---- +- Full API surface for bounties, submissions, and users +- Strong TypeScript definitions with JSDoc on exported APIs +- Dependency-light client built on the standard Fetch API +- Configurable retries with exponential backoff +- Client-side pacing plus `Retry-After` support for rate-limited environments +- Browser and Node.js support ## Installation @@ -19,126 +17,165 @@ TypeScript SDK for the [SolFoundry](https://solfoundry.io) bounty marketplace on npm install @solfoundry/sdk ``` -Node.js **18+** required. - ## Quick Start -```typescript -import { SolFoundry } from '@solfoundry/sdk'; - -const client = SolFoundry.create({ - baseUrl: 'https://api.solfoundry.io', - authToken: process.env.SOLFOUNDRY_TOKEN, // optional for read-only +```ts +import { SolFoundryClient, SolFoundryError } from "@solfoundry/sdk"; + +const client = new SolFoundryClient({ + auth: { + accessToken: process.env.SOLFOUNDRY_ACCESS_TOKEN, + }, + retry: { + maxRetries: 3, + }, + rateLimit: { + minIntervalMs: 100, + }, }); -// List open bounties -const bounties = await client.bounties.list({ status: 'open', limit: 10 }); -console.log(`${bounties.total} open bounties`); -bounties.bounties.forEach(b => console.log(`[T${b.tier}] ${b.title} โ€” ${b.reward_amount} $FNDRY`)); - -// Get a specific bounty -const bounty = await client.bounties.get('bounty-uuid'); +async function main(): Promise { + const bounties = await client.bounties.list({ status: "open", limit: 10 }); + console.log(bounties.items.map((bounty) => bounty.title)); +} -// Check contributor stats -const profile = await client.contributors.get('octocat'); -console.log(`${profile.display_name}: ${profile.reputation_score} pts (T${profile.tier})`); +main().catch((error) => { + if (error instanceof SolFoundryError) { + console.error(error.status, error.problem?.message); + return; + } -// Check escrow status -const escrow = await client.escrow.getStatus('bounty-uuid'); -console.log(`Escrow: ${escrow.state} โ€” ${escrow.amount} $FNDRY locked`); + throw error; +}); ``` -## CLI - -```bash -# One-off (no install needed) -npx @solfoundry/cli bounties -npx @solfoundry/cli status -npx @solfoundry/cli profile -npx @solfoundry/cli verify - -# Global install -npm install -g @solfoundry/cli -solfoundry bounties --tier 2 --limit 5 +## Configuration + +```ts +const client = new SolFoundryClient({ + baseUrl: "https://api.solfoundry.com/v1", + auth: { + accessToken: process.env.SOLFOUNDRY_ACCESS_TOKEN, + apiKey: process.env.SOLFOUNDRY_API_KEY, + getAccessToken: async () => process.env.SOLFOUNDRY_ACCESS_TOKEN, + }, + headers: { + "X-App-Version": "my-app/1.0.0", + }, + timeoutMs: 30_000, + retry: { + maxRetries: 4, + baseDelayMs: 250, + maxDelayMs: 4_000, + retryableStatusCodes: [408, 429, 500, 502, 503, 504], + }, + rateLimit: { + minIntervalMs: 100, + respectRetryAfter: true, + }, + onResponse: (response, rateLimitState) => { + console.log(response.status, rateLimitState.remaining); + }, +}); ``` -## Solana Helpers +## API Coverage -```typescript -import { createConnection, PublicKey, getTokenBalance, getSolBalance, isValidSolanaAddress, toRawAmount } from '@solfoundry/sdk'; +### Bounties -const connection = createConnection('https://api.mainnet-beta.solana.com'); -const wallet = new PublicKey('YourWalletAddress'); +```ts +const created = await client.bounties.create({ + title: "Implement wallet insights API", + description: "Build and ship the endpoint with tests and docs.", + reward: { currency: "FNDRY", amount: "900000" }, + status: "open", + tags: ["backend", "solana"], +}); -const fndry = await getTokenBalance(connection, wallet); -const sol = await getSolBalance(connection, wallet); +const list = await client.bounties.list({ + status: "open", + ownerId: "user_123", + search: "wallet", + limit: 20, +}); -console.log(`${fndry.balance} $FNDRY | ${sol.balanceSol} SOL`); -isValidSolanaAddress('validBase58'); // true -toRawAmount(1.5); // 1500000000n +const updated = await client.bounties.update(created.id, { + status: "in_review", +}); + +await client.bounties.delete(created.id); ``` -## Real-Time Events +### Submissions -```typescript -import { EventSubscriber } from '@solfoundry/sdk'; +```ts +const submission = await client.submissions.submit({ + bountyId: "bounty_123", + artifactUrl: "https://github.com/acme/sdk", + content: "Implementation complete with test coverage.", +}); -const events = new EventSubscriber({ wsUrl: 'wss://api.solfoundry.io/ws', autoReconnect: true }); +await client.submissions.review(submission.id, { + status: "changes_requested", + comment: "Please add pagination tests.", +}); -events.on('bounty_created', e => console.log('New bounty:', e.data.title)); -events.onConnect(() => events.subscribe('bounties')); -await events.connect(); +await client.submissions.approve(submission.id, { + comment: "Looks good.", + settlementReference: "payout_456", +}); ``` -## Error Handling +### Users and Authentication -```typescript -import { SolFoundry, NotFoundError, RateLimitError, AuthenticationError } from '@solfoundry/sdk'; +```ts +const session = await client.users.login({ + email: "builder@example.com", + password: "s3cret", +}); -try { - const bounty = await client.bounties.get('invalid-id'); -} catch (err) { - if (err instanceof NotFoundError) console.log('Not found'); - else if (err instanceof RateLimitError) console.log('Rate limited โ€” slow down'); - else if (err instanceof AuthenticationError) console.log('Set SOLFOUNDRY_TOKEN'); - else throw err; -} -``` +client.setSession(session); -## API Reference +const me = await client.users.getMe(); -| Client | Methods | -|--------|---------| -| `client.bounties` | `list`, `get`, `create`, `update`, `delete`, `submitSolution`, `listSubmissions`, `search`, `autocomplete` | -| `client.escrow` | `fund`, `release`, `refund`, `getStatus` | -| `client.contributors` | `list`, `get`, `create`, `update`, `getStats`, `getHealth` | -| `GitHubClient` | `listBountyIssues`, `isIssueClaimed`, `isIssueCompleted` | -| `EventSubscriber` | `connect`, `disconnect`, `subscribe`, `on`, `onConnect` | +const refreshed = await client.users.refresh(session.refreshToken!); +client.setSession(refreshed); -Full reference: [docs.solfoundry.io/api](https://docs.solfoundry.io/api/) +await client.users.updateMe({ + displayName: "Aki Builder", + bio: "Shipping Solana tooling.", +}); -## Examples +await client.users.logout(refreshed.refreshToken); +client.clearSession(); +``` -11 working examples in [`sdk/examples/`](./examples/): +## Error Handling -| # | File | Description | -|---|------|-------------| -| 01 | [list-bounties.ts](./examples/01-list-bounties.ts) | Paginate and filter bounties | -| 02 | [contributor-stats.ts](./examples/02-contributor-stats.ts) | Reputation, tier, earnings | -| 03 | [realtime-events.ts](./examples/03-realtime-events.ts) | WebSocket event subscriptions | -| 04 | [verify-onchain.ts](./examples/04-verify-onchain.ts) | Solana transaction verification | -| 05 | [leaderboard.ts](./examples/05-leaderboard.ts) | Top contributors | -| 06 | [submit-solution.ts](./examples/06-submit-solution.ts) | Submit a PR to a bounty | -| 07 | [escrow.ts](./examples/07-escrow.ts) | Fund, release, refund | -| 08 | [search-bounties.ts](./examples/08-search-bounties.ts) | Full-text search | -| 09 | [github-integration.ts](./examples/09-github-integration.ts) | GitHub Issues | -| 10 | [solana-helpers.ts](./examples/10-solana-helpers.ts) | On-chain utilities | -| 11 | [error-handling.ts](./examples/11-error-handling.ts) | Typed error patterns | +```ts +try { + await client.bounties.getById("missing-id"); +} catch (error) { + if (error instanceof SolFoundryError) { + console.error({ + status: error.status, + code: error.problem?.code, + message: error.problem?.message, + rateLimit: error.rateLimit, + }); + } +} +``` -## Contributing +## Documentation -See [CONTRIBUTING.md](../CONTRIBUTING.md). +- API guide: [docs/API.md](docs/API.md) +- Main client: [src/client/SolFoundryClient.ts](src/client/SolFoundryClient.ts) +- Exported types: [src/types/index.ts](src/types/index.ts) -## License +## Development -MIT +```bash +npm run build +npm run typecheck +``` diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 000000000..e2fa30ad5 --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,155 @@ +{ + "name": "@solfoundry/sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@solfoundry/sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/sdk/package.json b/sdk/package.json index 9fb00ac7a..072f18930 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,58 +1,40 @@ { "name": "@solfoundry/sdk", "version": "0.1.0", - "description": "TypeScript SDK for SolFoundry on-chain programs and REST APIs", + "description": "Production-ready TypeScript SDK for the SolFoundry bounty platform.", + "keywords": [ + "solfoundry", + "sdk", + "typescript", + "bounties", + "api" + ], + "license": "MIT", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./programs": { - "import": "./dist/programs/index.js", - "types": "./dist/programs/index.d.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, "files": [ "dist", - "idl", - "README.md" + "README.md", + "docs" ], - "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest", - "clean": "rm -rf dist", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "@coral-xyz/anchor": "^0.30.1", - "@solana/web3.js": "^1.95.0" - }, - "devDependencies": { - "typescript": "^5.5.0", - "vitest": "^2.0.0" - }, - "peerDependencies": { - "@coral-xyz/anchor": ">=0.30.0", - "@solana/web3.js": ">=1.90.0" - }, "engines": { "node": ">=18" }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SolFoundry/solfoundry", - "directory": "sdk" + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rimraf dist", + "prepublishOnly": "npm run clean && npm run build", + "typecheck": "tsc -p tsconfig.json --noEmit" }, - "keywords": [ - "solana", - "anchor", - "solfoundry", - "sdk", - "typescript" - ] + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.8.3" + } } diff --git a/sdk/src/api/BaseApi.ts b/sdk/src/api/BaseApi.ts new file mode 100644 index 000000000..f605932fc --- /dev/null +++ b/sdk/src/api/BaseApi.ts @@ -0,0 +1,8 @@ +import { HttpClient } from "../client/HttpClient.js"; + +/** + * Base class for resource API modules. + */ +export abstract class BaseApi { + public constructor(protected readonly httpClient: HttpClient) {} +} diff --git a/sdk/src/api/BountiesApi.ts b/sdk/src/api/BountiesApi.ts new file mode 100644 index 000000000..f5b6acfaf --- /dev/null +++ b/sdk/src/api/BountiesApi.ts @@ -0,0 +1,52 @@ +import { BaseApi } from "./BaseApi.js"; +import type { + Bounty, + BountyListResponse, + CreateBountyInput, + ListBountiesParams, + UpdateBountyInput, +} from "../types/index.js"; + +/** + * Bounty management endpoints. + */ +export class BountiesApi extends BaseApi { + /** + * Creates a new bounty. + */ + public async create(input: CreateBountyInput): Promise { + return this.httpClient.request("POST", "/bounties", { body: input }); + } + + /** + * Returns a paginated list of bounties. + */ + public async list(params: ListBountiesParams = {}): Promise { + return this.httpClient.request("GET", "/bounties", { query: params }); + } + + /** + * Fetches a single bounty by id. + */ + public async getById(bountyId: string): Promise { + return this.httpClient.request("GET", `/bounties/${encodeURIComponent(bountyId)}`); + } + + /** + * Applies a partial update to an existing bounty. + */ + public async update(bountyId: string, input: UpdateBountyInput): Promise { + return this.httpClient.request("PATCH", `/bounties/${encodeURIComponent(bountyId)}`, { + body: input, + }); + } + + /** + * Deletes a bounty permanently. + */ + public async delete(bountyId: string): Promise { + return this.httpClient.request("DELETE", `/bounties/${encodeURIComponent(bountyId)}`, { + responseType: "void", + }); + } +} diff --git a/sdk/src/api/SubmissionsApi.ts b/sdk/src/api/SubmissionsApi.ts new file mode 100644 index 000000000..4b91ee3e9 --- /dev/null +++ b/sdk/src/api/SubmissionsApi.ts @@ -0,0 +1,68 @@ +import { BaseApi } from "./BaseApi.js"; +import type { + ApproveSubmissionInput, + CreateSubmissionInput, + ListSubmissionsParams, + ReviewSubmissionInput, + Submission, + SubmissionListResponse, +} from "../types/index.js"; + +/** + * Submission and review endpoints. + */ +export class SubmissionsApi extends BaseApi { + /** + * Creates a new submission for a bounty. + */ + public async submit(input: CreateSubmissionInput): Promise { + return this.httpClient.request("POST", "/submissions", { body: input }); + } + + /** + * Returns a paginated list of submissions. + */ + public async list(params: ListSubmissionsParams = {}): Promise { + return this.httpClient.request("GET", "/submissions", { + query: params, + }); + } + + /** + * Fetches a single submission by id. + */ + public async getById(submissionId: string): Promise { + return this.httpClient.request( + "GET", + `/submissions/${encodeURIComponent(submissionId)}`, + ); + } + + /** + * Reviews an existing submission. + */ + public async review( + submissionId: string, + input: ReviewSubmissionInput, + ): Promise { + return this.httpClient.request( + "POST", + `/submissions/${encodeURIComponent(submissionId)}/review`, + { body: input }, + ); + } + + /** + * Approves a submission. + */ + public async approve( + submissionId: string, + input: ApproveSubmissionInput = {}, + ): Promise { + return this.httpClient.request( + "POST", + `/submissions/${encodeURIComponent(submissionId)}/approve`, + { body: input }, + ); + } +} diff --git a/sdk/src/api/UsersApi.ts b/sdk/src/api/UsersApi.ts new file mode 100644 index 000000000..e2b0c55ee --- /dev/null +++ b/sdk/src/api/UsersApi.ts @@ -0,0 +1,83 @@ +import { BaseApi } from "./BaseApi.js"; +import type { + AuthSession, + ListUsersParams, + LoginInput, + RegisterInput, + UpdateUserProfileInput, + User, + UserListResponse, +} from "../types/index.js"; + +/** + * User, authentication, and profile endpoints. + */ +export class UsersApi extends BaseApi { + /** + * Authenticates a user with email and password. + */ + public async login(input: LoginInput): Promise { + return this.httpClient.request("POST", "/auth/login", { + body: input, + skipAuth: true, + }); + } + + /** + * Registers a new user account. + */ + public async register(input: RegisterInput): Promise { + return this.httpClient.request("POST", "/auth/register", { + body: input, + skipAuth: true, + }); + } + + /** + * Refreshes the current access token using a refresh token. + */ + public async refresh(refreshToken: string): Promise { + return this.httpClient.request("POST", "/auth/refresh", { + body: { refreshToken }, + skipAuth: true, + }); + } + + /** + * Invalidates the current user session. + */ + public async logout(refreshToken?: string): Promise { + return this.httpClient.request("POST", "/auth/logout", { + body: refreshToken ? { refreshToken } : {}, + responseType: "void", + }); + } + + /** + * Returns the authenticated user profile. + */ + public async getMe(): Promise { + return this.httpClient.request("GET", "/users/me"); + } + + /** + * Updates the authenticated user profile. + */ + public async updateMe(input: UpdateUserProfileInput): Promise { + return this.httpClient.request("PATCH", "/users/me", { body: input }); + } + + /** + * Returns a paginated list of users. + */ + public async list(params: ListUsersParams = {}): Promise { + return this.httpClient.request("GET", "/users", { query: params }); + } + + /** + * Fetches a user profile by id. + */ + public async getById(userId: string): Promise { + return this.httpClient.request("GET", `/users/${encodeURIComponent(userId)}`); + } +} diff --git a/sdk/src/auth/AuthManager.ts b/sdk/src/auth/AuthManager.ts new file mode 100644 index 000000000..2191318d0 --- /dev/null +++ b/sdk/src/auth/AuthManager.ts @@ -0,0 +1,80 @@ +import type { AuthSession, SolFoundryAuthConfig } from "../types/index.js"; + +/** + * Manages runtime authentication state for the SDK. + */ +export class AuthManager { + private accessToken: string | undefined; + private refreshToken: string | undefined; + private apiKey: string | undefined; + private getAccessToken: (() => string | undefined | Promise) | undefined; + + public constructor(config: SolFoundryAuthConfig = {}) { + this.accessToken = config.accessToken; + this.apiKey = config.apiKey; + this.getAccessToken = config.getAccessToken; + } + + /** + * Returns headers required for authenticating an API request. + */ + public async getAuthHeaders(): Promise> { + const headers: Record = {}; + const token = (await this.getAccessToken?.()) ?? this.accessToken; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + + return headers; + } + + /** + * Replaces the current access token. + */ + public setAccessToken(token: string | undefined): void { + this.accessToken = token; + } + + /** + * Replaces the current API key. + */ + public setApiKey(apiKey: string | undefined): void { + this.apiKey = apiKey; + } + + /** + * Stores a new authenticated session. + */ + public setSession(session: Pick): void { + this.accessToken = session.accessToken; + this.refreshToken = session.refreshToken; + } + + /** + * Returns the stored refresh token if one exists. + */ + public getRefreshToken(): string | undefined { + return this.refreshToken; + } + + /** + * Clears the current session tokens while preserving any configured API key. + */ + public clear(): void { + this.accessToken = undefined; + this.refreshToken = undefined; + } + + /** + * Clears all stored authentication state, including the API key. + */ + public clearAllAuth(): void { + this.clear(); + this.apiKey = undefined; + } +} diff --git a/sdk/src/client/HttpClient.ts b/sdk/src/client/HttpClient.ts new file mode 100644 index 000000000..9aeb6f4c2 --- /dev/null +++ b/sdk/src/client/HttpClient.ts @@ -0,0 +1,311 @@ +import { AuthManager } from "../auth/AuthManager.js"; +import { SolFoundryError } from "../errors/SolFoundryError.js"; +import type { + ApiProblem, + JsonObject, + RateLimitState, + RequestOptions, + RetryConfig, + SolFoundryClientConfig, +} from "../types/index.js"; +import { RateLimiter } from "./RateLimiter.js"; + +const DEFAULT_BASE_URL = "https://api.solfoundry.com/v1"; +const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504]; +const DEFAULT_RETRY_CONFIG: Required = { + maxRetries: 3, + baseDelayMs: 250, + maxDelayMs: 4_000, + retryableStatusCodes: DEFAULT_RETRYABLE_STATUS_CODES, +}; + +/** + * Shared HTTP implementation used by resource APIs. + */ +export class HttpClient { + private readonly baseUrl: string; + private readonly fetchImpl: typeof globalThis.fetch; + private readonly defaultHeaders: Record; + private readonly timeoutMs: number; + private readonly retryConfig: Required; + private readonly rateLimiter: RateLimiter; + private readonly respectRetryAfter: boolean; + private readonly onResponse: SolFoundryClientConfig["onResponse"] | undefined; + private rateLimitState: RateLimitState = {}; + + public constructor( + private readonly authManager: AuthManager, + config: SolFoundryClientConfig = {}, + ) { + this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.fetchImpl = config.fetch ?? globalThis.fetch; + this.defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", + ...config.headers, + }; + this.timeoutMs = config.timeoutMs ?? 30_000; + this.retryConfig = { + maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY_CONFIG.maxRetries, + baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY_CONFIG.baseDelayMs, + maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY_CONFIG.maxDelayMs, + retryableStatusCodes: + config.retry?.retryableStatusCodes ?? DEFAULT_RETRY_CONFIG.retryableStatusCodes, + }; + this.rateLimiter = new RateLimiter(config.rateLimit?.minIntervalMs ?? 0); + this.respectRetryAfter = config.rateLimit?.respectRetryAfter ?? true; + this.onResponse = config.onResponse; + + if (!this.fetchImpl) { + throw new SolFoundryError( + "No fetch implementation available. Provide `fetch` in the client configuration.", + ); + } + } + + /** + * Returns the latest observed rate-limit state. + */ + public getRateLimitState(): RateLimitState { + return { ...this.rateLimitState }; + } + + /** + * Executes a typed API request. + */ + public async request( + method: string, + path: string, + options: RequestOptions = {}, + ): Promise { + const url = this.buildUrl(path, options.query); + const responseType = options.responseType ?? "json"; + let attempt = 0; + let lastError: unknown; + + while (attempt <= this.retryConfig.maxRetries) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs); + + try { + await this.rateLimiter.acquire(); + + const headers = { + ...this.defaultHeaders, + ...(options.skipAuth ? {} : await this.authManager.getAuthHeaders()), + ...options.headers, + }; + + const requestInit: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (options.body !== undefined) { + requestInit.body = JSON.stringify(options.body); + } + + const response = await this.fetchImpl(url, requestInit); + const rateLimitState = this.parseRateLimitState(response.headers); + this.rateLimitState = rateLimitState; + this.onResponse?.(response, this.getRateLimitState()); + + if (!response.ok) { + const failure = await this.buildHttpError(response, rateLimitState); + if (this.shouldRetryStatus(response.status, attempt)) { + await this.sleep(this.getRetryDelayMs(attempt, rateLimitState.retryAfterMs)); + attempt += 1; + continue; + } + + throw failure; + } + + if (responseType === "void" || response.status === 204) { + return undefined as T; + } + + if (responseType === "text") { + return (await response.text()) as T; + } + + return (await response.json()) as T; + } catch (error) { + lastError = error; + if (!this.shouldRetryError(error, attempt)) { + if (error instanceof SolFoundryError) { + throw error; + } + + throw new SolFoundryError("Request failed", { + cause: error, + rateLimit: this.getRateLimitState(), + }); + } + + await this.sleep(this.getRetryDelayMs(attempt)); + attempt += 1; + } finally { + clearTimeout(timeout); + } + } + + throw new SolFoundryError("Request failed after exhausting retries", { + cause: lastError, + rateLimit: this.getRateLimitState(), + }); + } + + private buildUrl(path: string, query?: object): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${this.baseUrl}${normalizedPath}`); + + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) { + continue; + } + + url.searchParams.set(key, String(value)); + } + } + + return url.toString(); + } + + private shouldRetryStatus(status: number, attempt: number): boolean { + return ( + attempt < this.retryConfig.maxRetries && + this.retryConfig.retryableStatusCodes.includes(status) + ); + } + + private shouldRetryError(error: unknown, attempt: number): boolean { + if (attempt >= this.retryConfig.maxRetries) { + return false; + } + + if (error instanceof SolFoundryError) { + return error.status !== undefined && this.shouldRetryStatus(error.status, attempt); + } + + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + + return true; + } + + private parseRateLimitState(headers: Headers): RateLimitState { + const limit = this.parseOptionalNumber(headers.get("x-ratelimit-limit")); + const remaining = this.parseOptionalNumber(headers.get("x-ratelimit-remaining")); + const reset = this.parseOptionalNumber(headers.get("x-ratelimit-reset")); + const retryAfterMs = this.parseRetryAfterMs(headers.get("retry-after")); + + const state: RateLimitState = {}; + + if (limit !== undefined) { + state.limit = limit; + } + if (remaining !== undefined) { + state.remaining = remaining; + } + if (reset !== undefined) { + state.resetAt = reset * 1_000; + } + if (retryAfterMs !== undefined) { + state.retryAfterMs = retryAfterMs; + } + + return state; + } + + private parseOptionalNumber(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + private parseRetryAfterMs(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return seconds * 1_000; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? undefined : Math.max(timestamp - Date.now(), 0); + } + + private getRetryDelayMs(attempt: number, retryAfterMs?: number): number { + if (this.respectRetryAfter && retryAfterMs !== undefined) { + return retryAfterMs; + } + + const exponential = this.retryConfig.baseDelayMs * 2 ** attempt; + const jitter = Math.floor(Math.random() * this.retryConfig.baseDelayMs); + return Math.min(exponential + jitter, this.retryConfig.maxDelayMs); + } + + private async buildHttpError( + response: Response, + rateLimitState: RateLimitState, + ): Promise { + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + const payload = (await response.json()) as ApiProblem | JsonObject; + const problem = this.toApiProblem(payload); + + return new SolFoundryError(problem?.message ?? response.statusText, { + status: response.status, + problem, + responseBody: payload, + rateLimit: rateLimitState, + }); + } + + const text = await response.text(); + return new SolFoundryError(text || response.statusText, { + status: response.status, + responseBody: text, + rateLimit: rateLimitState, + }); + } + + private toApiProblem(payload: ApiProblem | JsonObject): ApiProblem | undefined { + if (typeof payload.message === "string") { + const problem: ApiProblem = { message: payload.message }; + + if (typeof payload.code === "string") { + problem.code = payload.code; + } + if (this.isJsonObject(payload.details)) { + problem.details = payload.details; + } + + return problem; + } + + return undefined; + } + + private isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + private async sleep(ms: number): Promise { + if (ms <= 0) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/sdk/src/client/RateLimiter.ts b/sdk/src/client/RateLimiter.ts new file mode 100644 index 000000000..5fd75ad40 --- /dev/null +++ b/sdk/src/client/RateLimiter.ts @@ -0,0 +1,25 @@ +/** + * Minimal async rate limiter that enforces a delay between requests. + */ +export class RateLimiter { + private nextAvailableAt = 0; + + public constructor(private readonly minIntervalMs: number) {} + + /** + * Waits until the client can issue the next request. + */ + public async acquire(): Promise { + if (this.minIntervalMs <= 0) { + return; + } + + const now = Date.now(); + const waitMs = Math.max(this.nextAvailableAt - now, 0); + this.nextAvailableAt = Math.max(this.nextAvailableAt, now) + this.minIntervalMs; + + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + } +} diff --git a/sdk/src/client/SolFoundryClient.ts b/sdk/src/client/SolFoundryClient.ts new file mode 100644 index 000000000..5f94251d3 --- /dev/null +++ b/sdk/src/client/SolFoundryClient.ts @@ -0,0 +1,72 @@ +import { BountiesApi } from "../api/BountiesApi.js"; +import { SubmissionsApi } from "../api/SubmissionsApi.js"; +import { UsersApi } from "../api/UsersApi.js"; +import { AuthManager } from "../auth/AuthManager.js"; +import { HttpClient } from "./HttpClient.js"; +import type { AuthSession, RateLimitState, SolFoundryClientConfig } from "../types/index.js"; + +/** + * Main entry point for interacting with the SolFoundry API. + */ +export class SolFoundryClient { + /** + * Bounty resource API. + */ + public readonly bounties: BountiesApi; + + /** + * Submission resource API. + */ + public readonly submissions: SubmissionsApi; + + /** + * User and authentication API. + */ + public readonly users: UsersApi; + + private readonly authManager: AuthManager; + private readonly httpClient: HttpClient; + + public constructor(config: SolFoundryClientConfig = {}) { + this.authManager = new AuthManager(config.auth); + this.httpClient = new HttpClient(this.authManager, config); + this.bounties = new BountiesApi(this.httpClient); + this.submissions = new SubmissionsApi(this.httpClient); + this.users = new UsersApi(this.httpClient); + } + + /** + * Replaces the active bearer token. + */ + public setAccessToken(token: string | undefined): void { + this.authManager.setAccessToken(token); + } + + /** + * Replaces the active API key. + */ + public setApiKey(apiKey: string | undefined): void { + this.authManager.setApiKey(apiKey); + } + + /** + * Stores a new authenticated session returned by the API. + */ + public setSession(session: Pick): void { + this.authManager.setSession(session); + } + + /** + * Clears all locally stored authentication state. + */ + public clearSession(): void { + this.authManager.clear(); + } + + /** + * Returns the latest observed rate-limit headers. + */ + public getRateLimitState(): RateLimitState { + return this.httpClient.getRateLimitState(); + } +} diff --git a/sdk/src/errors/SolFoundryError.ts b/sdk/src/errors/SolFoundryError.ts new file mode 100644 index 000000000..4ff72db90 --- /dev/null +++ b/sdk/src/errors/SolFoundryError.ts @@ -0,0 +1,49 @@ +import type { ApiProblem, RateLimitState } from "../types/index.js"; + +/** + * Base SDK error with request context attached. + */ +export class SolFoundryError extends Error { + /** + * HTTP status when available. + */ + public readonly status: number | undefined; + + /** + * API error payload when available. + */ + public readonly problem: ApiProblem | undefined; + + /** + * Raw response body when parsing fails or a text response is returned. + */ + public readonly responseBody: unknown; + + /** + * Active rate-limit state at the time the error was raised. + */ + public readonly rateLimit: RateLimitState | undefined; + + public constructor( + message: string, + options: { + status?: number | undefined; + problem?: ApiProblem | undefined; + responseBody?: unknown; + rateLimit?: RateLimitState | undefined; + cause?: unknown; + } = {}, + ) { + super( + message, + Object.prototype.hasOwnProperty.call(options, "cause") + ? { cause: options.cause } + : undefined, + ); + this.name = "SolFoundryError"; + this.status = options.status; + this.problem = options.problem; + this.responseBody = options.responseBody; + this.rateLimit = options.rateLimit; + } +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 0ed837573..7d89ee27e 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,245 +1,3 @@ -/** - * SolFoundry TypeScript SDK โ€” Core Client Library. - * - * This is the primary way developers integrate with SolFoundry's - * on-chain programs and REST APIs. The SDK provides: - * - * - **Bounty operations**: CRUD, search, submissions, autocomplete - * - **Escrow management**: Fund, release, refund, audit ledger - * - **Contributor profiles**: Create, update, list, stats - * - **GitHub integration**: List bounties, check claims, verify completion - * - **Solana helpers**: PDA derivation, account deserialization, tx building - * - **Real-time events**: WebSocket subscriptions with auto-reconnect - * - **Error handling**: Typed error hierarchy matching backend exceptions - * - **Connection management**: Retry logic, rate limiting, timeout handling - * - * @example - * ```typescript - * import { SolFoundry } from '@solfoundry/sdk'; - * - * const client = SolFoundry.create({ - * baseUrl: 'https://api.solfoundry.io', - * authToken: 'your-jwt-token', - * }); - * - * // List open bounties - * const bounties = await client.bounties.list({ status: 'open' }); - * - * // Get Solana balances - * const balance = await client.solana.getTokenBalance(walletPubkey); - * ``` - * - * @packageDocumentation - */ - -// Core client -export { HttpClient, RateLimiter, buildUrl, calculateBackoff, isRetryable } from './client.js'; -export type { RequestOptions } from './client.js'; - -// Resource clients -export { BountyClient } from './bounties.js'; -export { EscrowClient } from './escrow.js'; -export { ContributorClient } from './contributors.js'; -export { GitHubClient } from './github.js'; -export type { GitHubClientConfig } from './github.js'; -export { EventSubscriber } from './events.js'; -export type { EventSubscriberConfig, EventHandler, ConnectionHandler, ErrorHandler } from './events.js'; - -// Solana helpers -export { - FNDRY_TOKEN_MINT, - TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, - TREASURY_WALLET, - DEFAULT_RPC_ENDPOINT, - FNDRY_DECIMALS, - findAssociatedTokenAddress, - findProgramAddress, - findEscrowAddress, - findReputationAddress, - getSolBalance, - getTokenBalance, - buildTokenTransferInstruction, - buildTokenTransferTransaction, - createConnection, - isValidSolanaAddress, - toRawAmount, - toUiAmount, - Connection, - PublicKey, - Transaction, - SystemProgram, -} from './solana.js'; -export type { SolBalance, TokenBalance } from './solana.js'; - -// Error hierarchy -export { - SolFoundryError, - ValidationError, - AuthenticationError, - AuthorizationError, - NotFoundError, - ConflictError, - LockError, - RateLimitError, - UpstreamError, - ServerError, - NetworkError, - RetryExhaustedError, -} from './errors.js'; - -// Types (re-export everything) -export type { - // Enums - BountyTier, - BountyStatus, - SubmissionStatus, - EscrowState, - LedgerAction, - // Bounty types - BountyCreate, - BountyUpdate, - BountyResponse, - BountyListItem, - BountyListResponse, - // Submission types - SubmissionCreate, - SubmissionResponse, - SubmissionStatusUpdate, - // Search types - BountySearchSort, - BountyCategory, - BountySearchParams, - BountySearchResult, - BountySearchResponse, - AutocompleteItem, - AutocompleteResponse, - // Escrow types - EscrowFundRequest, - EscrowReleaseRequest, - EscrowRefundRequest, - EscrowResponse, - EscrowLedgerEntry, - EscrowStatusResponse, - // Contributor types - ContributorCreate, - ContributorUpdate, - ContributorResponse, - ContributorListResponse, - // Stats types - TierStats, - TopContributor, - StatsResponse, - // Health types - ServiceHealth, - HealthResponse, - // GitHub types - GitHubBountyIssue, - GitHubPullRequest, - // WebSocket types - WebSocketEventType, - WebSocketEvent, - // Config types - SolFoundryClientConfig, - ApiErrorResponse, -} from './types.js'; - -// Program clients (on-chain Anchor program interaction) -export { - BaseClient, - BountyRegistryClient, - StakingClient, - EscrowProgramClient, - ReputationClient, - TreasuryClient, -} from './programs/index.js'; -export type { - ProgramClientConfig, - EscrowProgramClientConfig, - ReputationClientConfig, - TreasuryClientConfig, -} from './programs/index.js'; - -// --------------------------------------------------------------------------- -// Convenience factory -// --------------------------------------------------------------------------- - -import { HttpClient } from './client.js'; -import { BountyClient } from './bounties.js'; -import { EscrowClient } from './escrow.js'; -import { ContributorClient } from './contributors.js'; -import type { SolFoundryClientConfig } from './types.js'; - -/** - * Unified facade providing access to all SolFoundry SDK resource clients - * from a single entry point. - * - * This is the recommended way to use the SDK. Create an instance with - * {@link SolFoundry.create} and access resource-specific methods through - * the `bounties`, `escrow`, and `contributors` properties. - * - * @example - * ```typescript - * const sf = SolFoundry.create({ - * baseUrl: 'https://api.solfoundry.io', - * authToken: 'eyJ...', - * }); - * - * // Access bounty operations - * const list = await sf.bounties.list({ status: 'open' }); - * - * // Access escrow operations - * const escrow = await sf.escrow.getStatus('bounty-uuid'); - * - * // Access contributor operations - * const stats = await sf.contributors.getStats(); - * ``` - */ -export class SolFoundry { - /** The underlying HTTP client used for all API requests. */ - public readonly http: HttpClient; - - /** Client for bounty CRUD, search, and submission operations. */ - public readonly bounties: BountyClient; - - /** Client for escrow lifecycle management. */ - public readonly escrow: EscrowClient; - - /** Client for contributor profiles and platform statistics. */ - public readonly contributors: ContributorClient; - - /** - * Create a SolFoundry SDK instance with the given configuration. - * - * @param config - Client configuration (base URL, auth, retry settings). - */ - private constructor(config: SolFoundryClientConfig) { - this.http = new HttpClient(config); - this.bounties = new BountyClient(this.http); - this.escrow = new EscrowClient(this.http); - this.contributors = new ContributorClient(this.http); - } - - /** - * Factory method to create a new SolFoundry SDK instance. - * - * @param config - Client configuration including base URL, optional auth token, - * RPC endpoint, timeout, retry, and rate limit settings. - * @returns A fully configured SolFoundry SDK instance. - */ - static create(config: SolFoundryClientConfig): SolFoundry { - return new SolFoundry(config); - } - - /** - * Update the authentication token for all subsequent API requests. - * - * Call this after obtaining a new JWT through GitHub OAuth or - * Solana wallet authentication. - * - * @param token - The new JWT bearer token, or undefined to clear auth. - */ - setAuthToken(token: string | undefined): void { - this.http.setAuthToken(token); - } -} +export { SolFoundryClient } from "./client/SolFoundryClient.js"; +export { SolFoundryError } from "./errors/SolFoundryError.js"; +export * from "./types/index.js"; diff --git a/sdk/src/types/bounties.ts b/sdk/src/types/bounties.ts new file mode 100644 index 000000000..a28b1e00b --- /dev/null +++ b/sdk/src/types/bounties.ts @@ -0,0 +1,186 @@ +import type { JsonObject, PaginatedResponse, ResourceId, SortOrder, Timestamps } from "./common.js"; + +/** + * Lifecycle states for a bounty. + */ +export type BountyStatus = "draft" | "open" | "in_review" | "awarded" | "closed" | "archived"; + +/** + * Difficulty levels used to classify bounties. + */ +export type BountyDifficulty = "beginner" | "intermediate" | "advanced" | "expert"; + +/** + * Bounty payout details. + */ +export interface BountyReward { + /** + * Token or fiat currency symbol. + */ + currency: string; + /** + * Reward amount as a decimal string to preserve precision. + */ + amount: string; +} + +/** + * SolFoundry bounty resource. + */ +export interface Bounty extends Timestamps { + /** + * Unique bounty identifier. + */ + id: ResourceId; + /** + * Public title shown to participants. + */ + title: string; + /** + * Long-form bounty description. + */ + description: string; + /** + * Bounty status. + */ + status: BountyStatus; + /** + * Difficulty label. + */ + difficulty?: BountyDifficulty; + /** + * Reward configuration. + */ + reward: BountyReward; + /** + * Optional tags for search and categorization. + */ + tags: string[]; + /** + * ISO-8601 deadline timestamp. + */ + deadline?: string; + /** + * User id of the bounty owner. + */ + ownerId: ResourceId; + /** + * Additional provider-defined attributes. + */ + metadata?: JsonObject; +} + +/** + * Payload for bounty creation. + */ +export interface CreateBountyInput { + /** + * Public title shown to participants. + */ + title: string; + /** + * Long-form bounty description. + */ + description: string; + /** + * Reward configuration. + */ + reward: BountyReward; + /** + * Optional draft/open status at creation time. + */ + status?: Extract; + /** + * Difficulty label. + */ + difficulty?: BountyDifficulty; + /** + * Optional tags for search and categorization. + */ + tags?: string[]; + /** + * ISO-8601 deadline timestamp. + */ + deadline?: string; + /** + * Additional provider-defined attributes. + */ + metadata?: JsonObject; +} + +/** + * Payload for partial bounty updates. + */ +export interface UpdateBountyInput { + /** + * Updated title. + */ + title?: string; + /** + * Updated description. + */ + description?: string; + /** + * Updated reward configuration. + */ + reward?: BountyReward; + /** + * Updated bounty status. + */ + status?: BountyStatus; + /** + * Updated difficulty label. + */ + difficulty?: BountyDifficulty; + /** + * Updated tags. + */ + tags?: string[]; + /** + * Updated deadline. + */ + deadline?: string | null; + /** + * Updated custom metadata. + */ + metadata?: JsonObject; +} + +/** + * Query options for listing bounties. + */ +export interface ListBountiesParams { + /** + * Pagination cursor. + */ + cursor?: string; + /** + * Page size. + */ + limit?: number; + /** + * Filter by status. + */ + status?: BountyStatus; + /** + * Filter by owner. + */ + ownerId?: ResourceId; + /** + * Filter by tag. + */ + tag?: string; + /** + * Free-text search query. + */ + search?: string; + /** + * Sort order for creation time. + */ + sort?: SortOrder; +} + +/** + * Paginated bounty list. + */ +export type BountyListResponse = PaginatedResponse; diff --git a/sdk/src/types/client.ts b/sdk/src/types/client.ts new file mode 100644 index 000000000..506e25a5a --- /dev/null +++ b/sdk/src/types/client.ts @@ -0,0 +1,130 @@ +import type { RateLimitState } from "./common.js"; + +/** + * Supported authentication configuration. + * + * In {@link SolFoundryAuthConfig}, {@link getAccessToken} takes precedence over + * {@link accessToken}. When {@link getAccessToken} returns `undefined`, + * {@link accessToken} is used as a fallback. {@link apiKey} is always sent as + * the `X-API-Key` header in addition to any bearer token. + */ +export interface SolFoundryAuthConfig { + /** + * Static bearer token used for all requests when {@link getAccessToken} + * does not provide one. + */ + accessToken?: string; + /** + * API key sent as `X-API-Key` alongside any bearer token. + */ + apiKey?: string; + /** + * Custom callback for resolving a bearer token lazily before falling back to + * {@link accessToken}. + */ + getAccessToken?: () => string | undefined | Promise; +} + +/** + * Retry configuration for transient failures. + */ +export interface RetryConfig { + /** + * Maximum number of retries after the initial request. + */ + maxRetries?: number; + /** + * Base delay used for exponential backoff in milliseconds. + */ + baseDelayMs?: number; + /** + * Maximum delay between retry attempts in milliseconds. + */ + maxDelayMs?: number; + /** + * HTTP status codes eligible for retry. + */ + retryableStatusCodes?: number[]; +} + +/** + * Client-side rate limiter configuration. + */ +export interface RateLimitConfig { + /** + * Minimum delay between outbound requests in milliseconds. + */ + minIntervalMs?: number; + /** + * When true, honor server-provided `Retry-After` headers. + */ + respectRetryAfter?: boolean; +} + +/** + * SDK client configuration. + */ +export interface SolFoundryClientConfig { + /** + * Base URL for the SolFoundry API. + */ + baseUrl?: string; + /** + * Authentication settings. + */ + auth?: SolFoundryAuthConfig; + /** + * Additional default headers. + */ + headers?: Record; + /** + * Optional custom fetch implementation. + */ + fetch?: typeof globalThis.fetch; + /** + * Request timeout in milliseconds. + */ + timeoutMs?: number; + /** + * Retry behavior for transient failures. + */ + retry?: RetryConfig; + /** + * Client-side rate limiting behavior. + */ + rateLimit?: RateLimitConfig; + /** + * Hook invoked after each response. + */ + onResponse?: (response: Response, rateLimitState: RateLimitState) => void; +} + +/** + * Low-level request options. + */ +export interface RequestOptions { + /** + * Query string parameters. + */ + query?: object; + /** + * JSON payload sent to the API. + */ + body?: unknown; + /** + * Additional per-request headers. + */ + headers?: Record; + /** + * Override timeout for a single request. + */ + timeoutMs?: number; + /** + * Skip authentication headers. + */ + skipAuth?: boolean; + /** + * Expected response type. + */ + responseType?: "json" | "text" | "void"; +} diff --git a/sdk/src/types/common.ts b/sdk/src/types/common.ts new file mode 100644 index 000000000..ac29b2914 --- /dev/null +++ b/sdk/src/types/common.ts @@ -0,0 +1,104 @@ +/** + * Generic primitive-backed dictionary. + */ +export type JsonObject = Record; + +/** + * Shared identifier type used by API resources. + */ +export type ResourceId = string; + +/** + * Cursor metadata returned by paginated SolFoundry endpoints. + */ +export interface PaginationMeta { + /** + * Cursor for the next page of results. + */ + nextCursor?: string; + /** + * Cursor for the previous page of results. + */ + previousCursor?: string; + /** + * Number of items returned in the current page. + */ + count: number; + /** + * Total number of items if supplied by the server. + */ + total?: number; +} + +/** + * Standard paginated response shape. + */ +export interface PaginatedResponse { + /** + * Page items. + */ + items: T[]; + /** + * Pagination metadata. + */ + meta: PaginationMeta; +} + +/** + * Sort direction accepted by collection endpoints. + */ +export type SortOrder = "asc" | "desc"; + +/** + * Shared timestamp fields returned by the API. + */ +export interface Timestamps { + /** + * ISO-8601 creation timestamp. + */ + createdAt: string; + /** + * ISO-8601 update timestamp. + */ + updatedAt: string; +} + +/** + * Metadata about the active rate-limit window. + */ +export interface RateLimitState { + /** + * Maximum requests allowed in the current server window. + */ + limit?: number; + /** + * Remaining requests in the current server window. + */ + remaining?: number; + /** + * UTC epoch milliseconds when the window resets. + */ + resetAt?: number; + /** + * Server-advised wait time in milliseconds before retrying. + */ + retryAfterMs?: number; +} + +/** + * Problem details shape returned by the API. + */ +export interface ApiProblem { + /** + * Machine-readable error code. + */ + code?: string; + /** + * Human-readable error message. + */ + message: string; + /** + * Additional error details. + */ + details?: JsonObject; +} diff --git a/sdk/src/types/index.ts b/sdk/src/types/index.ts new file mode 100644 index 000000000..c0bd8795f --- /dev/null +++ b/sdk/src/types/index.ts @@ -0,0 +1,5 @@ +export * from "./bounties.js"; +export * from "./client.js"; +export * from "./common.js"; +export * from "./submissions.js"; +export * from "./users.js"; diff --git a/sdk/src/types/submissions.ts b/sdk/src/types/submissions.ts new file mode 100644 index 000000000..8c6d6d989 --- /dev/null +++ b/sdk/src/types/submissions.ts @@ -0,0 +1,151 @@ +import type { JsonObject, PaginatedResponse, ResourceId, SortOrder, Timestamps } from "./common.js"; + +/** + * Submission workflow state. + */ +export type SubmissionStatus = "submitted" | "under_review" | "changes_requested" | "approved" | "rejected"; + +/** + * Reviewer decision payload. + */ +export interface ReviewDecision { + /** + * Free-form reviewer feedback. + */ + comment?: string; + /** + * Optional structured score or rubric data. + */ + metadata?: JsonObject; +} + +/** + * SolFoundry submission resource. + */ +export interface Submission extends Timestamps { + /** + * Unique submission identifier. + */ + id: ResourceId; + /** + * Associated bounty identifier. + */ + bountyId: ResourceId; + /** + * Submitter user identifier. + */ + userId: ResourceId; + /** + * Current review state. + */ + status: SubmissionStatus; + /** + * URL pointing at the submission artifact. + */ + artifactUrl?: string; + /** + * Text summary of the submission. + */ + content?: string; + /** + * Optional reviewer feedback. + */ + review?: ReviewDecision; + /** + * Additional provider-defined attributes. + */ + metadata?: JsonObject; +} + +/** + * Payload for creating a submission. + * + * At least one of `artifactUrl` or `content` must be provided. + */ +type CreateSubmissionBase = { + /** + * Associated bounty identifier. + */ + bountyId: ResourceId; + /** + * Additional provider-defined attributes. + */ + metadata?: JsonObject; +}; + +export type CreateSubmissionInput = + | (CreateSubmissionBase & { + /** + * URL pointing at the submission artifact. + */ + artifactUrl: string; + /** + * Text summary of the submission. + */ + content?: string; + }) + | (CreateSubmissionBase & { + /** + * URL pointing at the submission artifact. + */ + artifactUrl?: string; + /** + * Text summary of the submission. + */ + content: string; + }); + +/** + * Payload for reviewing a submission. + */ +export interface ReviewSubmissionInput extends ReviewDecision { + /** + * The resulting submission state after review. + */ + status: Extract; +} + +/** + * Payload for approving a submission. + */ +export interface ApproveSubmissionInput extends ReviewDecision { + /** + * Optional payout id or settlement reference. + */ + settlementReference?: string; +} + +/** + * Query options for listing submissions. + */ +export interface ListSubmissionsParams { + /** + * Pagination cursor. + */ + cursor?: string; + /** + * Page size. + */ + limit?: number; + /** + * Filter by bounty. + */ + bountyId?: ResourceId; + /** + * Filter by submitter. + */ + userId?: ResourceId; + /** + * Filter by submission status. + */ + status?: SubmissionStatus; + /** + * Sort order for creation time. + */ + sort?: SortOrder; +} + +/** + * Paginated submission list. + */ +export type SubmissionListResponse = PaginatedResponse; diff --git a/sdk/src/types/users.ts b/sdk/src/types/users.ts new file mode 100644 index 000000000..07600bfee --- /dev/null +++ b/sdk/src/types/users.ts @@ -0,0 +1,146 @@ +import type { JsonObject, PaginatedResponse, ResourceId, SortOrder, Timestamps } from "./common.js"; + +/** + * SolFoundry user resource. + */ +export interface User extends Timestamps { + /** + * Unique user identifier. + */ + id: ResourceId; + /** + * Public display name. + */ + displayName: string; + /** + * Primary email address. + */ + email?: string; + /** + * Short bio for the user profile. + */ + bio?: string; + /** + * Avatar URL. + */ + avatarUrl?: string; + /** + * External wallet address. + */ + walletAddress?: string; + /** + * Additional provider-defined attributes. + */ + metadata?: JsonObject; +} + +/** + * Authentication response returned after login or token refresh. + */ +export interface AuthSession { + /** + * Bearer token used for API requests. + */ + accessToken: string; + /** + * Optional refresh token. + */ + refreshToken?: string; + /** + * Token expiration time in seconds. + */ + expiresIn?: number; + /** + * Authenticated user. + */ + user: User; +} + +/** + * User login payload. + */ +export interface LoginInput { + /** + * User email. + */ + email: string; + /** + * User password. + */ + password: string; +} + +/** + * User registration payload. + */ +export interface RegisterInput { + /** + * Public display name. + */ + displayName: string; + /** + * User email. + */ + email: string; + /** + * User password. + */ + password: string; + /** + * Optional wallet address. + */ + walletAddress?: string; +} + +/** + * Editable profile fields. + */ +export interface UpdateUserProfileInput { + /** + * Public display name. + */ + displayName?: string; + /** + * Short bio. + */ + bio?: string; + /** + * Avatar URL. + */ + avatarUrl?: string; + /** + * Wallet address. + */ + walletAddress?: string; + /** + * Additional provider-defined attributes. + */ + metadata?: JsonObject; +} + +/** + * Query options for listing users. + */ +export interface ListUsersParams { + /** + * Pagination cursor. + */ + cursor?: string; + /** + * Page size. + */ + limit?: number; + /** + * Free-text search query. + */ + search?: string; + /** + * Sort order for creation time. + */ + sort?: SortOrder; +} + +/** + * Paginated user list. + */ +export type UserListResponse = PaginatedResponse; diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json index 6678efefe..e4426c33b 100644 --- a/sdk/tsconfig.json +++ b/sdk/tsconfig.json @@ -1,19 +1,30 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", "strict": true, - "esModuleInterop": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "useDefineForClassFields": true, "skipLibCheck": true, + "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src" + "lib": [ + "ES2022", + "DOM" + ] }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/__tests__/**", "dist"] + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/__tests__/**", + "src/programs/**", + "src/solana.ts" + ] }