diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml new file mode 100644 index 0000000..0368387 --- /dev/null +++ b/.github/workflows/publish-sdk.yml @@ -0,0 +1,80 @@ +name: Publish SDK to npm + +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g., 1.0.0). Leave empty to use package.json version." + required: false + dry-run: + description: "Dry run (don't actually publish)" + type: boolean + default: false + +permissions: + contents: write + +jobs: + publish: + name: Build & Publish @debros/orama + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Bump version + if: inputs.version != '' + run: npm version ${{ inputs.version }} --no-git-tag-version + + - name: Typecheck + run: pnpm typecheck + + - name: Build + run: pnpm build + + - name: Run tests + run: pnpm test -- --run + + - name: Publish (dry run) + if: inputs.dry-run == true + run: npm publish --access public --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish + if: inputs.dry-run == false + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Get published version + if: inputs.dry-run == false + id: version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Create git tag + if: inputs.dry-run == false + working-directory: . + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "sdk/v${{ steps.version.outputs.version }}" + git push origin "sdk/v${{ steps.version.outputs.version }}" diff --git a/.gitignore b/.gitignore index 790fb26..207bf3a 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,11 @@ website/invest-api/*.db website/invest-api/*.db-shm website/invest-api/*.db-wal +# === SDK (TypeScript) === +sdk/node_modules/ +sdk/dist/ +sdk/coverage/ + # === Vault (Zig) === vault/.zig-cache/ vault/zig-out/ diff --git a/Makefile b/Makefile index 334cd5a..1948253 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,16 @@ website-dev: website-build: cd website && pnpm build +# === SDK (TypeScript) === +.PHONY: sdk sdk-build sdk-test +sdk: sdk-build + +sdk-build: + cd sdk && pnpm install && pnpm build + +sdk-test: + cd sdk && pnpm test + # === Vault (Zig) === .PHONY: vault vault-build vault-test vault-build: diff --git a/README.md b/README.md index ddf1d90..6f4dad5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A decentralized infrastructure platform combining distributed SQL, IPFS storage, | Package | Language | Description | |---------|----------|-------------| | [core/](core/) | Go | API gateway, distributed node, CLI, and client SDK | +| [sdk/](sdk/) | TypeScript | `@debros/orama` — JavaScript/TypeScript SDK ([npm](https://www.npmjs.com/package/@debros/orama)) | | [website/](website/) | TypeScript | Marketing website and invest portal | | [vault/](vault/) | Zig | Distributed secrets vault (Shamir's Secret Sharing) | | [os/](os/) | Go + Buildroot | OramaOS — hardened minimal Linux for network nodes | diff --git a/sdk/.env.example b/sdk/.env.example new file mode 100644 index 0000000..49c2ed1 --- /dev/null +++ b/sdk/.env.example @@ -0,0 +1,4 @@ +# Gateway Configuration +GATEWAY_BASE_URL=http://localhost:6001 +GATEWAY_API_KEY=ak_your_api_key:default +# GATEWAY_JWT=your_jwt_token (optional) diff --git a/sdk/.github/workflows/publish-npm.yml b/sdk/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..9741e97 --- /dev/null +++ b/sdk/.github/workflows/publish-npm.yml @@ -0,0 +1,32 @@ +name: Publish SDK to npm + +on: + push: + tags: + - 'v*' + +jobs: + publish-npm: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install --frozen-lockfile + + - name: Build SDK + run: npm run build + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000..1d979de --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +build/ +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.idea/ +.vscode/ +*.swp +*.swo +*~ +.env +.env.local +coverage/ +.nyc_output/ diff --git a/sdk/.npmrc b/sdk/.npmrc new file mode 100644 index 0000000..9bb528f --- /dev/null +++ b/sdk/.npmrc @@ -0,0 +1,2 @@ +@network:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NPM_TOKEN} diff --git a/sdk/LICENSE b/sdk/LICENSE new file mode 100644 index 0000000..26da2a2 --- /dev/null +++ b/sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeBrosOfficial + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/sdk/QUICKSTART.md b/sdk/QUICKSTART.md new file mode 100644 index 0000000..319b5be --- /dev/null +++ b/sdk/QUICKSTART.md @@ -0,0 +1,170 @@ +# Quick Start Guide for @debros/network-ts-sdk + +## 5-Minute Setup + +### 1. Install + +```bash +npm install @debros/network-ts-sdk +``` + +### 2. Create a Client + +```typescript +import { createClient } from "@debros/network-ts-sdk"; + +const client = createClient({ + baseURL: "http://localhost:6001", + apiKey: "ak_your_api_key:namespace", // Get from gateway +}); +``` + +### 3. Use It + +**Database:** +```typescript +await client.db.createTable("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)"); +await client.db.exec("INSERT INTO posts (title) VALUES (?)", ["Hello"]); +const posts = await client.db.query("SELECT * FROM posts"); +``` + +**Pub/Sub:** +```typescript +const sub = await client.pubsub.subscribe("news", { + onMessage: (msg) => console.log(msg.data), +}); + +await client.pubsub.publish("news", "Update!"); +sub.close(); +``` + +**Network:** +```typescript +const healthy = await client.network.health(); +const status = await client.network.status(); +``` + +## Running Tests Locally + +### Prerequisites +1. Bootstrap node must be running (provides database on port 5001) +2. Gateway must be running (provides REST API on port 6001) + +```bash +# Terminal 1: Start bootstrap node +cd ../network +make run-node + +# Terminal 2: Start gateway (after bootstrap is ready) +cd ../network +make run-gateway + +# Terminal 3: Run E2E tests +cd ../network-ts-sdk +export GATEWAY_BASE_URL=http://localhost:6001 +export GATEWAY_API_KEY=ak_your_api_key:default +pnpm run test:e2e +``` + +**Note**: The gateway configuration now correctly uses port 5001 for RQLite (not 4001 which is P2P). + +## Building for Production + +```bash +npm run build +# Output in dist/ +``` + +## Key Classes + +| Class | Purpose | +|-------|---------| +| `createClient()` | Factory function, returns `Client` | +| `AuthClient` | Authentication, token management | +| `DBClient` | Database operations (exec, query, etc.) | +| `QueryBuilder` | Fluent SELECT builder | +| `Repository` | Generic entity pattern | +| `PubSubClient` | Pub/sub operations | +| `NetworkClient` | Network status, peers | +| `SDKError` | All errors inherit from this | + +## Common Patterns + +### QueryBuilder +```typescript +const items = await client.db + .createQueryBuilder("items") + .where("status = ?", ["active"]) + .andWhere("price > ?", [10]) + .orderBy("created_at DESC") + .limit(20) + .getMany(); +``` + +### Repository +```typescript +interface User { id?: number; email: string; } +const repo = client.db.repository("users"); + +// Save (insert or update) +const user: User = { email: "alice@example.com" }; +await repo.save(user); + +// Find +const found = await repo.findOne({ email: "alice@example.com" }); +``` + +### Transaction +```typescript +await client.db.transaction([ + { kind: "exec", sql: "INSERT INTO logs (msg) VALUES (?)", args: ["Event A"] }, + { kind: "query", sql: "SELECT COUNT(*) FROM logs", args: [] }, +]); +``` + +### Error Handling +```typescript +import { SDKError } from "@debros/network-ts-sdk"; + +try { + await client.db.query("SELECT * FROM invalid_table"); +} catch (error) { + if (error instanceof SDKError) { + console.error(`${error.httpStatus}: ${error.message}`); + } +} +``` + +## TypeScript Types + +Full type safety - use autocomplete in your IDE: +```typescript +const status: NetworkStatus = await client.network.status(); +const users: User[] = await repo.find({ active: 1 }); +const msg: Message = await subscription.onMessage((m) => m); +``` + +## Next Steps + +1. Read the full [README.md](./README.md) +2. Explore [tests/e2e/](./tests/e2e/) for examples +3. Explore [examples/](./examples/) for runnable code samples + +## Troubleshooting + +**"Failed to connect to gateway"** +- Check `GATEWAY_BASE_URL` is correct +- Ensure gateway is running +- Verify network connectivity + +**"API key invalid"** +- Confirm `apiKey` format: `ak_key:namespace` +- Get a fresh API key from gateway admin + +**"WebSocket connection failed"** +- Gateway must support WebSocket at `/v1/pubsub/ws` +- Check firewall settings + +**"Tests skip"** +- Set `GATEWAY_API_KEY` environment variable +- Tests gracefully skip without it diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..6774611 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,665 @@ +# @debros/network-ts-sdk - TypeScript SDK for DeBros Network + +A modern, isomorphic TypeScript SDK for the DeBros Network gateway. Works seamlessly in both Node.js and browser environments with support for database operations, pub/sub messaging, and network management. + +## Features + +- **Isomorphic**: Works in Node.js and browsers (uses fetch and isomorphic-ws) +- **Database ORM-like API**: QueryBuilder, Repository pattern, transactions +- **Pub/Sub Messaging**: WebSocket subscriptions with automatic reconnection +- **Authentication**: API key and JWT support with automatic token management +- **TypeScript First**: Full type safety and IntelliSense +- **Error Handling**: Unified SDKError with HTTP status and code + +## Installation + +```bash +npm install @debros/network-ts-sdk +``` + +## Quick Start + +### Initialize the Client + +```typescript +import { createClient } from "@debros/network-ts-sdk"; + +const client = createClient({ + baseURL: "http://localhost:6001", + apiKey: "ak_your_api_key:namespace", +}); + +// Or with JWT +const client = createClient({ + baseURL: "http://localhost:6001", + jwt: "your_jwt_token", +}); +``` + +### Database Operations + +#### Create a Table + +```typescript +await client.db.createTable( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)" +); +``` + +#### Insert Data + +```typescript +const result = await client.db.exec( + "INSERT INTO users (name, email) VALUES (?, ?)", + ["Alice", "alice@example.com"] +); +console.log(result.last_insert_id); +``` + +#### Query Data + +```typescript +const users = await client.db.query("SELECT * FROM users WHERE email = ?", [ + "alice@example.com", +]); +``` + +#### Using QueryBuilder + +```typescript +const activeUsers = await client.db + .createQueryBuilder("users") + .where("active = ?", [1]) + .orderBy("name DESC") + .limit(10) + .getMany(); + +const firstUser = await client.db + .createQueryBuilder("users") + .where("id = ?", [1]) + .getOne(); +``` + +#### Using Repository Pattern + +```typescript +interface User { + id?: number; + name: string; + email: string; +} + +const repo = client.db.repository("users"); + +// Find +const users = await repo.find({ active: 1 }); +const user = await repo.findOne({ email: "alice@example.com" }); + +// Save (INSERT or UPDATE) +const newUser: User = { name: "Bob", email: "bob@example.com" }; +await repo.save(newUser); + +// Remove +await repo.remove(newUser); +``` + +#### Transactions + +```typescript +const results = await client.db.transaction([ + { + kind: "exec", + sql: "INSERT INTO users (name, email) VALUES (?, ?)", + args: ["Charlie", "charlie@example.com"], + }, + { + kind: "query", + sql: "SELECT COUNT(*) as count FROM users", + args: [], + }, +]); +``` + +### Pub/Sub Messaging + +The SDK provides a robust pub/sub client with: + +- **Multi-subscriber support**: Multiple connections can subscribe to the same topic +- **Namespace isolation**: Topics are scoped to your authenticated namespace +- **Server timestamps**: Messages preserve server-side timestamps +- **Binary-safe**: Supports both string and binary (`Uint8Array`) payloads +- **Strict envelope validation**: Type-safe message parsing with error handling + +#### Publish a Message + +```typescript +// Publish a string message +await client.pubsub.publish("notifications", "Hello, Network!"); + +// Publish binary data +const binaryData = new Uint8Array([1, 2, 3, 4]); +await client.pubsub.publish("binary-topic", binaryData); +``` + +#### Subscribe to Topics + +```typescript +const subscription = await client.pubsub.subscribe("notifications", { + onMessage: (msg) => { + console.log("Topic:", msg.topic); + console.log("Data:", msg.data); + console.log("Server timestamp:", new Date(msg.timestamp)); + }, + onError: (err) => { + console.error("Subscription error:", err); + }, + onClose: () => { + console.log("Subscription closed"); + }, +}); + +// Later, close the subscription +subscription.close(); +``` + +**Message Interface:** + +```typescript +interface Message { + data: string; // Decoded message payload (string) + topic: string; // Topic name + timestamp: number; // Server timestamp in milliseconds +} +``` + +#### Debug Raw Envelopes + +For debugging, you can inspect raw message envelopes before decoding: + +```typescript +const subscription = await client.pubsub.subscribe("notifications", { + onMessage: (msg) => { + console.log("Decoded message:", msg.data); + }, + onRaw: (envelope) => { + console.log("Raw envelope:", envelope); + // { data: "base64...", timestamp: 1234567890, topic: "notifications" } + }, +}); +``` + +#### Multi-Subscriber Support + +Multiple subscriptions to the same topic are supported. Each receives its own copy of messages: + +```typescript +// First subscriber +const sub1 = await client.pubsub.subscribe("events", { + onMessage: (msg) => console.log("Sub1:", msg.data), +}); + +// Second subscriber (both receive messages) +const sub2 = await client.pubsub.subscribe("events", { + onMessage: (msg) => console.log("Sub2:", msg.data), +}); + +// Unsubscribe independently +sub1.close(); // sub2 still active +sub2.close(); // fully unsubscribed +``` + +#### List Topics + +```typescript +const topics = await client.pubsub.topics(); +console.log("Active topics:", topics); +``` + +### Presence Support + +The SDK supports real-time presence tracking, allowing you to see who is currently subscribed to a topic. + +#### Subscribe with Presence + +Enable presence by providing `presence` options in `subscribe`: + +```typescript +const subscription = await client.pubsub.subscribe("room.123", { + onMessage: (msg) => console.log("Message:", msg.data), + presence: { + enabled: true, + memberId: "user-alice", + meta: { displayName: "Alice", avatar: "URL" }, + onJoin: (member) => { + console.log(`${member.memberId} joined at ${new Date(member.joinedAt)}`); + console.log("Meta:", member.meta); + }, + onLeave: (member) => { + console.log(`${member.memberId} left`); + }, + }, +}); +``` + +#### Get Presence for a Topic + +Query current members without subscribing: + +```typescript +const presence = await client.pubsub.getPresence("room.123"); +console.log(`Total members: ${presence.count}`); +presence.members.forEach((member) => { + console.log(`- ${member.memberId} (joined: ${new Date(member.joinedAt)})`); +}); +``` + +#### Subscription Helpers + +Get presence information from an active subscription: + +```typescript +if (subscription.hasPresence()) { + const members = await subscription.getPresence(); + console.log("Current members:", members); +} +``` + +### Authentication + +#### Switch API Key + +```typescript +client.auth.setApiKey("ak_new_key:namespace"); +``` + +#### Switch JWT + +```typescript +client.auth.setJwt("new_jwt_token"); +``` + +#### Get Current Token + +```typescript +const token = client.auth.getToken(); // Returns API key or JWT +``` + +#### Get Authentication Info + +```typescript +const info = await client.auth.whoami(); +console.log(info.authenticated, info.namespace); +``` + +#### Logout + +```typescript +await client.auth.logout(); +``` + +### Network Operations + +#### Check Health + +```typescript +const healthy = await client.network.health(); +``` + +#### Get Network Status + +```typescript +const status = await client.network.status(); +console.log(status.healthy, status.peers); +``` + +#### List Peers + +```typescript +const peers = await client.network.peers(); +peers.forEach((peer) => { + console.log(peer.id, peer.addresses); +}); +``` + +#### Proxy Requests Through Anyone Network + +Make anonymous HTTP requests through the Anyone network: + +```typescript +// Simple GET request +const response = await client.network.proxyAnon({ + url: "https://api.example.com/data", + method: "GET", + headers: { + Accept: "application/json", + }, +}); + +console.log(response.status_code); // 200 +console.log(response.body); // Response data as string +console.log(response.headers); // Response headers + +// POST request with body +const postResponse = await client.network.proxyAnon({ + url: "https://api.example.com/submit", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ key: "value" }), +}); + +// Parse JSON response +const data = JSON.parse(postResponse.body); +``` + +**Note:** The proxy endpoint requires authentication (API key or JWT) and only works when the Anyone relay is running on the gateway server. + +## Configuration + +### ClientConfig + +```typescript +interface ClientConfig { + baseURL: string; // Gateway URL + apiKey?: string; // API key (optional, if using JWT instead) + jwt?: string; // JWT token (optional, if using API key instead) + timeout?: number; // Request timeout in ms (default: 30000) + maxRetries?: number; // Max retry attempts (default: 3) + retryDelayMs?: number; // Delay between retries (default: 1000) + debug?: boolean; // Enable debug logging with full SQL queries (default: false) + storage?: StorageAdapter; // For persisting JWT/API key (default: MemoryStorage) + wsConfig?: Partial; // WebSocket configuration + fetch?: typeof fetch; // Custom fetch implementation +} +``` + +### Storage Adapters + +By default, credentials are stored in memory. For browser apps, use localStorage: + +```typescript +import { createClient, LocalStorageAdapter } from "@debros/network-ts-sdk"; + +const client = createClient({ + baseURL: "http://localhost:6001", + storage: new LocalStorageAdapter(), + apiKey: "ak_your_key:namespace", +}); +``` + +### Cache Operations + +The SDK provides a distributed cache client backed by Olric. Data is organized into distributed maps (dmaps). + +#### Put a Value + +```typescript +// Put with optional TTL +await client.cache.put("sessions", "user:alice", { role: "admin" }, "1h"); +``` + +#### Get a Value + +```typescript +// Returns null on cache miss (not an error) +const result = await client.cache.get("sessions", "user:alice"); +if (result) { + console.log(result.value); // { role: "admin" } +} +``` + +#### Delete a Value + +```typescript +await client.cache.delete("sessions", "user:alice"); +``` + +#### Multi-Get + +```typescript +const results = await client.cache.multiGet("sessions", [ + "user:alice", + "user:bob", +]); +// Returns Map — null for misses +results.forEach((value, key) => { + console.log(key, value); +}); +``` + +#### Scan Keys + +```typescript +// Scan all keys in a dmap, optionally matching a regex +const scan = await client.cache.scan("sessions", "user:.*"); +console.log(scan.keys); // ["user:alice", "user:bob"] +console.log(scan.count); // 2 +``` + +#### Health Check + +```typescript +const health = await client.cache.health(); +console.log(health.status); // "ok" +``` + +### Storage (IPFS) + +Upload, pin, and retrieve files from decentralized IPFS storage. + +#### Upload a File + +```typescript +// Browser +const fileInput = document.querySelector('input[type="file"]'); +const file = fileInput.files[0]; +const result = await client.storage.upload(file, file.name); +console.log(result.cid); // "Qm..." + +// Node.js +import { readFileSync } from "fs"; +const buffer = readFileSync("image.jpg"); +const result = await client.storage.upload(buffer, "image.jpg", { pin: true }); +``` + +#### Retrieve Content + +```typescript +// Get as ReadableStream +const stream = await client.storage.get(cid); +const reader = stream.getReader(); +while (true) { + const { done, value } = await reader.read(); + if (done) break; + // Process chunk +} + +// Get full Response (for headers like content-length) +const response = await client.storage.getBinary(cid); +const contentLength = response.headers.get("content-length"); +``` + +#### Pin / Unpin / Status + +```typescript +// Pin an existing CID +await client.storage.pin("QmExampleCid", "my-file"); + +// Check pin status +const status = await client.storage.status("QmExampleCid"); +console.log(status.status); // "pinned", "pinning", "queued", "unpinned", "error" + +// Unpin +await client.storage.unpin("QmExampleCid"); +``` + +### Serverless Functions (WASM) + +Invoke WebAssembly serverless functions deployed on the network. + +```typescript +// Configure functions namespace +const client = createClient({ + baseURL: "http://localhost:6001", + apiKey: "ak_your_key:namespace", + functionsConfig: { + namespace: "my-namespace", + }, +}); + +// Invoke a function with typed input/output +interface PushInput { + token: string; + message: string; +} +interface PushOutput { + success: boolean; + messageId: string; +} + +const result = await client.functions.invoke( + "send-push", + { token: "device-token", message: "Hello!" } +); +console.log(result.messageId); +``` + +### Vault (Distributed Secrets) + +The vault client provides Shamir-split secret storage across guardian nodes. Secrets are split into shares, distributed to guardians, and reconstructed only when enough shares are collected (quorum). + +```typescript +const client = createClient({ + baseURL: "http://localhost:6001", + apiKey: "ak_your_key:namespace", + vaultConfig: { + guardians: [ + { address: "10.0.0.1", port: 8443 }, + { address: "10.0.0.2", port: 8443 }, + { address: "10.0.0.3", port: 8443 }, + ], + identityHex: "your-identity-hex", + }, +}); + +// Store a secret (Shamir-split across guardians) +const data = new TextEncoder().encode("my-secret-data"); +const storeResult = await client.vault.store("api-key", data, 1); +console.log(storeResult.quorumMet); // true if enough guardians ACKed + +// Retrieve and reconstruct a secret +const retrieved = await client.vault.retrieve("api-key"); +console.log(new TextDecoder().decode(retrieved.data)); // "my-secret-data" + +// List all secrets for this identity +const secrets = await client.vault.list(); +console.log(secrets.secrets); + +// Delete a secret from all guardians +await client.vault.delete("api-key"); +``` + +### Wallet-Based Authentication + +For wallet-based auth (challenge-response flow): + +```typescript +// 1. Request a challenge +const challenge = await client.auth.challenge(); + +// 2. Sign the challenge with your wallet (external) +const signature = await wallet.signMessage(challenge.message); + +// 3. Verify signature and get JWT +const session = await client.auth.verify(challenge.id, signature); +console.log(session.token); + +// 4. Get an API key for long-lived access +const apiKey = await client.auth.getApiKey(); +``` + +## Error Handling + +The SDK throws `SDKError` for all errors: + +```typescript +import { SDKError } from "@debros/network-ts-sdk"; + +try { + await client.db.query("SELECT * FROM nonexistent"); +} catch (error) { + if (error instanceof SDKError) { + console.log(error.httpStatus); // e.g., 400 + console.log(error.code); // e.g., "HTTP_400" + console.log(error.message); // Error message + console.log(error.details); // Full error response + } +} +``` + +## Browser Usage + +The SDK works in browsers with minimal setup: + +```typescript +// Browser example +import { createClient } from "@debros/network-ts-sdk"; + +const client = createClient({ + baseURL: "https://gateway.example.com", + apiKey: "ak_browser_key:my-app", +}); + +// Use like any other API client +const data = await client.db.query("SELECT * FROM items"); +``` + +**Note**: For WebSocket connections in browsers with authentication, ensure your gateway supports either header-based auth or query parameter auth. + +## Testing + +Run E2E tests against a running gateway: + +```bash +# Set environment variables +export GATEWAY_BASE_URL=http://localhost:6001 +export GATEWAY_API_KEY=ak_test_key:default + +# Run tests +npm run test:e2e +``` + +## Examples + +See the `tests/e2e/` directory for complete examples of: + +- Authentication (`auth.test.ts`) +- Database operations (`db.test.ts`) +- Transactions (`tx.test.ts`) +- Pub/Sub messaging (`pubsub.test.ts`) +- Network operations (`network.test.ts`) + +## Building + +```bash +npm run build +``` + +Output goes to `dist/` with ESM and type declarations. + +## Development + +```bash +npm run dev # Watch mode +npm run typecheck # Type checking +npm run lint # Linting (if configured) +``` + +## License + +MIT + +## Support + +For issues, questions, or contributions, please open an issue on GitHub or visit [DeBros Network Documentation](https://network.debros.io/docs/). diff --git a/sdk/examples/basic-usage.ts b/sdk/examples/basic-usage.ts new file mode 100644 index 0000000..100480b --- /dev/null +++ b/sdk/examples/basic-usage.ts @@ -0,0 +1,100 @@ +/** + * Basic Usage Example + * + * This example demonstrates the fundamental usage of the DeBros Network SDK. + * It covers client initialization, database operations, pub/sub, and caching. + */ + +import { createClient } from '../src/index'; + +async function main() { + // 1. Create client + const client = createClient({ + baseURL: 'http://localhost:6001', + apiKey: 'ak_your_key:default', + debug: true, // Enable debug logging + }); + + console.log('✓ Client created'); + + // 2. Database operations + console.log('\n--- Database Operations ---'); + + // Create table + await client.db.createTable( + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + )` + ); + console.log('✓ Table created'); + + // Insert data + const result = await client.db.exec( + 'INSERT INTO users (name, email) VALUES (?, ?)', + ['Alice Johnson', 'alice@example.com'] + ); + console.log(`✓ Inserted user with ID: ${result.last_insert_id}`); + + // Query data + const users = await client.db.query( + 'SELECT * FROM users WHERE email = ?', + ['alice@example.com'] + ); + console.log('✓ Found users:', users); + + // 3. Pub/Sub messaging + console.log('\n--- Pub/Sub Messaging ---'); + + const subscription = await client.pubsub.subscribe('demo-topic', { + onMessage: (msg) => { + console.log(`✓ Received message: "${msg.data}" at ${new Date(msg.timestamp).toISOString()}`); + }, + onError: (err) => console.error('Subscription error:', err), + }); + console.log('✓ Subscribed to demo-topic'); + + // Publish a message + await client.pubsub.publish('demo-topic', 'Hello from the SDK!'); + console.log('✓ Published message'); + + // Wait a bit for message delivery + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Close subscription + subscription.close(); + console.log('✓ Subscription closed'); + + // 4. Cache operations + console.log('\n--- Cache Operations ---'); + + // Put value with 1-hour TTL + await client.cache.put('default', 'user:alice', { + id: result.last_insert_id, + name: 'Alice Johnson', + email: 'alice@example.com', + }, '1h'); + console.log('✓ Cached user data'); + + // Get value + const cached = await client.cache.get('default', 'user:alice'); + if (cached) { + console.log('✓ Retrieved from cache:', cached.value); + } + + // 5. Network health check + console.log('\n--- Network Operations ---'); + + const healthy = await client.network.health(); + console.log(`✓ Gateway health: ${healthy ? 'OK' : 'FAIL'}`); + + const status = await client.network.status(); + console.log(`✓ Network status: ${status.peer_count} peers connected`); + + console.log('\n--- Example completed successfully ---'); +} + +// Run example +main().catch(console.error); diff --git a/sdk/examples/database-crud.ts b/sdk/examples/database-crud.ts new file mode 100644 index 0000000..9b7ff3e --- /dev/null +++ b/sdk/examples/database-crud.ts @@ -0,0 +1,170 @@ +/** + * Database CRUD Operations Example + * + * Demonstrates comprehensive database operations including: + * - Table creation and schema management + * - Insert, Update, Delete operations + * - QueryBuilder fluent API + * - Repository pattern (ORM-style) + * - Transactions + */ + +import { createClient } from '../src/index'; + +interface User { + id?: number; + name: string; + email: string; + age: number; + active?: number; + created_at?: number; +} + +async function main() { + const client = createClient({ + baseURL: 'http://localhost:6001', + apiKey: 'ak_your_key:default', + }); + + // 1. Create table + console.log('Creating users table...'); + await client.db.createTable( + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + age INTEGER, + active INTEGER DEFAULT 1, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + )` + ); + + // 2. Raw SQL INSERT + console.log('\n--- Raw SQL Operations ---'); + const insertResult = await client.db.exec( + 'INSERT INTO users (name, email, age) VALUES (?, ?, ?)', + ['Bob Smith', 'bob@example.com', 30] + ); + console.log('Inserted ID:', insertResult.last_insert_id); + + // 3. Raw SQL UPDATE + await client.db.exec( + 'UPDATE users SET age = ? WHERE id = ?', + [31, insertResult.last_insert_id] + ); + console.log('Updated user age'); + + // 4. Raw SQL SELECT + const users = await client.db.query( + 'SELECT * FROM users WHERE email = ?', + ['bob@example.com'] + ); + console.log('Found users:', users); + + // 5. QueryBuilder + console.log('\n--- QueryBuilder Operations ---'); + + // Insert multiple users for querying + await client.db.exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", ["Charlie", "charlie@example.com", 25]); + await client.db.exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", ["Diana", "diana@example.com", 35]); + await client.db.exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", ["Eve", "eve@example.com", 28]); + + // Complex query with QueryBuilder + const activeUsers = await client.db + .createQueryBuilder('users') + .select('id', 'name', 'email', 'age') + .where('active = ?', [1]) + .andWhere('age > ?', [25]) + .orderBy('age DESC') + .limit(10) + .getMany(); + + console.log('Active users over 25:', activeUsers); + + // Get single user + const singleUser = await client.db + .createQueryBuilder('users') + .where('email = ?', ['charlie@example.com']) + .getOne(); + + console.log('Single user:', singleUser); + + // Count users + const count = await client.db + .createQueryBuilder('users') + .where('age > ?', [25]) + .count(); + + console.log('Users over 25:', count); + + // 6. Repository Pattern (ORM) + console.log('\n--- Repository Pattern ---'); + + const userRepo = client.db.repository('users'); + + // Find all + const allUsers = await userRepo.find({}); + console.log('All users:', allUsers.length); + + // Find with criteria + const youngUsers = await userRepo.find({ age: 25 }); + console.log('Users aged 25:', youngUsers); + + // Find one + const diana = await userRepo.findOne({ email: 'diana@example.com' }); + console.log('Found Diana:', diana); + + // Save (insert new) + const newUser: User = { + name: 'Frank', + email: 'frank@example.com', + age: 40, + }; + await userRepo.save(newUser); + console.log('Saved new user:', newUser); + + // Save (update existing) + if (diana) { + diana.age = 36; + await userRepo.save(diana); + console.log('Updated Diana:', diana); + } + + // Remove + if (newUser.id) { + await userRepo.remove(newUser); + console.log('Deleted Frank'); + } + + // 7. Transactions + console.log('\n--- Transaction Operations ---'); + + const txResults = await client.db.transaction([ + { + kind: 'exec', + sql: 'INSERT INTO users (name, email, age) VALUES (?, ?, ?)', + args: ['Grace', 'grace@example.com', 27], + }, + { + kind: 'exec', + sql: 'UPDATE users SET active = ? WHERE age < ?', + args: [0, 26], + }, + { + kind: 'query', + sql: 'SELECT COUNT(*) as count FROM users WHERE active = ?', + args: [1], + }, + ]); + + console.log('Transaction results:', txResults); + + // 8. Get schema + console.log('\n--- Schema Information ---'); + const schema = await client.db.getSchema(); + console.log('Database schema:', schema); + + console.log('\n--- CRUD operations completed successfully ---'); +} + +main().catch(console.error); diff --git a/sdk/examples/pubsub-chat.ts b/sdk/examples/pubsub-chat.ts new file mode 100644 index 0000000..58a09dd --- /dev/null +++ b/sdk/examples/pubsub-chat.ts @@ -0,0 +1,140 @@ +/** + * Pub/Sub Chat Example + * + * Demonstrates a simple chat application using pub/sub with presence tracking. + * Multiple clients can join a room, send messages, and see who's online. + */ + +import { createClient } from '../src/index'; +import type { PresenceMember } from '../src/index'; + +interface ChatMessage { + user: string; + text: string; + timestamp: number; +} + +async function createChatClient(userName: string, roomName: string) { + const client = createClient({ + baseURL: 'http://localhost:6001', + apiKey: 'ak_your_key:default', + }); + + console.log(`[${userName}] Joining room: ${roomName}...`); + + // Subscribe to chat room with presence + const subscription = await client.pubsub.subscribe(roomName, { + onMessage: (msg) => { + try { + const chatMsg: ChatMessage = JSON.parse(msg.data); + const time = new Date(chatMsg.timestamp).toLocaleTimeString(); + console.log(`[${time}] ${chatMsg.user}: ${chatMsg.text}`); + } catch { + console.log(`[${userName}] Received: ${msg.data}`); + } + }, + onError: (err) => { + console.error(`[${userName}] Error:`, err.message); + }, + onClose: () => { + console.log(`[${userName}] Disconnected from ${roomName}`); + }, + presence: { + enabled: true, + memberId: userName, + meta: { + displayName: userName, + joinedAt: Date.now(), + }, + onJoin: (member: PresenceMember) => { + console.log(`[${userName}] 👋 ${member.memberId} joined the room`); + if (member.meta) { + console.log(`[${userName}] Display name: ${member.meta.displayName}`); + } + }, + onLeave: (member: PresenceMember) => { + console.log(`[${userName}] 👋 ${member.memberId} left the room`); + }, + }, + }); + + console.log(`[${userName}] ✓ Joined ${roomName}`); + + // Send a join message + await sendMessage(client, roomName, userName, 'Hello everyone!'); + + // Helper to send messages + async function sendMessage(client: any, room: string, user: string, text: string) { + const chatMsg: ChatMessage = { + user, + text, + timestamp: Date.now(), + }; + await client.pubsub.publish(room, JSON.stringify(chatMsg)); + } + + // Get current presence + if (subscription.hasPresence()) { + const members = await subscription.getPresence(); + console.log(`[${userName}] Current members in room (${members.length}):`); + members.forEach(m => { + console.log(`[${userName}] - ${m.memberId} (joined at ${new Date(m.joinedAt).toLocaleTimeString()})`); + }); + } + + return { + client, + subscription, + sendMessage: (text: string) => sendMessage(client, roomName, userName, text), + }; +} + +async function main() { + const roomName = 'chat:lobby'; + + // Create first user + const alice = await createChatClient('Alice', roomName); + + // Wait a bit + await new Promise(resolve => setTimeout(resolve, 500)); + + // Create second user + const bob = await createChatClient('Bob', roomName); + + // Wait a bit + await new Promise(resolve => setTimeout(resolve, 500)); + + // Send some messages + await alice.sendMessage('Hey Bob! How are you?'); + await new Promise(resolve => setTimeout(resolve, 200)); + + await bob.sendMessage('Hi Alice! I\'m doing great, thanks!'); + await new Promise(resolve => setTimeout(resolve, 200)); + + await alice.sendMessage('That\'s awesome! Want to grab coffee later?'); + await new Promise(resolve => setTimeout(resolve, 200)); + + await bob.sendMessage('Sure! See you at 3pm?'); + await new Promise(resolve => setTimeout(resolve, 200)); + + await alice.sendMessage('Perfect! See you then! 👋'); + + // Wait to receive all messages + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Get final presence count + const presence = await alice.client.pubsub.getPresence(roomName); + console.log(`\nFinal presence count: ${presence.count} members`); + + // Leave room + console.log('\nClosing connections...'); + alice.subscription.close(); + await new Promise(resolve => setTimeout(resolve, 500)); + + bob.subscription.close(); + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log('\n--- Chat example completed ---'); +} + +main().catch(console.error); diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..de577bc --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,82 @@ +{ + "name": "@debros/orama", + "version": "1.0.0", + "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "license": "MIT", + "author": "DeBrosOfficial", + "keywords": [ + "debros", + "network", + "sdk", + "typescript", + "database", + "rqlite", + "pubsub", + "websocket", + "cache", + "olric", + "ipfs", + "storage", + "wasm", + "serverless", + "distributed", + "gateway", + "vault", + "secrets", + "shamir", + "encryption", + "guardian" + ], + "repository": { + "type": "git", + "url": "https://github.com/DeBrosOfficial/network", + "directory": "sdk" + }, + "bugs": { + "url": "https://github.com/DeBrosOfficial/network/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint src tests", + "test": "vitest", + "test:e2e": "vitest run tests/e2e", + "release:npm": "npm publish --access public --registry=https://registry.npmjs.org/", + "release:gh": "npm publish --registry=https://npm.pkg.github.com" + }, + "dependencies": { + "@noble/ciphers": "^0.5.3", + "@noble/hashes": "^1.4.0", + "isomorphic-ws": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "@vitest/coverage-v8": "^1.0.0", + "dotenv": "^17.2.3", + "eslint": "^8.0.0", + "tsup": "^8.0.0", + "typedoc": "^0.25.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + } +} diff --git a/sdk/pnpm-lock.yaml b/sdk/pnpm-lock.yaml new file mode 100644 index 0000000..6007fa2 --- /dev/null +++ b/sdk/pnpm-lock.yaml @@ -0,0 +1,3077 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@noble/ciphers': + specifier: ^0.5.3 + version: 0.5.3 + '@noble/hashes': + specifier: ^1.4.0 + version: 1.8.0 + isomorphic-ws: + specifier: ^5.0.0 + version: 5.0.0(ws@8.18.3) + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.23 + '@typescript-eslint/eslint-plugin': + specifier: ^6.0.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.0.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@vitest/coverage-v8': + specifier: ^1.0.0 + version: 1.6.1(vitest@1.6.1(@types/node@20.19.23)) + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + eslint: + specifier: ^8.0.0 + version: 8.57.1 + tsup: + specifier: ^8.0.0 + version: 8.5.0(postcss@8.5.6)(typescript@5.9.3) + typedoc: + specifier: ^0.25.0 + version: 0.25.13(typescript@5.9.3) + typescript: + specifier: ^5.3.0 + version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@20.19.23) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/rollup-android-arm-eabi@4.52.5': + resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.5': + resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.5': + resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.5': + resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.5': + resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.5': + resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.52.5': + resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.52.5': + resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.52.5': + resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.52.5': + resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.52.5': + resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.5': + resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.5': + resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.5': + resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.5': + resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.23': + resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitest/coverage-v8@1.6.1': + resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} + peerDependencies: + vitest: 1.6.1 + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-sequence-parser@1.1.3: + resolution: {integrity: sha512-+fksAx9eG3Ab6LDnLs3ZqZa8KVJ/jYnX+D4Qe1azX+LFGFAXqynCQLOdLpNYN/l9e7l6hMWwZbrnctqr6eSQSw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.52.5: + resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@0.14.7: + resolution: {integrity: sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.0: + resolution: {integrity: sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typedoc@0.25.13: + resolution: {integrity: sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-oniguruma@1.7.0: + resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} + + vscode-textmate@8.0.0: + resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/ciphers@0.5.3': {} + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.52.5': + optional: true + + '@rollup/rollup-android-arm64@4.52.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.5': + optional: true + + '@rollup/rollup-darwin-x64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.5': + optional: true + + '@sinclair/typebox@0.27.8': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.23': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.7.1': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@20.19.23))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.19 + magicast: 0.3.5 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + test-exclude: 6.0.0 + vitest: 1.6.1(@types/node@20.19.23) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.19 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-sequence-parser@1.1.3: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@1.1.0: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bundle-require@5.1.0(esbuild@0.25.11): + dependencies: + esbuild: 0.25.11 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + deep-is@0.1.4: {} + + diff-sequences@29.6.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dotenv@17.2.3: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escape-string-regexp@4.0.0: {} + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.19 + mlly: 1.8.0 + rollup: 4.52.5 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + human-signals@5.0.0: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + isomorphic-ws@5.0.0(ws@8.18.3): + dependencies: + ws: 8.18.3 + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonc-parser@3.3.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.0 + pkg-types: 1.3.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash.sortby@4.7.0: {} + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + lru-cache@10.4.3: {} + + lunr@2.3.9: {} + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + marked@4.3.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-fn@4.0.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + object-assign@4.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.1 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.6 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-is@18.3.1: {} + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.52.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.5 + '@rollup/rollup-android-arm64': 4.52.5 + '@rollup/rollup-darwin-arm64': 4.52.5 + '@rollup/rollup-darwin-x64': 4.52.5 + '@rollup/rollup-freebsd-arm64': 4.52.5 + '@rollup/rollup-freebsd-x64': 4.52.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 + '@rollup/rollup-linux-arm-musleabihf': 4.52.5 + '@rollup/rollup-linux-arm64-gnu': 4.52.5 + '@rollup/rollup-linux-arm64-musl': 4.52.5 + '@rollup/rollup-linux-loong64-gnu': 4.52.5 + '@rollup/rollup-linux-ppc64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-musl': 4.52.5 + '@rollup/rollup-linux-s390x-gnu': 4.52.5 + '@rollup/rollup-linux-x64-gnu': 4.52.5 + '@rollup/rollup-linux-x64-musl': 4.52.5 + '@rollup/rollup-openharmony-arm64': 4.52.5 + '@rollup/rollup-win32-arm64-msvc': 4.52.5 + '@rollup/rollup-win32-ia32-msvc': 4.52.5 + '@rollup/rollup-win32-x64-gnu': 4.52.5 + '@rollup/rollup-win32-x64-msvc': 4.52.5 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + semver@7.7.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@0.14.7: + dependencies: + ansi-sequence-parser: 1.1.3 + jsonc-parser: 3.3.1 + vscode-oniguruma: 1.7.0 + vscode-textmate: 8.0.0 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@3.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.0(postcss@8.5.6)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.11) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.25.11 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.6) + resolve-from: 5.0.0 + rollup: 4.52.5 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.1.0: {} + + type-fest@0.20.2: {} + + typedoc@0.25.13(typescript@5.9.3): + dependencies: + lunr: 2.3.9 + marked: 4.3.0 + minimatch: 9.0.5 + shiki: 0.14.7 + typescript: 5.9.3 + + typescript@5.9.3: {} + + ufo@1.6.1: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@1.6.1(@types/node@20.19.23): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.23) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.23): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.52.5 + optionalDependencies: + '@types/node': 20.19.23 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.23): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.19 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.23) + vite-node: 1.6.1(@types/node@20.19.23) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.23 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vscode-oniguruma@1.7.0: {} + + vscode-textmate@8.0.0: {} + + webidl-conversions@4.0.2: {} + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.1: {} diff --git a/sdk/src/auth/client.ts b/sdk/src/auth/client.ts new file mode 100644 index 0000000..49f55d3 --- /dev/null +++ b/sdk/src/auth/client.ts @@ -0,0 +1,231 @@ +import { HttpClient } from "../core/http"; +import { AuthConfig, WhoAmI, StorageAdapter, MemoryStorage } from "./types"; + +export class AuthClient { + private httpClient: HttpClient; + private storage: StorageAdapter; + private currentApiKey?: string; + private currentJwt?: string; + + constructor(config: { + httpClient: HttpClient; + storage?: StorageAdapter; + apiKey?: string; + jwt?: string; + }) { + this.httpClient = config.httpClient; + this.storage = config.storage ?? new MemoryStorage(); + this.currentApiKey = config.apiKey; + this.currentJwt = config.jwt; + + if (this.currentApiKey) { + this.httpClient.setApiKey(this.currentApiKey); + } + if (this.currentJwt) { + this.httpClient.setJwt(this.currentJwt); + } + } + + setApiKey(apiKey: string) { + this.currentApiKey = apiKey; + // Don't clear JWT - it will be cleared explicitly on logout + this.httpClient.setApiKey(apiKey); + this.storage.set("apiKey", apiKey); + } + + setJwt(jwt: string) { + this.currentJwt = jwt; + // Don't clear API key - keep it as fallback for after logout + this.httpClient.setJwt(jwt); + this.storage.set("jwt", jwt); + } + + getToken(): string | undefined { + return this.httpClient.getToken(); + } + + async whoami(): Promise { + try { + const response = await this.httpClient.get("/v1/auth/whoami"); + return response; + } catch { + return { authenticated: false }; + } + } + + async refresh(): Promise { + const response = await this.httpClient.post<{ token: string }>( + "/v1/auth/refresh" + ); + const token = response.token; + this.setJwt(token); + return token; + } + + /** + * Logout user and clear JWT, but preserve API key + * Use this for user logout in apps where API key is app-level credential + */ + async logoutUser(): Promise { + // Attempt server-side logout if using JWT + if (this.currentJwt) { + try { + await this.httpClient.post("/v1/auth/logout", { all: true }); + } catch (error) { + // Log warning but don't fail - local cleanup is more important + console.warn( + "Server-side logout failed, continuing with local cleanup:", + error + ); + } + } + + // Clear JWT only, preserve API key + this.currentJwt = undefined; + this.httpClient.setJwt(undefined); + await this.storage.set("jwt", ""); // Clear JWT from storage + + // Ensure API key is loaded and set as active auth method + if (!this.currentApiKey) { + // Try to load from storage + const storedApiKey = await this.storage.get("apiKey"); + if (storedApiKey) { + this.currentApiKey = storedApiKey; + } + } + + // Restore API key as the active auth method + if (this.currentApiKey) { + this.httpClient.setApiKey(this.currentApiKey); + console.log("[Auth] API key restored after user logout"); + } else { + console.warn("[Auth] No API key available after logout"); + } + } + + /** + * Full logout - clears both JWT and API key + * Use this to completely reset authentication state + */ + async logout(): Promise { + // Only attempt server-side logout if using JWT + // API keys don't support server-side logout with all=true + if (this.currentJwt) { + try { + await this.httpClient.post("/v1/auth/logout", { all: true }); + } catch (error) { + // Log warning but don't fail - local cleanup is more important + console.warn( + "Server-side logout failed, continuing with local cleanup:", + error + ); + } + } + + // Always clear local state + this.currentApiKey = undefined; + this.currentJwt = undefined; + this.httpClient.setApiKey(undefined); + this.httpClient.setJwt(undefined); + await this.storage.clear(); + } + + async clear(): Promise { + this.currentApiKey = undefined; + this.currentJwt = undefined; + this.httpClient.setApiKey(undefined); + this.httpClient.setJwt(undefined); + await this.storage.clear(); + } + + /** + * Request a challenge nonce for wallet authentication + */ + async challenge(params: { + wallet: string; + purpose?: string; + namespace?: string; + }): Promise<{ + nonce: string; + wallet: string; + namespace: string; + expires_at: string; + }> { + const response = await this.httpClient.post("/v1/auth/challenge", { + wallet: params.wallet, + purpose: params.purpose || "authentication", + namespace: params.namespace || "default", + }); + return response; + } + + /** + * Verify wallet signature and get JWT token + */ + async verify(params: { + wallet: string; + nonce: string; + signature: string; + namespace?: string; + chain_type?: "ETH" | "SOL"; + }): Promise<{ + access_token: string; + refresh_token?: string; + subject: string; + namespace: string; + api_key?: string; + expires_in?: number; + token_type?: string; + }> { + const response = await this.httpClient.post("/v1/auth/verify", { + wallet: params.wallet, + nonce: params.nonce, + signature: params.signature, + namespace: params.namespace || "default", + chain_type: params.chain_type || "ETH", + }); + + // Persist JWT + this.setJwt(response.access_token); + + // Persist API key if server provided it (created in verifyHandler) + if ((response as any).api_key) { + this.setApiKey((response as any).api_key); + } + + // Persist refresh token if present (optional, for silent renewal) + if ((response as any).refresh_token) { + await this.storage.set("refreshToken", (response as any).refresh_token); + } + + return response as any; + } + + /** + * Get API key for wallet (creates namespace ownership) + */ + async getApiKey(params: { + wallet: string; + nonce: string; + signature: string; + namespace?: string; + chain_type?: "ETH" | "SOL"; + }): Promise<{ + api_key: string; + namespace: string; + wallet: string; + }> { + const response = await this.httpClient.post("/v1/auth/api-key", { + wallet: params.wallet, + nonce: params.nonce, + signature: params.signature, + namespace: params.namespace || "default", + chain_type: params.chain_type || "ETH", + }); + + // Automatically set the API key + this.setApiKey(response.api_key); + + return response; + } +} diff --git a/sdk/src/auth/index.ts b/sdk/src/auth/index.ts new file mode 100644 index 0000000..b839661 --- /dev/null +++ b/sdk/src/auth/index.ts @@ -0,0 +1,3 @@ +export { AuthClient } from "./client"; +export type { AuthConfig, WhoAmI, StorageAdapter } from "./types"; +export { MemoryStorage, LocalStorageAdapter } from "./types"; diff --git a/sdk/src/auth/types.ts b/sdk/src/auth/types.ts new file mode 100644 index 0000000..860f248 --- /dev/null +++ b/sdk/src/auth/types.ts @@ -0,0 +1,62 @@ +export interface AuthConfig { + apiKey?: string; + jwt?: string; +} + +export interface WhoAmI { + address?: string; + namespace?: string; + authenticated: boolean; +} + +export interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + clear(): Promise; +} + +export class MemoryStorage implements StorageAdapter { + private storage: Map = new Map(); + + async get(key: string): Promise { + return this.storage.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.storage.set(key, value); + } + + async clear(): Promise { + this.storage.clear(); + } +} + +export class LocalStorageAdapter implements StorageAdapter { + private prefix = "@network/sdk:"; + + async get(key: string): Promise { + if (typeof globalThis !== "undefined" && globalThis.localStorage) { + return globalThis.localStorage.getItem(this.prefix + key); + } + return null; + } + + async set(key: string, value: string): Promise { + if (typeof globalThis !== "undefined" && globalThis.localStorage) { + globalThis.localStorage.setItem(this.prefix + key, value); + } + } + + async clear(): Promise { + if (typeof globalThis !== "undefined" && globalThis.localStorage) { + const keysToDelete: string[] = []; + for (let i = 0; i < globalThis.localStorage.length; i++) { + const key = globalThis.localStorage.key(i); + if (key?.startsWith(this.prefix)) { + keysToDelete.push(key); + } + } + keysToDelete.forEach((key) => globalThis.localStorage.removeItem(key)); + } + } +} diff --git a/sdk/src/cache/client.ts b/sdk/src/cache/client.ts new file mode 100644 index 0000000..46b8012 --- /dev/null +++ b/sdk/src/cache/client.ts @@ -0,0 +1,203 @@ +import { HttpClient } from "../core/http"; +import { SDKError } from "../errors"; + +export interface CacheGetRequest { + dmap: string; + key: string; +} + +export interface CacheGetResponse { + key: string; + value: any; + dmap: string; +} + +export interface CachePutRequest { + dmap: string; + key: string; + value: any; + ttl?: string; // Duration string like "1h", "30m" +} + +export interface CachePutResponse { + status: string; + key: string; + dmap: string; +} + +export interface CacheDeleteRequest { + dmap: string; + key: string; +} + +export interface CacheDeleteResponse { + status: string; + key: string; + dmap: string; +} + +export interface CacheMultiGetRequest { + dmap: string; + keys: string[]; +} + +export interface CacheMultiGetResponse { + results: Array<{ + key: string; + value: any; + }>; + dmap: string; +} + +export interface CacheScanRequest { + dmap: string; + match?: string; // Optional regex pattern +} + +export interface CacheScanResponse { + keys: string[]; + count: number; + dmap: string; +} + +export interface CacheHealthResponse { + status: string; + service: string; +} + +export class CacheClient { + private httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + /** + * Check cache service health + */ + async health(): Promise { + return this.httpClient.get("/v1/cache/health"); + } + + /** + * Get a value from cache + * Returns null if the key is not found (cache miss/expired), which is normal behavior + */ + async get(dmap: string, key: string): Promise { + try { + return await this.httpClient.post("/v1/cache/get", { + dmap, + key, + }); + } catch (error) { + // Cache misses (404 or "key not found" messages) are normal behavior - return null instead of throwing + if ( + error instanceof SDKError && + (error.httpStatus === 404 || + (error.httpStatus === 500 && + error.message?.toLowerCase().includes("key not found"))) + ) { + return null; + } + // Re-throw other errors (network issues, server errors, etc.) + throw error; + } + } + + /** + * Put a value into cache + */ + async put( + dmap: string, + key: string, + value: any, + ttl?: string + ): Promise { + return this.httpClient.post("/v1/cache/put", { + dmap, + key, + value, + ttl, + }); + } + + /** + * Delete a value from cache + */ + async delete(dmap: string, key: string): Promise { + return this.httpClient.post("/v1/cache/delete", { + dmap, + key, + }); + } + + /** + * Get multiple values from cache in a single request + * Returns a map of key -> value (or null if not found) + * Gracefully handles 404 errors (endpoint not implemented) by returning empty results + */ + async multiGet( + dmap: string, + keys: string[] + ): Promise> { + try { + if (keys.length === 0) { + return new Map(); + } + + const response = await this.httpClient.post( + "/v1/cache/mget", + { + dmap, + keys, + } + ); + + // Convert array to Map + const resultMap = new Map(); + + // First, mark all keys as null (cache miss) + keys.forEach((key) => { + resultMap.set(key, null); + }); + + // Then, update with found values + if (response.results) { + response.results.forEach(({ key, value }) => { + resultMap.set(key, value); + }); + } + + return resultMap; + } catch (error) { + // Handle 404 errors silently (endpoint not implemented on backend) + // This is expected behavior when the backend doesn't support multiGet yet + if (error instanceof SDKError && error.httpStatus === 404) { + // Return map with all nulls silently - caller can fall back to individual gets + const resultMap = new Map(); + keys.forEach((key) => { + resultMap.set(key, null); + }); + return resultMap; + } + + // Log and return empty results for other errors + const resultMap = new Map(); + keys.forEach((key) => { + resultMap.set(key, null); + }); + console.error(`[CacheClient] Error in multiGet for ${dmap}:`, error); + return resultMap; + } + } + + /** + * Scan keys in a distributed map, optionally matching a regex pattern + */ + async scan(dmap: string, match?: string): Promise { + return this.httpClient.post("/v1/cache/scan", { + dmap, + match, + }); + } +} diff --git a/sdk/src/cache/index.ts b/sdk/src/cache/index.ts new file mode 100644 index 0000000..8cc3592 --- /dev/null +++ b/sdk/src/cache/index.ts @@ -0,0 +1,14 @@ +export { CacheClient } from "./client"; +export type { + CacheGetRequest, + CacheGetResponse, + CachePutRequest, + CachePutResponse, + CacheDeleteRequest, + CacheDeleteResponse, + CacheMultiGetRequest, + CacheMultiGetResponse, + CacheScanRequest, + CacheScanResponse, + CacheHealthResponse, +} from "./client"; diff --git a/sdk/src/core/http.ts b/sdk/src/core/http.ts new file mode 100644 index 0000000..470def4 --- /dev/null +++ b/sdk/src/core/http.ts @@ -0,0 +1,541 @@ +import { SDKError } from "../errors"; + +/** + * Context provided to the onNetworkError callback + */ +export interface NetworkErrorContext { + method: "GET" | "POST" | "PUT" | "DELETE" | "WS"; + path: string; + isRetry: boolean; + attempt: number; +} + +/** + * Callback invoked when a network error occurs. + * Use this to trigger gateway failover or other error handling. + */ +export type NetworkErrorCallback = ( + error: SDKError, + context: NetworkErrorContext +) => void; + +export interface HttpClientConfig { + baseURL: string; + timeout?: number; + maxRetries?: number; + retryDelayMs?: number; + fetch?: typeof fetch; + /** + * Enable debug logging (includes full SQL queries and args). Default: false + */ + debug?: boolean; + /** + * Callback invoked on network errors (after all retries exhausted). + * Use this to trigger gateway failover at the application layer. + */ + onNetworkError?: NetworkErrorCallback; +} + +/** + * Create a fetch function with proper TLS configuration for staging certificates + * In Node.js, we need to configure TLS to accept Let's Encrypt staging certificates + */ +function createFetchWithTLSConfig(): typeof fetch { + // Check if we're in a Node.js environment + if (typeof process !== "undefined" && process.versions?.node) { + // For testing/staging/development: allow staging certificates + // Let's Encrypt staging certificates are self-signed and not trusted by default + const isDevelopmentOrStaging = + process.env.NODE_ENV !== "production" || + process.env.DEBROS_ALLOW_STAGING_CERTS === "true" || + process.env.DEBROS_USE_HTTPS === "true"; + + if (isDevelopmentOrStaging) { + // Allow self-signed/staging certificates + // WARNING: Only use this in development/testing environments + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + } + } + return globalThis.fetch; +} + +export class HttpClient { + private baseURL: string; + private timeout: number; + private maxRetries: number; + private retryDelayMs: number; + private fetch: typeof fetch; + private apiKey?: string; + private jwt?: string; + private debug: boolean; + private onNetworkError?: NetworkErrorCallback; + + constructor(config: HttpClientConfig) { + this.baseURL = config.baseURL.replace(/\/$/, ""); + this.timeout = config.timeout ?? 60000; + this.maxRetries = config.maxRetries ?? 3; + this.retryDelayMs = config.retryDelayMs ?? 1000; + // Use provided fetch or create one with proper TLS configuration for staging certificates + this.fetch = config.fetch ?? createFetchWithTLSConfig(); + this.debug = config.debug ?? false; + this.onNetworkError = config.onNetworkError; + } + + /** + * Set the network error callback + */ + setOnNetworkError(callback: NetworkErrorCallback | undefined): void { + this.onNetworkError = callback; + } + + setApiKey(apiKey?: string) { + this.apiKey = apiKey; + // Don't clear JWT - allow both to coexist + } + + setJwt(jwt?: string) { + this.jwt = jwt; + // Don't clear API key - allow both to coexist + if (typeof console !== "undefined") { + console.log( + "[HttpClient] JWT set:", + !!jwt, + "API key still present:", + !!this.apiKey + ); + } + } + + private getAuthHeaders(path: string): Record { + const headers: Record = {}; + + // For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context + // interfering with namespace-level authorization + const isDbOperation = path.includes("/v1/rqlite/"); + const isPubSubOperation = path.includes("/v1/pubsub/"); + const isProxyOperation = path.includes("/v1/proxy/"); + const isCacheOperation = path.includes("/v1/cache/"); + + // For auth operations, prefer API key over JWT to ensure proper authentication + const isAuthOperation = path.includes("/v1/auth/"); + + if ( + isDbOperation || + isPubSubOperation || + isProxyOperation || + isCacheOperation + ) { + // For database/pubsub/proxy/cache operations: use only API key (preferred for namespace operations) + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } else if (this.jwt) { + // Fallback to JWT if no API key + headers["Authorization"] = `Bearer ${this.jwt}`; + } + } else if (isAuthOperation) { + // For auth operations: prefer API key over JWT (auth endpoints should use explicit API key) + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + if (this.jwt) { + headers["Authorization"] = `Bearer ${this.jwt}`; + } + } else { + // For other operations: send both JWT and API key + if (this.jwt) { + headers["Authorization"] = `Bearer ${this.jwt}`; + } + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + } + return headers; + } + + private getAuthToken(): string | undefined { + return this.jwt || this.apiKey; + } + + getApiKey(): string | undefined { + return this.apiKey; + } + + /** + * Get the base URL + */ + getBaseURL(): string { + return this.baseURL; + } + + async request( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + options: { + body?: any; + headers?: Record; + query?: Record; + timeout?: number; // Per-request timeout override + } = {} + ): Promise { + const startTime = performance.now(); // Track request start time + const url = new URL(this.baseURL + path); + if (options.query) { + Object.entries(options.query).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + } + + const headers: Record = { + "Content-Type": "application/json", + ...this.getAuthHeaders(path), + ...options.headers, + }; + + const controller = new AbortController(); + const requestTimeout = options.timeout ?? this.timeout; // Use override or default + const timeoutId = setTimeout(() => controller.abort(), requestTimeout); + + const fetchOptions: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (options.body !== undefined) { + fetchOptions.body = JSON.stringify(options.body); + } + + // Extract and log SQL query details for rqlite operations + const isRqliteOperation = path.includes("/v1/rqlite/"); + let queryDetails: string | null = null; + if (isRqliteOperation && options.body) { + try { + const body = + typeof options.body === "string" + ? JSON.parse(options.body) + : options.body; + + if (body.sql) { + // Direct SQL query (query/exec endpoints) + queryDetails = `SQL: ${body.sql}`; + if (body.args && body.args.length > 0) { + queryDetails += ` | Args: [${body.args + .map((a: any) => (typeof a === "string" ? `"${a}"` : a)) + .join(", ")}]`; + } + } else if (body.table) { + // Table-based query (find/find-one/select endpoints) + queryDetails = `Table: ${body.table}`; + if (body.criteria && Object.keys(body.criteria).length > 0) { + queryDetails += ` | Criteria: ${JSON.stringify(body.criteria)}`; + } + if (body.options) { + queryDetails += ` | Options: ${JSON.stringify(body.options)}`; + } + if (body.select) { + queryDetails += ` | Select: ${JSON.stringify(body.select)}`; + } + if (body.where) { + queryDetails += ` | Where: ${JSON.stringify(body.where)}`; + } + if (body.limit) { + queryDetails += ` | Limit: ${body.limit}`; + } + if (body.offset) { + queryDetails += ` | Offset: ${body.offset}`; + } + } + } catch (e) { + // Failed to parse body, ignore + } + } + + try { + const result = await this.requestWithRetry( + url.toString(), + fetchOptions, + 0, + startTime + ); + const duration = performance.now() - startTime; + if (typeof console !== "undefined") { + const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed( + 2 + )}ms`; + if (queryDetails && this.debug) { + console.log(logMessage); + console.log(`[HttpClient] ${queryDetails}`); + } else { + console.log(logMessage); + } + } + return result; + } catch (error) { + const duration = performance.now() - startTime; + if (typeof console !== "undefined") { + // For 404 errors on find-one calls, log at warn level (not error) since "not found" is expected + // Application layer handles these cases in try-catch blocks + const is404FindOne = + path === "/v1/rqlite/find-one" && + error instanceof SDKError && + error.httpStatus === 404; + + if (is404FindOne) { + // Log as warning for visibility, but not as error since it's expected behavior + console.warn( + `[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed( + 2 + )}ms (expected for optional lookups)` + ); + } else { + const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed( + 2 + )}ms:`; + console.error(errorMessage, error); + if (queryDetails && this.debug) { + console.error(`[HttpClient] ${queryDetails}`); + } + } + } + + // Call the network error callback if configured + // This allows the app to trigger gateway failover + if (this.onNetworkError) { + // Convert native errors (TypeError, AbortError) to SDKError for the callback + const sdkError = + error instanceof SDKError + ? error + : new SDKError( + error instanceof Error ? error.message : String(error), + 0, // httpStatus 0 indicates network-level failure + "NETWORK_ERROR" + ); + this.onNetworkError(sdkError, { + method, + path, + isRetry: false, + attempt: this.maxRetries, // All retries exhausted + }); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + private async requestWithRetry( + url: string, + options: RequestInit, + attempt: number = 0, + startTime?: number // Track start time for timing across retries + ): Promise { + try { + const response = await this.fetch(url, options); + + if (!response.ok) { + let body: any; + try { + body = await response.json(); + } catch { + body = { error: response.statusText }; + } + throw SDKError.fromResponse(response.status, body); + } + + // Request succeeded - return response + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + return response.text(); + } catch (error) { + const isRetryableError = + error instanceof SDKError && + [408, 429, 500, 502, 503, 504].includes(error.httpStatus); + + // Retry on same gateway for retryable HTTP errors + if (isRetryableError && attempt < this.maxRetries) { + if (typeof console !== "undefined") { + console.warn( + `[HttpClient] Retrying request (attempt ${attempt + 1}/${this.maxRetries})` + ); + } + await new Promise((resolve) => + setTimeout(resolve, this.retryDelayMs * (attempt + 1)) + ); + return this.requestWithRetry(url, options, attempt + 1, startTime); + } + + // All retries exhausted - throw error for app to handle + throw error; + } + } + + async get( + path: string, + options?: Omit[2], "body"> + ): Promise { + return this.request("GET", path, options); + } + + async post( + path: string, + body?: any, + options?: Omit[2], "body"> + ): Promise { + return this.request("POST", path, { ...options, body }); + } + + async put( + path: string, + body?: any, + options?: Omit[2], "body"> + ): Promise { + return this.request("PUT", path, { ...options, body }); + } + + async delete( + path: string, + options?: Omit[2], "body"> + ): Promise { + return this.request("DELETE", path, options); + } + + /** + * Upload a file using multipart/form-data + * This is a special method for file uploads that bypasses JSON serialization + */ + async uploadFile( + path: string, + formData: FormData, + options?: { + timeout?: number; + } + ): Promise { + const startTime = performance.now(); // Track upload start time + const url = new URL(this.baseURL + path); + const headers: Record = { + ...this.getAuthHeaders(path), + // Don't set Content-Type - browser will set it with boundary + }; + + const controller = new AbortController(); + const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads + const timeoutId = setTimeout(() => controller.abort(), requestTimeout); + + const fetchOptions: RequestInit = { + method: "POST", + headers, + body: formData, + signal: controller.signal, + }; + + try { + const result = await this.requestWithRetry( + url.toString(), + fetchOptions, + 0, + startTime + ); + const duration = performance.now() - startTime; + if (typeof console !== "undefined") { + console.log( + `[HttpClient] POST ${path} (upload) completed in ${duration.toFixed( + 2 + )}ms` + ); + } + return result; + } catch (error) { + const duration = performance.now() - startTime; + if (typeof console !== "undefined") { + console.error( + `[HttpClient] POST ${path} (upload) failed after ${duration.toFixed( + 2 + )}ms:`, + error + ); + } + + // Call the network error callback if configured + if (this.onNetworkError) { + const sdkError = + error instanceof SDKError + ? error + : new SDKError( + error instanceof Error ? error.message : String(error), + 0, + "NETWORK_ERROR" + ); + this.onNetworkError(sdkError, { + method: "POST", + path, + isRetry: false, + attempt: this.maxRetries, + }); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Get a binary response (returns Response object for streaming) + */ + async getBinary(path: string): Promise { + const url = new URL(this.baseURL + path); + const headers: Record = { + ...this.getAuthHeaders(path), + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads + + const fetchOptions: RequestInit = { + method: "GET", + headers, + signal: controller.signal, + }; + + try { + const response = await this.fetch(url.toString(), fetchOptions); + if (!response.ok) { + clearTimeout(timeoutId); + const errorBody = await response.json().catch(() => ({ + error: response.statusText, + })); + throw SDKError.fromResponse(response.status, errorBody); + } + return response; + } catch (error) { + clearTimeout(timeoutId); + + // Call the network error callback if configured + if (this.onNetworkError) { + const sdkError = + error instanceof SDKError + ? error + : new SDKError( + error instanceof Error ? error.message : String(error), + 0, + "NETWORK_ERROR" + ); + this.onNetworkError(sdkError, { + method: "GET", + path, + isRetry: false, + attempt: 0, + }); + } + + throw error; + } + } + + getToken(): string | undefined { + return this.getAuthToken(); + } +} diff --git a/sdk/src/core/index.ts b/sdk/src/core/index.ts new file mode 100644 index 0000000..89f89d6 --- /dev/null +++ b/sdk/src/core/index.ts @@ -0,0 +1,10 @@ +export { HttpClient, type HttpClientConfig, type NetworkErrorCallback, type NetworkErrorContext } from "./http"; +export { WSClient, type WSClientConfig } from "./ws"; +export type { IHttpTransport, RequestOptions } from "./interfaces/IHttpTransport"; +export type { IWebSocketClient } from "./interfaces/IWebSocketClient"; +export type { IAuthStrategy, RequestContext } from "./interfaces/IAuthStrategy"; +export type { IRetryPolicy } from "./interfaces/IRetryPolicy"; +export { PathBasedAuthStrategy } from "./transport/AuthHeaderStrategy"; +export { ExponentialBackoffRetryPolicy } from "./transport/RequestRetryPolicy"; +export { RequestLogger } from "./transport/RequestLogger"; +export { TLSConfiguration } from "./transport/TLSConfiguration"; diff --git a/sdk/src/core/interfaces/IAuthStrategy.ts b/sdk/src/core/interfaces/IAuthStrategy.ts new file mode 100644 index 0000000..7aa2215 --- /dev/null +++ b/sdk/src/core/interfaces/IAuthStrategy.ts @@ -0,0 +1,28 @@ +/** + * Request context for authentication + */ +export interface RequestContext { + path: string; + method: string; +} + +/** + * Authentication strategy interface + * Provides abstraction for different authentication header strategies + */ +export interface IAuthStrategy { + /** + * Get authentication headers for a request + */ + getHeaders(context: RequestContext): Record; + + /** + * Set API key + */ + setApiKey(apiKey?: string): void; + + /** + * Set JWT token + */ + setJwt(jwt?: string): void; +} diff --git a/sdk/src/core/interfaces/IHttpTransport.ts b/sdk/src/core/interfaces/IHttpTransport.ts new file mode 100644 index 0000000..531fa01 --- /dev/null +++ b/sdk/src/core/interfaces/IHttpTransport.ts @@ -0,0 +1,73 @@ +/** + * HTTP Request options + */ +export interface RequestOptions { + headers?: Record; + query?: Record; + timeout?: number; +} + +/** + * HTTP Transport abstraction interface + * Provides a testable abstraction layer for HTTP operations + */ +export interface IHttpTransport { + /** + * Perform GET request + */ + get(path: string, options?: RequestOptions): Promise; + + /** + * Perform POST request + */ + post(path: string, body?: any, options?: RequestOptions): Promise; + + /** + * Perform PUT request + */ + put(path: string, body?: any, options?: RequestOptions): Promise; + + /** + * Perform DELETE request + */ + delete(path: string, options?: RequestOptions): Promise; + + /** + * Upload file using multipart/form-data + */ + uploadFile( + path: string, + formData: FormData, + options?: { timeout?: number } + ): Promise; + + /** + * Get binary response (returns Response object for streaming) + */ + getBinary(path: string): Promise; + + /** + * Get base URL + */ + getBaseURL(): string; + + /** + * Get API key + */ + getApiKey(): string | undefined; + + /** + * Get current token (JWT or API key) + */ + getToken(): string | undefined; + + /** + * Set API key for authentication + */ + setApiKey(apiKey?: string): void; + + /** + * Set JWT token for authentication + */ + setJwt(jwt?: string): void; +} diff --git a/sdk/src/core/interfaces/IRetryPolicy.ts b/sdk/src/core/interfaces/IRetryPolicy.ts new file mode 100644 index 0000000..ea05d43 --- /dev/null +++ b/sdk/src/core/interfaces/IRetryPolicy.ts @@ -0,0 +1,20 @@ +/** + * Retry policy interface + * Provides abstraction for retry logic and backoff strategies + */ +export interface IRetryPolicy { + /** + * Determine if request should be retried + */ + shouldRetry(error: any, attempt: number): boolean; + + /** + * Get delay before next retry attempt (in milliseconds) + */ + getDelay(attempt: number): number; + + /** + * Get maximum number of retry attempts + */ + getMaxRetries(): number; +} diff --git a/sdk/src/core/interfaces/IWebSocketClient.ts b/sdk/src/core/interfaces/IWebSocketClient.ts new file mode 100644 index 0000000..98c1a53 --- /dev/null +++ b/sdk/src/core/interfaces/IWebSocketClient.ts @@ -0,0 +1,60 @@ +/** + * WebSocket Client abstraction interface + * Provides a testable abstraction layer for WebSocket operations + */ +export interface IWebSocketClient { + /** + * Connect to WebSocket server + */ + connect(): Promise; + + /** + * Close WebSocket connection + */ + close(): void; + + /** + * Send data through WebSocket + */ + send(data: string): void; + + /** + * Register message handler + */ + onMessage(handler: (data: string) => void): void; + + /** + * Unregister message handler + */ + offMessage(handler: (data: string) => void): void; + + /** + * Register error handler + */ + onError(handler: (error: Error) => void): void; + + /** + * Unregister error handler + */ + offError(handler: (error: Error) => void): void; + + /** + * Register close handler + */ + onClose(handler: (code: number, reason: string) => void): void; + + /** + * Unregister close handler + */ + offClose(handler: (code: number, reason: string) => void): void; + + /** + * Check if WebSocket is connected + */ + isConnected(): boolean; + + /** + * Get WebSocket URL + */ + get url(): string; +} diff --git a/sdk/src/core/interfaces/index.ts b/sdk/src/core/interfaces/index.ts new file mode 100644 index 0000000..a85f365 --- /dev/null +++ b/sdk/src/core/interfaces/index.ts @@ -0,0 +1,4 @@ +export type { IHttpTransport, RequestOptions } from "./IHttpTransport"; +export type { IWebSocketClient } from "./IWebSocketClient"; +export type { IAuthStrategy, RequestContext } from "./IAuthStrategy"; +export type { IRetryPolicy } from "./IRetryPolicy"; diff --git a/sdk/src/core/transport/AuthHeaderStrategy.ts b/sdk/src/core/transport/AuthHeaderStrategy.ts new file mode 100644 index 0000000..90bf461 --- /dev/null +++ b/sdk/src/core/transport/AuthHeaderStrategy.ts @@ -0,0 +1,108 @@ +import type { IAuthStrategy, RequestContext } from "../interfaces/IAuthStrategy"; + +/** + * Authentication type for different operations + */ +type AuthType = "api-key-only" | "api-key-preferred" | "jwt-preferred" | "both"; + +/** + * Path-based authentication strategy + * Determines which auth credentials to use based on the request path + */ +export class PathBasedAuthStrategy implements IAuthStrategy { + private apiKey?: string; + private jwt?: string; + + /** + * Mapping of path patterns to auth types + */ + private readonly authRules: Array<{ pattern: string; type: AuthType }> = [ + // Database, PubSub, Proxy, Cache: prefer API key + { pattern: "/v1/rqlite/", type: "api-key-only" }, + { pattern: "/v1/pubsub/", type: "api-key-only" }, + { pattern: "/v1/proxy/", type: "api-key-only" }, + { pattern: "/v1/cache/", type: "api-key-only" }, + // Auth operations: prefer API key + { pattern: "/v1/auth/", type: "api-key-preferred" }, + ]; + + constructor(apiKey?: string, jwt?: string) { + this.apiKey = apiKey; + this.jwt = jwt; + } + + /** + * Get authentication headers for a request + */ + getHeaders(context: RequestContext): Record { + const headers: Record = {}; + const authType = this.detectAuthType(context.path); + + switch (authType) { + case "api-key-only": + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } else if (this.jwt) { + // Fallback to JWT if no API key + headers["Authorization"] = `Bearer ${this.jwt}`; + } + break; + + case "api-key-preferred": + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + if (this.jwt) { + headers["Authorization"] = `Bearer ${this.jwt}`; + } + break; + + case "jwt-preferred": + if (this.jwt) { + headers["Authorization"] = `Bearer ${this.jwt}`; + } + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + break; + + case "both": + if (this.jwt) { + headers["Authorization"] = `Bearer ${this.jwt}`; + } + if (this.apiKey) { + headers["X-API-Key"] = this.apiKey; + } + break; + } + + return headers; + } + + /** + * Set API key + */ + setApiKey(apiKey?: string): void { + this.apiKey = apiKey; + } + + /** + * Set JWT token + */ + setJwt(jwt?: string): void { + this.jwt = jwt; + } + + /** + * Detect auth type based on path + */ + private detectAuthType(path: string): AuthType { + for (const rule of this.authRules) { + if (path.includes(rule.pattern)) { + return rule.type; + } + } + // Default: send both if available + return "both"; + } +} diff --git a/sdk/src/core/transport/RequestLogger.ts b/sdk/src/core/transport/RequestLogger.ts new file mode 100644 index 0000000..dcda817 --- /dev/null +++ b/sdk/src/core/transport/RequestLogger.ts @@ -0,0 +1,116 @@ +/** + * Request logger for debugging HTTP operations + */ +export class RequestLogger { + private readonly debug: boolean; + + constructor(debug: boolean = false) { + this.debug = debug; + } + + /** + * Log successful request + */ + logSuccess( + method: string, + path: string, + duration: number, + queryDetails?: string + ): void { + if (typeof console === "undefined") return; + + const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(2)}ms`; + + if (queryDetails && this.debug) { + console.log(logMessage); + console.log(`[HttpClient] ${queryDetails}`); + } else { + console.log(logMessage); + } + } + + /** + * Log failed request + */ + logError( + method: string, + path: string, + duration: number, + error: any, + queryDetails?: string + ): void { + if (typeof console === "undefined") return; + + // Special handling for 404 on find-one (expected behavior) + const is404FindOne = + path === "/v1/rqlite/find-one" && + error?.httpStatus === 404; + + if (is404FindOne) { + console.warn( + `[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(2)}ms (expected for optional lookups)` + ); + return; + } + + const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(2)}ms:`; + console.error(errorMessage, error); + + if (queryDetails && this.debug) { + console.error(`[HttpClient] ${queryDetails}`); + } + } + + /** + * Extract query details from request for logging + */ + extractQueryDetails(path: string, body?: any): string | null { + if (!this.debug) return null; + + const isRqliteOperation = path.includes("/v1/rqlite/"); + if (!isRqliteOperation || !body) return null; + + try { + const parsedBody = typeof body === "string" ? JSON.parse(body) : body; + + // Direct SQL query + if (parsedBody.sql) { + let details = `SQL: ${parsedBody.sql}`; + if (parsedBody.args && parsedBody.args.length > 0) { + details += ` | Args: [${parsedBody.args + .map((a: any) => (typeof a === "string" ? `"${a}"` : a)) + .join(", ")}]`; + } + return details; + } + + // Table-based query + if (parsedBody.table) { + let details = `Table: ${parsedBody.table}`; + if (parsedBody.criteria && Object.keys(parsedBody.criteria).length > 0) { + details += ` | Criteria: ${JSON.stringify(parsedBody.criteria)}`; + } + if (parsedBody.options) { + details += ` | Options: ${JSON.stringify(parsedBody.options)}`; + } + if (parsedBody.select) { + details += ` | Select: ${JSON.stringify(parsedBody.select)}`; + } + if (parsedBody.where) { + details += ` | Where: ${JSON.stringify(parsedBody.where)}`; + } + if (parsedBody.limit) { + details += ` | Limit: ${parsedBody.limit}`; + } + if (parsedBody.offset) { + details += ` | Offset: ${parsedBody.offset}`; + } + return details; + } + } catch { + // Failed to parse, ignore + } + + return null; + } +} diff --git a/sdk/src/core/transport/RequestRetryPolicy.ts b/sdk/src/core/transport/RequestRetryPolicy.ts new file mode 100644 index 0000000..75ca45b --- /dev/null +++ b/sdk/src/core/transport/RequestRetryPolicy.ts @@ -0,0 +1,53 @@ +import type { IRetryPolicy } from "../interfaces/IRetryPolicy"; +import { SDKError } from "../../errors"; + +/** + * Exponential backoff retry policy + * Retries failed requests with increasing delays + */ +export class ExponentialBackoffRetryPolicy implements IRetryPolicy { + private readonly maxRetries: number; + private readonly baseDelayMs: number; + + /** + * HTTP status codes that should trigger a retry + */ + private readonly retryableStatusCodes = [408, 429, 500, 502, 503, 504]; + + constructor(maxRetries: number = 3, baseDelayMs: number = 1000) { + this.maxRetries = maxRetries; + this.baseDelayMs = baseDelayMs; + } + + /** + * Determine if request should be retried + */ + shouldRetry(error: any, attempt: number): boolean { + // Don't retry if max attempts reached + if (attempt >= this.maxRetries) { + return false; + } + + // Retry on retryable HTTP errors + if (error instanceof SDKError) { + return this.retryableStatusCodes.includes(error.httpStatus); + } + + // Don't retry other errors + return false; + } + + /** + * Get delay before next retry (exponential backoff) + */ + getDelay(attempt: number): number { + return this.baseDelayMs * (attempt + 1); + } + + /** + * Get maximum number of retry attempts + */ + getMaxRetries(): number { + return this.maxRetries; + } +} diff --git a/sdk/src/core/transport/TLSConfiguration.ts b/sdk/src/core/transport/TLSConfiguration.ts new file mode 100644 index 0000000..2119e80 --- /dev/null +++ b/sdk/src/core/transport/TLSConfiguration.ts @@ -0,0 +1,53 @@ +/** + * TLS Configuration for development/staging environments + * + * WARNING: Only use this in development/testing environments! + * DO NOT disable certificate validation in production. + */ +export class TLSConfiguration { + /** + * Create fetch function with proper TLS configuration + */ + static createFetchWithTLSConfig(): typeof fetch { + // Only allow insecure TLS in development + if (this.shouldAllowInsecure()) { + this.configureInsecureTLS(); + } + + return globalThis.fetch; + } + + /** + * Check if insecure TLS should be allowed + */ + private static shouldAllowInsecure(): boolean { + // Check if we're in Node.js environment + if (typeof process === "undefined" || !process.versions?.node) { + return false; + } + + // Only allow in non-production with explicit flag + const isProduction = process.env.NODE_ENV === "production"; + const allowInsecure = process.env.DEBROS_ALLOW_INSECURE_TLS === "true"; + + return !isProduction && allowInsecure; + } + + /** + * Configure Node.js to allow insecure TLS + * WARNING: Only call in development! + */ + private static configureInsecureTLS(): void { + if (typeof process !== "undefined" && process.env) { + // Allow self-signed/staging certificates for development + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + if (typeof console !== "undefined") { + console.warn( + "[TLSConfiguration] WARNING: TLS certificate validation disabled for development. " + + "DO NOT use in production!" + ); + } + } + } +} diff --git a/sdk/src/core/transport/index.ts b/sdk/src/core/transport/index.ts new file mode 100644 index 0000000..27152a0 --- /dev/null +++ b/sdk/src/core/transport/index.ts @@ -0,0 +1,4 @@ +export { PathBasedAuthStrategy } from "./AuthHeaderStrategy"; +export { ExponentialBackoffRetryPolicy } from "./RequestRetryPolicy"; +export { RequestLogger } from "./RequestLogger"; +export { TLSConfiguration } from "./TLSConfiguration"; diff --git a/sdk/src/core/ws.ts b/sdk/src/core/ws.ts new file mode 100644 index 0000000..bbd3149 --- /dev/null +++ b/sdk/src/core/ws.ts @@ -0,0 +1,246 @@ +import WebSocket from "isomorphic-ws"; +import { SDKError } from "../errors"; +import { NetworkErrorCallback } from "./http"; + +export interface WSClientConfig { + wsURL: string; + timeout?: number; + authToken?: string; + WebSocket?: typeof WebSocket; + /** + * Callback invoked on WebSocket errors. + * Use this to trigger gateway failover at the application layer. + */ + onNetworkError?: NetworkErrorCallback; +} + +export type WSMessageHandler = (data: string) => void; +export type WSErrorHandler = (error: Error) => void; +export type WSCloseHandler = (code: number, reason: string) => void; +export type WSOpenHandler = () => void; + +/** + * Simple WebSocket client with minimal abstractions + * No complex reconnection, no failover - keep it simple + * Gateway failover is handled at the application layer + */ +export class WSClient { + private wsURL: string; + private timeout: number; + private authToken?: string; + private WebSocketClass: typeof WebSocket; + private onNetworkError?: NetworkErrorCallback; + + private ws?: WebSocket; + private messageHandlers: Set = new Set(); + private errorHandlers: Set = new Set(); + private closeHandlers: Set = new Set(); + private openHandlers: Set = new Set(); + private isClosed = false; + + constructor(config: WSClientConfig) { + this.wsURL = config.wsURL; + this.timeout = config.timeout ?? 30000; + this.authToken = config.authToken; + this.WebSocketClass = config.WebSocket ?? WebSocket; + this.onNetworkError = config.onNetworkError; + } + + /** + * Set the network error callback + */ + setOnNetworkError(callback: NetworkErrorCallback | undefined): void { + this.onNetworkError = callback; + } + + /** + * Get the current WebSocket URL + */ + get url(): string { + return this.wsURL; + } + + /** + * Connect to WebSocket server + */ + connect(): Promise { + return new Promise((resolve, reject) => { + try { + const wsUrl = this.buildWSUrl(); + this.ws = new this.WebSocketClass(wsUrl); + this.isClosed = false; + + const timeout = setTimeout(() => { + this.ws?.close(); + const error = new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT"); + + // Call the network error callback if configured + if (this.onNetworkError) { + this.onNetworkError(error, { + method: "WS", + path: this.wsURL, + isRetry: false, + attempt: 0, + }); + } + + reject(error); + }, this.timeout); + + this.ws.addEventListener("open", () => { + clearTimeout(timeout); + console.log("[WSClient] Connected to", this.wsURL); + this.openHandlers.forEach((handler) => handler()); + resolve(); + }); + + this.ws.addEventListener("message", (event: Event) => { + const msgEvent = event as MessageEvent; + this.messageHandlers.forEach((handler) => handler(msgEvent.data)); + }); + + this.ws.addEventListener("error", (event: Event) => { + console.error("[WSClient] WebSocket error:", event); + clearTimeout(timeout); + // Extract useful details from the event — raw Event objects don't serialize + const details: Record = { type: event.type }; + if ("message" in event) { + details.message = (event as ErrorEvent).message; + } + const error = new SDKError("WebSocket error", 0, "WS_ERROR", details); + + // Call the network error callback if configured + if (this.onNetworkError) { + this.onNetworkError(error, { + method: "WS", + path: this.wsURL, + isRetry: false, + attempt: 0, + }); + } + + this.errorHandlers.forEach((handler) => handler(error)); + reject(error); + }); + + this.ws.addEventListener("close", (event: Event) => { + clearTimeout(timeout); + const closeEvent = event as CloseEvent; + const code = closeEvent.code ?? 1006; + const reason = closeEvent.reason ?? ""; + console.log(`[WSClient] Connection closed (code: ${code}, reason: ${reason || "none"})`); + this.closeHandlers.forEach((handler) => handler(code, reason)); + }); + } catch (error) { + reject(error); + } + }); + } + + /** + * Build WebSocket URL with auth token + */ + private buildWSUrl(): string { + let url = this.wsURL; + + if (this.authToken) { + const separator = url.includes("?") ? "&" : "?"; + const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token"; + // API keys contain a colon (ak_xxx:namespace) that must not be percent-encoded + const encodedToken = this.authToken.startsWith("ak_") + ? this.authToken + : encodeURIComponent(this.authToken); + url += `${separator}${paramName}=${encodedToken}`; + } + + return url; + } + + /** + * Register message handler + */ + onMessage(handler: WSMessageHandler): () => void { + this.messageHandlers.add(handler); + return () => this.messageHandlers.delete(handler); + } + + /** + * Unregister message handler + */ + offMessage(handler: WSMessageHandler): void { + this.messageHandlers.delete(handler); + } + + /** + * Register error handler + */ + onError(handler: WSErrorHandler): () => void { + this.errorHandlers.add(handler); + return () => this.errorHandlers.delete(handler); + } + + /** + * Unregister error handler + */ + offError(handler: WSErrorHandler): void { + this.errorHandlers.delete(handler); + } + + /** + * Register close handler + */ + onClose(handler: WSCloseHandler): () => void { + this.closeHandlers.add(handler); + return () => this.closeHandlers.delete(handler); + } + + /** + * Unregister close handler + */ + offClose(handler: WSCloseHandler): void { + this.closeHandlers.delete(handler); + } + + /** + * Register open handler + */ + onOpen(handler: WSOpenHandler): () => void { + this.openHandlers.add(handler); + return () => this.openHandlers.delete(handler); + } + + /** + * Send data through WebSocket + */ + send(data: string): void { + if (this.ws?.readyState !== WebSocket.OPEN) { + throw new SDKError("WebSocket is not connected", 0, "WS_NOT_CONNECTED"); + } + this.ws.send(data); + } + + /** + * Close WebSocket connection + */ + close(): void { + if (this.isClosed) { + return; + } + this.isClosed = true; + this.ws?.close(); + } + + /** + * Check if WebSocket is connected + */ + isConnected(): boolean { + return !this.isClosed && this.ws?.readyState === WebSocket.OPEN; + } + + /** + * Update auth token + */ + setAuthToken(token?: string): void { + this.authToken = token; + } +} diff --git a/sdk/src/db/client.ts b/sdk/src/db/client.ts new file mode 100644 index 0000000..7a193c6 --- /dev/null +++ b/sdk/src/db/client.ts @@ -0,0 +1,126 @@ +import { HttpClient } from "../core/http"; +import { QueryBuilder } from "./qb"; +import { Repository } from "./repository"; +import { + QueryResponse, + TransactionOp, + TransactionRequest, + Entity, + FindOptions, +} from "./types"; + +export class DBClient { + private httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + /** + * Execute a write/DDL SQL statement. + */ + async exec( + sql: string, + args: any[] = [] + ): Promise<{ rows_affected: number; last_insert_id?: number }> { + return this.httpClient.post("/v1/rqlite/exec", { sql, args }); + } + + /** + * Execute a SELECT query. + */ + async query(sql: string, args: any[] = []): Promise { + const response = await this.httpClient.post( + "/v1/rqlite/query", + { sql, args } + ); + return response.items || []; + } + + /** + * Find rows with map-based criteria. + */ + async find( + table: string, + criteria: Record = {}, + options: FindOptions = {} + ): Promise { + const response = await this.httpClient.post( + "/v1/rqlite/find", + { + table, + criteria, + options, + } + ); + return response.items || []; + } + + /** + * Find a single row with map-based criteria. + */ + async findOne( + table: string, + criteria: Record + ): Promise { + return this.httpClient.post("/v1/rqlite/find-one", { + table, + criteria, + }); + } + + /** + * Create a fluent QueryBuilder for complex SELECT queries. + */ + createQueryBuilder(table: string): QueryBuilder { + return new QueryBuilder(this.httpClient, table); + } + + /** + * Create a Repository for entity-based operations. + */ + repository>( + tableName: string, + primaryKey = "id" + ): Repository { + return new Repository(this.httpClient, tableName, primaryKey); + } + + /** + * Execute multiple operations atomically. + */ + async transaction( + ops: TransactionOp[], + returnResults = true + ): Promise { + const response = await this.httpClient.post<{ results?: any[] }>( + "/v1/rqlite/transaction", + { + ops, + return_results: returnResults, + } + ); + return response.results || []; + } + + /** + * Create a table from DDL SQL. + */ + async createTable(schema: string): Promise { + await this.httpClient.post("/v1/rqlite/create-table", { schema }); + } + + /** + * Drop a table. + */ + async dropTable(table: string): Promise { + await this.httpClient.post("/v1/rqlite/drop-table", { table }); + } + + /** + * Get current database schema. + */ + async getSchema(): Promise { + return this.httpClient.get("/v1/rqlite/schema"); + } +} diff --git a/sdk/src/db/index.ts b/sdk/src/db/index.ts new file mode 100644 index 0000000..f8c8ee2 --- /dev/null +++ b/sdk/src/db/index.ts @@ -0,0 +1,13 @@ +export { DBClient } from "./client"; +export { QueryBuilder } from "./qb"; +export { Repository } from "./repository"; +export type { + Entity, + QueryResponse, + TransactionOp, + TransactionRequest, + SelectOptions, + FindOptions, + ColumnDefinition, +} from "./types"; +export { extractTableName, extractPrimaryKey } from "./types"; diff --git a/sdk/src/db/qb.ts b/sdk/src/db/qb.ts new file mode 100644 index 0000000..6bacd28 --- /dev/null +++ b/sdk/src/db/qb.ts @@ -0,0 +1,111 @@ +import { HttpClient } from "../core/http"; +import { SelectOptions, QueryResponse } from "./types"; + +export class QueryBuilder { + private httpClient: HttpClient; + private table: string; + private options: SelectOptions = {}; + + constructor(httpClient: HttpClient, table: string) { + this.httpClient = httpClient; + this.table = table; + } + + select(...columns: string[]): this { + this.options.select = columns; + return this; + } + + innerJoin(table: string, on: string): this { + if (!this.options.joins) this.options.joins = []; + this.options.joins.push({ kind: "INNER", table, on }); + return this; + } + + leftJoin(table: string, on: string): this { + if (!this.options.joins) this.options.joins = []; + this.options.joins.push({ kind: "LEFT", table, on }); + return this; + } + + rightJoin(table: string, on: string): this { + if (!this.options.joins) this.options.joins = []; + this.options.joins.push({ kind: "RIGHT", table, on }); + return this; + } + + where(expr: string, args?: any[]): this { + if (!this.options.where) this.options.where = []; + this.options.where.push({ conj: "AND", expr, args }); + return this; + } + + andWhere(expr: string, args?: any[]): this { + return this.where(expr, args); + } + + orWhere(expr: string, args?: any[]): this { + if (!this.options.where) this.options.where = []; + this.options.where.push({ conj: "OR", expr, args }); + return this; + } + + groupBy(...columns: string[]): this { + this.options.group_by = columns; + return this; + } + + orderBy(...columns: string[]): this { + this.options.order_by = columns; + return this; + } + + limit(n: number): this { + this.options.limit = n; + return this; + } + + offset(n: number): this { + this.options.offset = n; + return this; + } + + async getMany(ctx?: any): Promise { + const response = await this.httpClient.post( + "/v1/rqlite/select", + { + table: this.table, + ...this.options, + } + ); + return response.items || []; + } + + async getOne(ctx?: any): Promise { + const response = await this.httpClient.post( + "/v1/rqlite/select", + { + table: this.table, + ...this.options, + one: true, + limit: 1, + } + ); + const items = response.items || []; + return items.length > 0 ? items[0] : null; + } + + async count(): Promise { + const response = await this.httpClient.post( + "/v1/rqlite/select", + { + table: this.table, + select: ["COUNT(*) AS count"], + where: this.options.where, + one: true, + } + ); + const items = response.items || []; + return items.length > 0 ? items[0].count : 0; + } +} diff --git a/sdk/src/db/repository.ts b/sdk/src/db/repository.ts new file mode 100644 index 0000000..cd12702 --- /dev/null +++ b/sdk/src/db/repository.ts @@ -0,0 +1,128 @@ +import { HttpClient } from "../core/http"; +import { QueryBuilder } from "./qb"; +import { QueryResponse, FindOptions } from "./types"; +import { SDKError } from "../errors"; + +export class Repository> { + private httpClient: HttpClient; + private tableName: string; + private primaryKey: string; + + constructor(httpClient: HttpClient, tableName: string, primaryKey = "id") { + this.httpClient = httpClient; + this.tableName = tableName; + this.primaryKey = primaryKey; + } + + createQueryBuilder(): QueryBuilder { + return new QueryBuilder(this.httpClient, this.tableName); + } + + async find( + criteria: Record = {}, + options: FindOptions = {} + ): Promise { + const response = await this.httpClient.post( + "/v1/rqlite/find", + { + table: this.tableName, + criteria, + options, + } + ); + return response.items || []; + } + + async findOne(criteria: Record): Promise { + try { + const response = await this.httpClient.post( + "/v1/rqlite/find-one", + { + table: this.tableName, + criteria, + } + ); + return response; + } catch (error) { + // Return null if not found instead of throwing + if (error instanceof SDKError && error.httpStatus === 404) { + return null; + } + throw error; + } + } + + async save(entity: T): Promise { + const pkValue = entity[this.primaryKey]; + + if (!pkValue) { + // INSERT + const response = await this.httpClient.post<{ + rows_affected: number; + last_insert_id: number; + }>("/v1/rqlite/exec", { + sql: this.buildInsertSql(entity), + args: this.buildInsertArgs(entity), + }); + + if (response.last_insert_id) { + (entity as any)[this.primaryKey] = response.last_insert_id; + } + return entity; + } else { + // UPDATE + await this.httpClient.post("/v1/rqlite/exec", { + sql: this.buildUpdateSql(entity), + args: this.buildUpdateArgs(entity), + }); + return entity; + } + } + + async remove(entity: T | Record): Promise { + const pkValue = entity[this.primaryKey]; + if (!pkValue) { + throw new SDKError( + `Primary key "${this.primaryKey}" is required for remove`, + 400, + "MISSING_PK" + ); + } + + await this.httpClient.post("/v1/rqlite/exec", { + sql: `DELETE FROM ${this.tableName} WHERE ${this.primaryKey} = ?`, + args: [pkValue], + }); + } + + private buildInsertSql(entity: T): string { + const columns = Object.keys(entity).filter((k) => entity[k] !== undefined); + const placeholders = columns.map(() => "?").join(", "); + return `INSERT INTO ${this.tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; + } + + private buildInsertArgs(entity: T): any[] { + return Object.entries(entity) + .filter(([, v]) => v !== undefined) + .map(([, v]) => v); + } + + private buildUpdateSql(entity: T): string { + const columns = Object.keys(entity) + .filter((k) => entity[k] !== undefined && k !== this.primaryKey) + .map((k) => `${k} = ?`); + return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${ + this.primaryKey + } = ?`; + } + + private buildUpdateArgs(entity: T): any[] { + const args = Object.entries(entity) + .filter(([k, v]) => v !== undefined && k !== this.primaryKey) + .map(([, v]) => v); + args.push(entity[this.primaryKey]); + return args; + } +} diff --git a/sdk/src/db/types.ts b/sdk/src/db/types.ts new file mode 100644 index 0000000..969e89a --- /dev/null +++ b/sdk/src/db/types.ts @@ -0,0 +1,67 @@ +export interface Entity { + TableName(): string; +} + +export interface QueryResponse { + columns?: string[]; + rows?: any[][]; + count?: number; + items?: any[]; +} + +export interface TransactionOp { + kind: "exec" | "query"; + sql: string; + args?: any[]; +} + +export interface TransactionRequest { + statements?: string[]; + ops?: TransactionOp[]; + return_results?: boolean; +} + +export interface SelectOptions { + select?: string[]; + joins?: Array<{ + kind: "INNER" | "LEFT" | "RIGHT" | "FULL"; + table: string; + on: string; + }>; + where?: Array<{ + conj?: "AND" | "OR"; + expr: string; + args?: any[]; + }>; + group_by?: string[]; + order_by?: string[]; + limit?: number; + offset?: number; + one?: boolean; +} + +export type FindOptions = Omit; + +export interface ColumnDefinition { + name: string; + isPrimaryKey?: boolean; + isAutoIncrement?: boolean; +} + +export function extractTableName(entity: Entity | string): string { + if (typeof entity === "string") return entity; + return entity.TableName(); +} + +export function extractPrimaryKey(entity: any): string | undefined { + if (typeof entity === "string") return undefined; + + // Check for explicit pk tag + const metadata = (entity as any)._dbMetadata; + if (metadata?.primaryKey) return metadata.primaryKey; + + // Check for ID field + if (entity.id !== undefined) return "id"; + + return undefined; +} diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts new file mode 100644 index 0000000..fa91ae3 --- /dev/null +++ b/sdk/src/errors.ts @@ -0,0 +1,38 @@ +export class SDKError extends Error { + public readonly httpStatus: number; + public readonly code: string; + public readonly details: Record; + + constructor( + message: string, + httpStatus: number = 500, + code: string = "SDK_ERROR", + details: Record = {} + ) { + super(message); + this.name = "SDKError"; + this.httpStatus = httpStatus; + this.code = code; + this.details = details; + } + + static fromResponse( + status: number, + body: any, + message?: string + ): SDKError { + const errorMsg = message || body?.error || `HTTP ${status}`; + const code = body?.code || `HTTP_${status}`; + return new SDKError(errorMsg, status, code, body); + } + + toJSON() { + return { + name: this.name, + message: this.message, + httpStatus: this.httpStatus, + code: this.code, + details: this.details, + }; + } +} diff --git a/sdk/src/functions/client.ts b/sdk/src/functions/client.ts new file mode 100644 index 0000000..4cf10c8 --- /dev/null +++ b/sdk/src/functions/client.ts @@ -0,0 +1,62 @@ +/** + * Functions Client + * Client for calling serverless functions on the Orama Network + */ + +import { HttpClient } from "../core/http"; +import { SDKError } from "../errors"; + +export interface FunctionsClientConfig { + /** + * Base URL for the functions gateway + * Defaults to using the same baseURL as the HTTP client + */ + gatewayURL?: string; + + /** + * Namespace for the functions + */ + namespace: string; +} + +export class FunctionsClient { + private httpClient: HttpClient; + private gatewayURL?: string; + private namespace: string; + + constructor(httpClient: HttpClient, config?: FunctionsClientConfig) { + this.httpClient = httpClient; + this.gatewayURL = config?.gatewayURL; + this.namespace = config?.namespace ?? "default"; + } + + /** + * Invoke a serverless function by name + * + * @param functionName - Name of the function to invoke + * @param input - Input payload for the function + * @returns The function response + */ + async invoke( + functionName: string, + input: TInput + ): Promise { + const url = this.gatewayURL + ? `${this.gatewayURL}/v1/invoke/${this.namespace}/${functionName}` + : `/v1/invoke/${this.namespace}/${functionName}`; + + try { + const response = await this.httpClient.post(url, input); + return response; + } catch (error) { + if (error instanceof SDKError) { + throw error; + } + throw new SDKError( + `Function ${functionName} failed`, + 500, + error instanceof Error ? error.message : String(error) + ); + } + } +} diff --git a/sdk/src/functions/index.ts b/sdk/src/functions/index.ts new file mode 100644 index 0000000..dd6fd21 --- /dev/null +++ b/sdk/src/functions/index.ts @@ -0,0 +1,2 @@ +export { FunctionsClient, type FunctionsClientConfig } from "./client"; +export type { FunctionResponse, SuccessResponse } from "./types"; diff --git a/sdk/src/functions/types.ts b/sdk/src/functions/types.ts new file mode 100644 index 0000000..4bd1688 --- /dev/null +++ b/sdk/src/functions/types.ts @@ -0,0 +1,21 @@ +/** + * Serverless Functions Types + * Type definitions for calling serverless functions on the Orama Network + */ + +/** + * Generic response from a serverless function + */ +export interface FunctionResponse { + success: boolean; + error?: string; + data?: T; +} + +/** + * Standard success/error response used by many functions + */ +export interface SuccessResponse { + success: boolean; + error?: string; +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..415c494 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,201 @@ +import { HttpClient, HttpClientConfig, NetworkErrorCallback } from "./core/http"; +import { AuthClient } from "./auth/client"; +import { DBClient } from "./db/client"; +import { PubSubClient } from "./pubsub/client"; +import { NetworkClient } from "./network/client"; +import { CacheClient } from "./cache/client"; +import { StorageClient } from "./storage/client"; +import { FunctionsClient, FunctionsClientConfig } from "./functions/client"; +import { VaultClient } from "./vault/client"; +import { WSClientConfig } from "./core/ws"; +import { + StorageAdapter, + MemoryStorage, + LocalStorageAdapter, +} from "./auth/types"; +import type { VaultConfig } from "./vault/types"; + +export interface ClientConfig extends Omit { + apiKey?: string; + jwt?: string; + storage?: StorageAdapter; + wsConfig?: Partial>; + functionsConfig?: FunctionsClientConfig; + fetch?: typeof fetch; + /** + * Callback invoked on network errors (HTTP and WebSocket). + * Use this to trigger gateway failover at the application layer. + */ + onNetworkError?: NetworkErrorCallback; + /** Configuration for the vault (distributed secrets store). */ + vaultConfig?: VaultConfig; +} + +export interface Client { + auth: AuthClient; + db: DBClient; + pubsub: PubSubClient; + network: NetworkClient; + cache: CacheClient; + storage: StorageClient; + functions: FunctionsClient; + vault: VaultClient | null; +} + +export function createClient(config: ClientConfig): Client { + const httpClient = new HttpClient({ + baseURL: config.baseURL, + timeout: config.timeout, + maxRetries: config.maxRetries, + retryDelayMs: config.retryDelayMs, + debug: config.debug, + fetch: config.fetch, + onNetworkError: config.onNetworkError, + }); + + const auth = new AuthClient({ + httpClient, + storage: config.storage, + apiKey: config.apiKey, + jwt: config.jwt, + }); + + // Derive WebSocket URL from baseURL + const wsURL = config.baseURL.replace(/^http/, "ws").replace(/\/$/, ""); + + const db = new DBClient(httpClient); + const pubsub = new PubSubClient(httpClient, { + ...config.wsConfig, + wsURL, + onNetworkError: config.onNetworkError, + }); + const network = new NetworkClient(httpClient); + const cache = new CacheClient(httpClient); + const storage = new StorageClient(httpClient); + const functions = new FunctionsClient(httpClient, config.functionsConfig); + const vault = config.vaultConfig + ? new VaultClient(config.vaultConfig) + : null; + + return { + auth, + db, + pubsub, + network, + cache, + storage, + functions, + vault, + }; +} + +export { HttpClient } from "./core/http"; +export type { NetworkErrorCallback, NetworkErrorContext } from "./core/http"; +export { WSClient } from "./core/ws"; +export { AuthClient } from "./auth/client"; +export { DBClient } from "./db/client"; +export { QueryBuilder } from "./db/qb"; +export { Repository } from "./db/repository"; +export { PubSubClient, Subscription } from "./pubsub/client"; +export { NetworkClient } from "./network/client"; +export { CacheClient } from "./cache/client"; +export { StorageClient } from "./storage/client"; +export { FunctionsClient } from "./functions/client"; +export { SDKError } from "./errors"; +export { MemoryStorage, LocalStorageAdapter } from "./auth/types"; +export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types"; +export type * from "./db/types"; +export type { + MessageHandler, + ErrorHandler, + CloseHandler, + PresenceMember, + PresenceResponse, + PresenceOptions, + SubscribeOptions, +} from "./pubsub/types"; +export { type PubSubMessage } from "./pubsub/types"; +export type { + PeerInfo, + NetworkStatus, + ProxyRequest, + ProxyResponse, +} from "./network/client"; +export type { + CacheGetRequest, + CacheGetResponse, + CachePutRequest, + CachePutResponse, + CacheDeleteRequest, + CacheDeleteResponse, + CacheMultiGetRequest, + CacheMultiGetResponse, + CacheScanRequest, + CacheScanResponse, + CacheHealthResponse, +} from "./cache/client"; +export type { + StorageUploadResponse, + StoragePinRequest, + StoragePinResponse, + StorageStatus, +} from "./storage/client"; +export type { FunctionsClientConfig } from "./functions/client"; +export type * from "./functions/types"; +// Vault module +export { VaultClient } from "./vault/client"; +export { AuthClient as VaultAuthClient } from "./vault/auth"; +export { GuardianClient, GuardianError } from "./vault/transport"; +export { fanOut, fanOutIndexed, withTimeout, withRetry } from "./vault/transport"; +export { adaptiveThreshold, writeQuorum } from "./vault/quorum"; +export { + encrypt, + decrypt, + encryptString, + decryptString, + serializeEncrypted, + deserializeEncrypted, + encryptAndSerialize, + deserializeAndDecrypt, + encryptedToHex, + encryptedFromHex, + encryptedToBase64, + encryptedFromBase64, + generateKey, + generateNonce, + clearKey, + isValidEncryptedData, + KEY_SIZE, + NONCE_SIZE, + TAG_SIZE, + deriveKeyHKDF, + shamirSplit, + shamirCombine, +} from "./vault"; +export type { + VaultConfig, + SecretMeta, + StoreResult, + RetrieveResult, + ListResult, + DeleteResult, + GuardianResult as VaultGuardianResult, + EncryptedData, + SerializedEncryptedData, + ShamirShare, + GuardianEndpoint, + GuardianErrorCode, + GuardianInfo, + GuardianHealthResponse, + GuardianStatusResponse, + PushResponse, + PullResponse, + StoreSecretResponse, + GetSecretResponse, + DeleteSecretResponse, + ListSecretsResponse, + SecretEntry, + GuardianChallengeResponse, + GuardianSessionResponse, + FanOutResult, +} from "./vault"; diff --git a/sdk/src/network/client.ts b/sdk/src/network/client.ts new file mode 100644 index 0000000..c9180bd --- /dev/null +++ b/sdk/src/network/client.ts @@ -0,0 +1,119 @@ +import { HttpClient } from "../core/http"; + +export interface PeerInfo { + id: string; + addresses: string[]; + lastSeen?: string; +} + +export interface NetworkStatus { + node_id: string; + connected: boolean; + peer_count: number; + database_size: number; + uptime: number; +} + +export interface ProxyRequest { + url: string; + method: string; + headers?: Record; + body?: string; +} + +export interface ProxyResponse { + status_code: number; + headers: Record; + body: string; + error?: string; +} + +export class NetworkClient { + private httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + /** + * Check gateway health. + */ + async health(): Promise { + try { + await this.httpClient.get("/v1/health"); + return true; + } catch { + return false; + } + } + + /** + * Get network status. + */ + async status(): Promise { + const response = await this.httpClient.get( + "/v1/network/status" + ); + return response; + } + + /** + * Get connected peers. + */ + async peers(): Promise { + const response = await this.httpClient.get<{ peers: PeerInfo[] }>( + "/v1/network/peers" + ); + return response.peers || []; + } + + /** + * Connect to a peer. + */ + async connect(peerAddr: string): Promise { + await this.httpClient.post("/v1/network/connect", { peer_addr: peerAddr }); + } + + /** + * Disconnect from a peer. + */ + async disconnect(peerId: string): Promise { + await this.httpClient.post("/v1/network/disconnect", { peer_id: peerId }); + } + + /** + * Proxy an HTTP request through the Anyone network. + * Requires authentication (API key or JWT). + * + * @param request - The proxy request configuration + * @returns The proxied response + * @throws {SDKError} If the Anyone proxy is not available or the request fails + * + * @example + * ```ts + * const response = await client.network.proxyAnon({ + * url: 'https://api.example.com/data', + * method: 'GET', + * headers: { + * 'Accept': 'application/json' + * } + * }); + * + * console.log(response.status_code); // 200 + * console.log(response.body); // Response data + * ``` + */ + async proxyAnon(request: ProxyRequest): Promise { + const response = await this.httpClient.post( + "/v1/proxy/anon", + request + ); + + // Check if the response contains an error + if (response.error) { + throw new Error(`Proxy request failed: ${response.error}`); + } + + return response; + } +} diff --git a/sdk/src/network/index.ts b/sdk/src/network/index.ts new file mode 100644 index 0000000..f5d2497 --- /dev/null +++ b/sdk/src/network/index.ts @@ -0,0 +1,7 @@ +export { NetworkClient } from "./client"; +export type { + PeerInfo, + NetworkStatus, + ProxyRequest, + ProxyResponse, +} from "./client"; diff --git a/sdk/src/pubsub/client.ts b/sdk/src/pubsub/client.ts new file mode 100644 index 0000000..0fcfb62 --- /dev/null +++ b/sdk/src/pubsub/client.ts @@ -0,0 +1,361 @@ +import { HttpClient } from "../core/http"; +import { WSClient, WSClientConfig } from "../core/ws"; +import { + PubSubMessage, + RawEnvelope, + MessageHandler, + ErrorHandler, + CloseHandler, + SubscribeOptions, + PresenceResponse, + PresenceMember, + PresenceOptions, +} from "./types"; + +// Cross-platform base64 encoding/decoding utilities +function base64Encode(str: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(str).toString("base64"); + } else if (typeof btoa !== "undefined") { + return btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => + String.fromCharCode(parseInt(p1, 16)) + ) + ); + } + throw new Error("No base64 encoding method available"); +} + +function base64EncodeBytes(bytes: Uint8Array): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } else if (typeof btoa !== "undefined") { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + throw new Error("No base64 encoding method available"); +} + +function base64Decode(b64: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(b64, "base64").toString("utf-8"); + } else if (typeof atob !== "undefined") { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); + } + throw new Error("No base64 decoding method available"); +} + +/** + * Simple PubSub client - one WebSocket connection per topic + * Gateway failover is handled at the application layer + */ +export class PubSubClient { + private httpClient: HttpClient; + private wsConfig: Partial; + + constructor(httpClient: HttpClient, wsConfig: Partial = {}) { + this.httpClient = httpClient; + this.wsConfig = wsConfig; + } + + /** + * Publish a message to a topic via HTTP + */ + async publish(topic: string, data: string | Uint8Array): Promise { + let dataBase64: string; + if (typeof data === "string") { + dataBase64 = base64Encode(data); + } else { + dataBase64 = base64EncodeBytes(data); + } + + await this.httpClient.post( + "/v1/pubsub/publish", + { + topic, + data_base64: dataBase64, + }, + { + timeout: 30000, + } + ); + } + + /** + * List active topics in the current namespace + */ + async topics(): Promise { + const response = await this.httpClient.get<{ topics: string[] }>( + "/v1/pubsub/topics" + ); + return response.topics || []; + } + + /** + * Get current presence for a topic without subscribing + */ + async getPresence(topic: string): Promise { + const response = await this.httpClient.get( + `/v1/pubsub/presence?topic=${encodeURIComponent(topic)}` + ); + return response; + } + + /** + * Subscribe to a topic via WebSocket + * Creates one WebSocket connection per topic + */ + async subscribe( + topic: string, + options: SubscribeOptions = {} + ): Promise { + // Build WebSocket URL for this topic + const wsUrl = new URL(this.wsConfig.wsURL || "ws://127.0.0.1:6001"); + wsUrl.pathname = "/v1/pubsub/ws"; + wsUrl.searchParams.set("topic", topic); + + // Handle presence options + let presence: PresenceOptions | undefined; + if (options.presence?.enabled) { + presence = options.presence; + wsUrl.searchParams.set("presence", "true"); + wsUrl.searchParams.set("member_id", presence.memberId); + if (presence.meta) { + wsUrl.searchParams.set("member_meta", JSON.stringify(presence.meta)); + } + } + + const authToken = this.httpClient.getApiKey() ?? this.httpClient.getToken(); + + // Create WebSocket client + const wsClient = new WSClient({ + ...this.wsConfig, + wsURL: wsUrl.toString(), + authToken, + }); + + await wsClient.connect(); + + // Create subscription wrapper + const subscription = new Subscription(wsClient, topic, presence, () => + this.getPresence(topic) + ); + + if (options.onMessage) { + subscription.onMessage(options.onMessage); + } + if (options.onError) { + subscription.onError(options.onError); + } + if (options.onClose) { + subscription.onClose(options.onClose); + } + + return subscription; + } +} + +/** + * Subscription represents an active WebSocket subscription to a topic + */ +export class Subscription { + private wsClient: WSClient; + private topic: string; + private presenceOptions?: PresenceOptions; + private messageHandlers: Set = new Set(); + private errorHandlers: Set = new Set(); + private closeHandlers: Set = new Set(); + private isClosed = false; + private wsMessageHandler: ((data: string) => void) | null = null; + private wsErrorHandler: ((error: Error) => void) | null = null; + private wsCloseHandler: ((code: number, reason: string) => void) | null = null; + private getPresenceFn: () => Promise; + + constructor( + wsClient: WSClient, + topic: string, + presenceOptions: PresenceOptions | undefined, + getPresenceFn: () => Promise + ) { + this.wsClient = wsClient; + this.topic = topic; + this.presenceOptions = presenceOptions; + this.getPresenceFn = getPresenceFn; + + // Register message handler + this.wsMessageHandler = (data) => { + try { + // Parse gateway JSON envelope: {data: base64String, timestamp, topic} + const envelope: RawEnvelope = JSON.parse(data); + + // Validate envelope structure + if (!envelope || typeof envelope !== "object") { + throw new Error("Invalid envelope: not an object"); + } + + // Handle presence events + if ( + envelope.type === "presence.join" || + envelope.type === "presence.leave" + ) { + if (!envelope.member_id) { + console.warn("[Subscription] Presence event missing member_id"); + return; + } + + const presenceMember: PresenceMember = { + memberId: envelope.member_id, + joinedAt: envelope.timestamp, + meta: envelope.meta, + }; + + if ( + envelope.type === "presence.join" && + this.presenceOptions?.onJoin + ) { + this.presenceOptions.onJoin(presenceMember); + } else if ( + envelope.type === "presence.leave" && + this.presenceOptions?.onLeave + ) { + this.presenceOptions.onLeave(presenceMember); + } + return; // Don't call regular onMessage for presence events + } + + if (!envelope.data || typeof envelope.data !== "string") { + throw new Error("Invalid envelope: missing or invalid data field"); + } + if (!envelope.topic || typeof envelope.topic !== "string") { + throw new Error("Invalid envelope: missing or invalid topic field"); + } + if (typeof envelope.timestamp !== "number") { + throw new Error( + "Invalid envelope: missing or invalid timestamp field" + ); + } + + // Decode base64 data + const messageData = base64Decode(envelope.data); + + const message: PubSubMessage = { + topic: envelope.topic, + data: messageData, + timestamp: envelope.timestamp, + }; + + console.log("[Subscription] Received message on topic:", this.topic); + this.messageHandlers.forEach((handler) => handler(message)); + } catch (error) { + console.error("[Subscription] Error processing message:", error); + this.errorHandlers.forEach((handler) => + handler(error instanceof Error ? error : new Error(String(error))) + ); + } + }; + + this.wsClient.onMessage(this.wsMessageHandler); + + // Register error handler + this.wsErrorHandler = (error) => { + this.errorHandlers.forEach((handler) => handler(error)); + }; + this.wsClient.onError(this.wsErrorHandler); + + // Register close handler + this.wsCloseHandler = (code: number, reason: string) => { + this.closeHandlers.forEach((handler) => handler(code, reason)); + }; + this.wsClient.onClose(this.wsCloseHandler); + } + + /** + * Get current presence (requires presence.enabled on subscribe) + */ + async getPresence(): Promise { + if (!this.presenceOptions?.enabled) { + throw new Error("Presence is not enabled for this subscription"); + } + + const response = await this.getPresenceFn(); + return response.members; + } + + /** + * Check if presence is enabled for this subscription + */ + hasPresence(): boolean { + return !!this.presenceOptions?.enabled; + } + + /** + * Register message handler + */ + onMessage(handler: MessageHandler): () => void { + this.messageHandlers.add(handler); + return () => this.messageHandlers.delete(handler); + } + + /** + * Register error handler + */ + onError(handler: ErrorHandler): () => void { + this.errorHandlers.add(handler); + return () => this.errorHandlers.delete(handler); + } + + /** + * Register close handler + */ + onClose(handler: CloseHandler): () => void { + this.closeHandlers.add(handler); + return () => this.closeHandlers.delete(handler); + } + + /** + * Close subscription and underlying WebSocket + */ + close(): void { + if (this.isClosed) { + return; + } + this.isClosed = true; + + // Remove handlers from WSClient + if (this.wsMessageHandler) { + this.wsClient.offMessage(this.wsMessageHandler); + this.wsMessageHandler = null; + } + if (this.wsErrorHandler) { + this.wsClient.offError(this.wsErrorHandler); + this.wsErrorHandler = null; + } + if (this.wsCloseHandler) { + this.wsClient.offClose(this.wsCloseHandler); + this.wsCloseHandler = null; + } + + // Clear all local handlers + this.messageHandlers.clear(); + this.errorHandlers.clear(); + this.closeHandlers.clear(); + + // Close WebSocket connection + this.wsClient.close(); + } + + /** + * Check if subscription is active + */ + isConnected(): boolean { + return !this.isClosed && this.wsClient.isConnected(); + } +} diff --git a/sdk/src/pubsub/index.ts b/sdk/src/pubsub/index.ts new file mode 100644 index 0000000..fbe7dd4 --- /dev/null +++ b/sdk/src/pubsub/index.ts @@ -0,0 +1,12 @@ +export { PubSubClient, Subscription } from "./client"; +export type { + PubSubMessage, + RawEnvelope, + MessageHandler, + ErrorHandler, + CloseHandler, + PresenceMember, + PresenceResponse, + PresenceOptions, + SubscribeOptions, +} from "./types"; diff --git a/sdk/src/pubsub/types.ts b/sdk/src/pubsub/types.ts new file mode 100644 index 0000000..6365f51 --- /dev/null +++ b/sdk/src/pubsub/types.ts @@ -0,0 +1,46 @@ +export interface PubSubMessage { + data: string; + topic: string; + timestamp: number; +} + +export interface RawEnvelope { + type?: string; + data: string; // base64-encoded + timestamp: number; + topic: string; + member_id?: string; + meta?: Record; +} + +export interface PresenceMember { + memberId: string; + joinedAt: number; + meta?: Record; +} + +export interface PresenceResponse { + topic: string; + members: PresenceMember[]; + count: number; +} + +export interface PresenceOptions { + enabled: boolean; + memberId: string; + meta?: Record; + onJoin?: (member: PresenceMember) => void; + onLeave?: (member: PresenceMember) => void; +} + +export interface SubscribeOptions { + onMessage?: MessageHandler; + onError?: ErrorHandler; + onClose?: CloseHandler; + presence?: PresenceOptions; +} + +export type MessageHandler = (message: PubSubMessage) => void; +export type ErrorHandler = (error: Error) => void; +export type CloseHandler = (code: number, reason: string) => void; + diff --git a/sdk/src/storage/client.ts b/sdk/src/storage/client.ts new file mode 100644 index 0000000..a923cfb --- /dev/null +++ b/sdk/src/storage/client.ts @@ -0,0 +1,272 @@ +import { HttpClient } from "../core/http"; + +export interface StorageUploadResponse { + cid: string; + name: string; + size: number; +} + +export interface StoragePinRequest { + cid: string; + name?: string; +} + +export interface StoragePinResponse { + cid: string; + name: string; +} + +export interface StorageStatus { + cid: string; + name: string; + status: string; // "pinned", "pinning", "queued", "unpinned", "error" + replication_min: number; + replication_max: number; + replication_factor: number; + peers: string[]; + error?: string; +} + +export class StorageClient { + private httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + /** + * Upload content to IPFS and optionally pin it. + * Supports both File objects (browser) and Buffer/ReadableStream (Node.js). + * + * @param file - File to upload (File, Blob, or Buffer) + * @param name - Optional filename + * @param options - Optional upload options + * @param options.pin - Whether to pin the content (default: true). Pinning happens asynchronously on the backend. + * @returns Upload result with CID + * + * @example + * ```ts + * // Browser + * const fileInput = document.querySelector('input[type="file"]'); + * const file = fileInput.files[0]; + * const result = await client.storage.upload(file, file.name); + * console.log(result.cid); + * + * // Node.js + * const fs = require('fs'); + * const fileBuffer = fs.readFileSync('image.jpg'); + * const result = await client.storage.upload(fileBuffer, 'image.jpg', { pin: true }); + * ``` + */ + async upload( + file: File | Blob | ArrayBuffer | Uint8Array | ReadableStream, + name?: string, + options?: { + pin?: boolean; + } + ): Promise { + // Create FormData for multipart upload + const formData = new FormData(); + + // Handle different input types + if (file instanceof File) { + formData.append("file", file); + } else if (file instanceof Blob) { + formData.append("file", file, name); + } else if (file instanceof ArrayBuffer) { + const blob = new Blob([file]); + formData.append("file", blob, name); + } else if (file instanceof Uint8Array) { + // Convert Uint8Array to ArrayBuffer for Blob constructor + const buffer = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength + ) as ArrayBuffer; + const blob = new Blob([buffer], { type: "application/octet-stream" }); + formData.append("file", blob, name); + } else if (file instanceof ReadableStream) { + // For ReadableStream, we need to read it into a blob first + // This is a limitation - in practice, pass File/Blob/Buffer + const chunks: ArrayBuffer[] = []; + const reader = file.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const buffer = value.buffer.slice( + value.byteOffset, + value.byteOffset + value.byteLength + ) as ArrayBuffer; + chunks.push(buffer); + } + const blob = new Blob(chunks); + formData.append("file", blob, name); + } else { + throw new Error( + "Unsupported file type. Use File, Blob, ArrayBuffer, Uint8Array, or ReadableStream." + ); + } + + // Add pin flag (default: true) + const shouldPin = options?.pin !== false; // Default to true + formData.append("pin", shouldPin ? "true" : "false"); + + return this.httpClient.uploadFile( + "/v1/storage/upload", + formData, + { timeout: 300000 } // 5 minute timeout for large files + ); + } + + /** + * Pin an existing CID + * + * @param cid - Content ID to pin + * @param name - Optional name for the pin + * @returns Pin result + */ + async pin(cid: string, name?: string): Promise { + return this.httpClient.post("/v1/storage/pin", { + cid, + name, + }); + } + + /** + * Get the pin status for a CID + * + * @param cid - Content ID to check + * @returns Pin status information + */ + async status(cid: string): Promise { + return this.httpClient.get(`/v1/storage/status/${cid}`); + } + + /** + * Retrieve content from IPFS by CID + * + * @param cid - Content ID to retrieve + * @returns ReadableStream of the content + * + * @example + * ```ts + * const stream = await client.storage.get(cid); + * const reader = stream.getReader(); + * while (true) { + * const { done, value } = await reader.read(); + * if (done) break; + * // Process chunk + * } + * ``` + */ + async get(cid: string): Promise> { + // Retry logic for content retrieval - content may not be immediately available + // after upload due to eventual consistency in IPFS Cluster + // IPFS Cluster pins can take 2-3+ seconds to complete across all nodes + const maxAttempts = 8; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await this.httpClient.getBinary( + `/v1/storage/get/${cid}` + ); + + if (!response.body) { + throw new Error("Response body is null"); + } + + return response.body; + } catch (error: any) { + lastError = error; + + // Check if this is a 404 error (content not found) + const isNotFound = + error?.httpStatus === 404 || + error?.message?.includes("not found") || + error?.message?.includes("404"); + + // If it's not a 404 error, or this is the last attempt, give up + if (!isNotFound || attempt === maxAttempts) { + throw error; + } + + // Wait before retrying with bounded exponential backoff + // Max 3 seconds per retry to fit within 30s test timeout + // Total: 1s + 2s + 3s + 3s + 3s + 3s + 3s + 3s = 21 seconds + const backoffMs = Math.min(attempt * 1000, 3000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + + // This should never be reached, but TypeScript needs it + throw lastError || new Error("Failed to retrieve content"); + } + + /** + * Retrieve content from IPFS by CID and return the full Response object + * Useful when you need access to response headers (e.g., content-length) + * + * @param cid - Content ID to retrieve + * @returns Response object with body stream and headers + * + * @example + * ```ts + * const response = await client.storage.getBinary(cid); + * const contentLength = response.headers.get('content-length'); + * const reader = response.body.getReader(); + * // ... read stream + * ``` + */ + async getBinary(cid: string): Promise { + // Retry logic for content retrieval - content may not be immediately available + // after upload due to eventual consistency in IPFS Cluster + // IPFS Cluster pins can take 2-3+ seconds to complete across all nodes + const maxAttempts = 8; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await this.httpClient.getBinary( + `/v1/storage/get/${cid}` + ); + + if (!response) { + throw new Error("Response is null"); + } + + return response; + } catch (error: any) { + lastError = error; + + // Check if this is a 404 error (content not found) + const isNotFound = + error?.httpStatus === 404 || + error?.message?.includes("not found") || + error?.message?.includes("404"); + + // If it's not a 404 error, or this is the last attempt, give up + if (!isNotFound || attempt === maxAttempts) { + throw error; + } + + // Wait before retrying with bounded exponential backoff + // Max 3 seconds per retry to fit within 30s test timeout + // Total: 1s + 2s + 3s + 3s + 3s + 3s + 3s + 3s = 21 seconds + const backoffMs = Math.min(attempt * 1000, 3000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + + // This should never be reached, but TypeScript needs it + throw lastError || new Error("Failed to retrieve content"); + } + + /** + * Unpin a CID + * + * @param cid - Content ID to unpin + */ + async unpin(cid: string): Promise { + await this.httpClient.delete(`/v1/storage/unpin/${cid}`); + } +} diff --git a/sdk/src/storage/index.ts b/sdk/src/storage/index.ts new file mode 100644 index 0000000..a255cc0 --- /dev/null +++ b/sdk/src/storage/index.ts @@ -0,0 +1,7 @@ +export { StorageClient } from "./client"; +export type { + StorageUploadResponse, + StoragePinRequest, + StoragePinResponse, + StorageStatus, +} from "./client"; diff --git a/sdk/src/utils/codec.ts b/sdk/src/utils/codec.ts new file mode 100644 index 0000000..7e0d5f0 --- /dev/null +++ b/sdk/src/utils/codec.ts @@ -0,0 +1,68 @@ +/** + * Base64 Codec for cross-platform encoding/decoding + * Works in both Node.js and browser environments + */ +export class Base64Codec { + /** + * Encode string or Uint8Array to base64 + */ + static encode(input: string | Uint8Array): string { + if (typeof input === "string") { + return this.encodeString(input); + } + return this.encodeBytes(input); + } + + /** + * Encode string to base64 + */ + static encodeString(str: string): string { + if (this.isNode()) { + return Buffer.from(str).toString("base64"); + } + // Browser + return btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => + String.fromCharCode(parseInt(p1, 16)) + ) + ); + } + + /** + * Encode Uint8Array to base64 + */ + static encodeBytes(bytes: Uint8Array): string { + if (this.isNode()) { + return Buffer.from(bytes).toString("base64"); + } + // Browser + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + /** + * Decode base64 to string + */ + static decode(b64: string): string { + if (this.isNode()) { + return Buffer.from(b64, "base64").toString("utf-8"); + } + // Browser + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); + } + + /** + * Check if running in Node.js environment + */ + private static isNode(): boolean { + return typeof Buffer !== "undefined"; + } +} diff --git a/sdk/src/utils/index.ts b/sdk/src/utils/index.ts new file mode 100644 index 0000000..23df933 --- /dev/null +++ b/sdk/src/utils/index.ts @@ -0,0 +1,3 @@ +export { Base64Codec } from "./codec"; +export { retryWithBackoff, type RetryConfig } from "./retry"; +export { Platform } from "./platform"; diff --git a/sdk/src/utils/platform.ts b/sdk/src/utils/platform.ts new file mode 100644 index 0000000..8ceec89 --- /dev/null +++ b/sdk/src/utils/platform.ts @@ -0,0 +1,44 @@ +/** + * Platform detection utilities + * Helps determine runtime environment (Node.js vs Browser) + */ +export const Platform = { + /** + * Check if running in Node.js + */ + isNode: (): boolean => { + return typeof process !== "undefined" && !!process.versions?.node; + }, + + /** + * Check if running in browser + */ + isBrowser: (): boolean => { + return typeof window !== "undefined"; + }, + + /** + * Check if localStorage is available + */ + hasLocalStorage: (): boolean => { + try { + return typeof localStorage !== "undefined" && localStorage !== null; + } catch { + return false; + } + }, + + /** + * Check if Buffer is available (Node.js) + */ + hasBuffer: (): boolean => { + return typeof Buffer !== "undefined"; + }, + + /** + * Check if btoa/atob are available (Browser) + */ + hasBase64: (): boolean => { + return typeof btoa !== "undefined" && typeof atob !== "undefined"; + }, +}; diff --git a/sdk/src/utils/retry.ts b/sdk/src/utils/retry.ts new file mode 100644 index 0000000..5d755e5 --- /dev/null +++ b/sdk/src/utils/retry.ts @@ -0,0 +1,58 @@ +/** + * Retry configuration + */ +export interface RetryConfig { + /** + * Maximum number of retry attempts + */ + maxAttempts: number; + + /** + * Function to calculate backoff delay in milliseconds + */ + backoffMs: (attempt: number) => number; + + /** + * Function to determine if error should trigger retry + */ + shouldRetry: (error: any) => boolean; +} + +/** + * Retry an operation with exponential backoff + * @param operation - The async operation to retry + * @param config - Retry configuration + * @returns Promise resolving to operation result + * @throws Last error if all retries exhausted + */ +export async function retryWithBackoff( + operation: () => Promise, + config: RetryConfig +): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + try { + return await operation(); + } catch (error: any) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if we should retry this error + if (!config.shouldRetry(error)) { + throw error; + } + + // If this was the last attempt, throw + if (attempt === config.maxAttempts) { + throw error; + } + + // Wait before next attempt + const delay = config.backoffMs(attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // Fallback (should never reach here) + throw lastError || new Error("Retry failed"); +} diff --git a/sdk/src/vault/auth.ts b/sdk/src/vault/auth.ts new file mode 100644 index 0000000..c991a20 --- /dev/null +++ b/sdk/src/vault/auth.ts @@ -0,0 +1,98 @@ +import { GuardianClient } from './transport/guardian'; +import type { GuardianEndpoint } from './transport/types'; + +/** + * Handles challenge-response authentication with guardian nodes. + * Caches session tokens per guardian endpoint. + * + * Auth flow: + * 1. POST /v2/vault/auth/challenge with identity → get {nonce, created_ns, tag} + * 2. POST /v2/vault/auth/session with identity + challenge fields → get session token + * 3. Use session token as X-Session-Token header for V2 requests + * + * The session token format is: `::` + */ +export class AuthClient { + private sessions = new Map(); + private identityHex: string; + private timeoutMs: number; + + constructor(identityHex: string, timeoutMs = 10_000) { + this.identityHex = identityHex; + this.timeoutMs = timeoutMs; + } + + /** + * Authenticate with a guardian and cache the session token. + * Returns a GuardianClient with the session token set. + */ + async authenticate(endpoint: GuardianEndpoint): Promise { + const key = `${endpoint.address}:${endpoint.port}`; + const cached = this.sessions.get(key); + + // Check if we have a valid cached session (with 30s safety margin) + if (cached) { + const nowNs = Date.now() * 1_000_000; + if (cached.expiryNs > nowNs + 30_000_000_000) { + const client = new GuardianClient(endpoint, this.timeoutMs); + client.setSessionToken(cached.token); + return client; + } + // Expired, remove + this.sessions.delete(key); + } + + const client = new GuardianClient(endpoint, this.timeoutMs); + + // Step 1: Request challenge + const challenge = await client.requestChallenge(this.identityHex); + + // Step 2: Exchange for session + const session = await client.createSession( + this.identityHex, + challenge.nonce, + challenge.created_ns, + challenge.tag, + ); + + // Build token string: identity:expiry_ns:tag + const token = `${session.identity}:${session.expiry_ns}:${session.tag}`; + client.setSessionToken(token); + + // Cache + this.sessions.set(key, { token, expiryNs: session.expiry_ns }); + + return client; + } + + /** + * Authenticate with multiple guardians in parallel. + * Returns authenticated GuardianClients for all that succeed. + */ + async authenticateAll(endpoints: GuardianEndpoint[]): Promise<{ client: GuardianClient; endpoint: GuardianEndpoint }[]> { + const results = await Promise.allSettled( + endpoints.map(async (ep) => { + const client = await this.authenticate(ep); + return { client, endpoint: ep }; + }), + ); + + const authenticated: { client: GuardianClient; endpoint: GuardianEndpoint }[] = []; + for (const r of results) { + if (r.status === 'fulfilled') { + authenticated.push(r.value); + } + } + return authenticated; + } + + /** Clear all cached sessions. */ + clearSessions(): void { + this.sessions.clear(); + } + + /** Get the identity hex string. */ + getIdentityHex(): string { + return this.identityHex; + } +} diff --git a/sdk/src/vault/client.ts b/sdk/src/vault/client.ts new file mode 100644 index 0000000..028658e --- /dev/null +++ b/sdk/src/vault/client.ts @@ -0,0 +1,197 @@ +import { AuthClient } from './auth'; +import type { GuardianClient } from './transport/guardian'; +import { withTimeout, withRetry } from './transport/fanout'; +import { split, combine } from './crypto/shamir'; +import type { Share } from './crypto/shamir'; +import { adaptiveThreshold, writeQuorum } from './quorum'; +import type { + VaultConfig, + StoreResult, + RetrieveResult, + ListResult, + DeleteResult, + GuardianResult, +} from './types'; + +const PULL_TIMEOUT_MS = 10_000; + +/** + * High-level client for the orama-vault distributed secrets store. + * + * Handles: + * - Authentication with guardian nodes + * - Shamir split/combine for data distribution + * - Quorum-based writes and reads + * - V2 CRUD operations (store, retrieve, list, delete) + */ +export class VaultClient { + private config: VaultConfig; + private auth: AuthClient; + + constructor(config: VaultConfig) { + this.config = config; + this.auth = new AuthClient(config.identityHex, config.timeoutMs); + } + + /** + * Store a secret across guardian nodes using Shamir splitting. + * + * @param name - Secret name (alphanumeric, _, -, max 128 chars) + * @param data - Secret data to store + * @param version - Monotonic version number (must be > previous) + */ + async store(name: string, data: Uint8Array, version: number): Promise { + const guardians = this.config.guardians; + const n = guardians.length; + const k = adaptiveThreshold(n); + + // Shamir split the data + const shares = split(data, n, k); + + // Authenticate and push to all guardians + const authed = await this.auth.authenticateAll(guardians); + + const results = await Promise.allSettled( + authed.map(async ({ client, endpoint }, _i) => { + // Find the share for this guardian's index + const guardianIdx = guardians.indexOf(endpoint); + const share = shares[guardianIdx]; + if (!share) throw new Error('share index out of bounds'); + + // Encode share as [x:1byte][y:rest] + const shareBytes = new Uint8Array(1 + share.y.length); + shareBytes[0] = share.x; + shareBytes.set(share.y, 1); + + return withRetry(() => client.putSecret(name, shareBytes, version)); + }), + ); + + // Wipe shares + for (const share of shares) { + share.y.fill(0); + } + + const guardianResults: GuardianResult[] = authed.map(({ endpoint }, i) => { + const ep = `${endpoint.address}:${endpoint.port}`; + const r = results[i]!; + if (r.status === 'fulfilled') { + return { endpoint: ep, success: true }; + } + return { endpoint: ep, success: false, error: (r.reason as Error).message }; + }); + + const ackCount = results.filter((r) => r.status === 'fulfilled').length; + const failCount = results.filter((r) => r.status === 'rejected').length; + const w = writeQuorum(n); + + return { + ackCount, + totalContacted: authed.length, + failCount, + quorumMet: ackCount >= w, + guardianResults, + }; + } + + /** + * Retrieve and reconstruct a secret from guardian nodes. + * + * @param name - Secret name + */ + async retrieve(name: string): Promise { + const guardians = this.config.guardians; + const n = guardians.length; + const k = adaptiveThreshold(n); + + // Authenticate and pull from all guardians + const authed = await this.auth.authenticateAll(guardians); + + const pullResults = await Promise.allSettled( + authed.map(async ({ client }) => { + const resp = await withTimeout(client.getSecret(name), PULL_TIMEOUT_MS); + const shareBytes = resp.share; + if (shareBytes.length < 2) throw new Error('Share too short'); + return { + x: shareBytes[0]!, + y: shareBytes.slice(1), + } as Share; + }), + ); + + const shares: Share[] = []; + for (const r of pullResults) { + if (r.status === 'fulfilled') { + shares.push(r.value); + } + } + + if (shares.length < k) { + throw new Error( + `Not enough shares: collected ${shares.length} of ${k} required (contacted ${authed.length} guardians)`, + ); + } + + // Reconstruct + const data = combine(shares); + + // Wipe collected shares + for (const share of shares) { + share.y.fill(0); + } + + return { + data, + sharesCollected: shares.length, + }; + } + + /** + * List all secrets for this identity. + * Queries the first reachable guardian (metadata is replicated). + */ + async list(): Promise { + const guardians = this.config.guardians; + const authed = await this.auth.authenticateAll(guardians); + + if (authed.length === 0) { + throw new Error('No guardians reachable'); + } + + // Query first authenticated guardian + const resp = await authed[0]!.client.listSecrets(); + return { secrets: resp.secrets }; + } + + /** + * Delete a secret from all guardian nodes. + * + * @param name - Secret name to delete + */ + async delete(name: string): Promise { + const guardians = this.config.guardians; + const n = guardians.length; + + const authed = await this.auth.authenticateAll(guardians); + + const results = await Promise.allSettled( + authed.map(async ({ client }) => { + return withRetry(() => client.deleteSecret(name)); + }), + ); + + const ackCount = results.filter((r) => r.status === 'fulfilled').length; + const w = writeQuorum(n); + + return { + ackCount, + totalContacted: authed.length, + quorumMet: ackCount >= w, + }; + } + + /** Clear all cached auth sessions. */ + clearSessions(): void { + this.auth.clearSessions(); + } +} diff --git a/sdk/src/vault/crypto/aes.ts b/sdk/src/vault/crypto/aes.ts new file mode 100644 index 0000000..ff349ac --- /dev/null +++ b/sdk/src/vault/crypto/aes.ts @@ -0,0 +1,271 @@ +/** + * AES-256-GCM Encryption + * + * Implements authenticated encryption using AES-256 in Galois/Counter Mode. + * Uses @noble/ciphers for platform-agnostic, audited cryptographic operations. + * + * Features: + * - Authenticated encryption (confidentiality + integrity) + * - 256-bit keys for strong security + * - 96-bit nonces (randomly generated) + * - 128-bit authentication tags + * + * Security considerations: + * - Never reuse a nonce with the same key + * - Nonces are randomly generated and prepended to ciphertext + * - Authentication tags are verified before decryption + */ + +import { gcm } from '@noble/ciphers/aes'; +import { randomBytes } from '@noble/ciphers/webcrypto'; +import { bytesToHex, hexToBytes, concatBytes } from '@noble/hashes/utils'; + +/** + * Size constants + */ +export const KEY_SIZE = 32; // 256 bits +export const NONCE_SIZE = 12; // 96 bits (recommended for GCM) +export const TAG_SIZE = 16; // 128 bits + +/** + * Encrypted data structure + */ +export interface EncryptedData { + /** Ciphertext including authentication tag */ + ciphertext: Uint8Array; + /** Nonce used for encryption */ + nonce: Uint8Array; + /** Additional authenticated data (optional) */ + aad?: Uint8Array; +} + +/** + * Serialized encrypted data (nonce prepended to ciphertext) + */ +export interface SerializedEncryptedData { + /** Combined nonce + ciphertext + tag */ + data: Uint8Array; + /** Additional authenticated data (optional) */ + aad?: Uint8Array; +} + +/** + * Encrypts data using AES-256-GCM + */ +export function encrypt( + plaintext: Uint8Array, + key: Uint8Array, + aad?: Uint8Array +): EncryptedData { + validateKey(key); + + const nonce = randomBytes(NONCE_SIZE); + const cipher = gcm(key, nonce, aad); + const ciphertext = cipher.encrypt(plaintext); + + return { + ciphertext, + nonce, + aad, + }; +} + +/** + * Decrypts data using AES-256-GCM + */ +export function decrypt(encryptedData: EncryptedData, key: Uint8Array): Uint8Array { + validateKey(key); + validateNonce(encryptedData.nonce); + + const cipher = gcm(key, encryptedData.nonce, encryptedData.aad); + + try { + return cipher.decrypt(encryptedData.ciphertext); + } catch (error) { + throw new Error('Decryption failed: invalid ciphertext or authentication tag'); + } +} + +/** + * Encrypts a string message + */ +export function encryptString( + message: string, + key: Uint8Array, + aad?: Uint8Array +): EncryptedData { + const plaintext = new TextEncoder().encode(message); + try { + return encrypt(plaintext, key, aad); + } finally { + plaintext.fill(0); + } +} + +/** + * Decrypts to a string message + */ +export function decryptString(encryptedData: EncryptedData, key: Uint8Array): string { + const plaintext = decrypt(encryptedData, key); + try { + return new TextDecoder().decode(plaintext); + } finally { + plaintext.fill(0); + } +} + +/** + * Serializes encrypted data (prepends nonce to ciphertext) + */ +export function serialize(encryptedData: EncryptedData): SerializedEncryptedData { + const data = concatBytes(encryptedData.nonce, encryptedData.ciphertext); + + return { + data, + aad: encryptedData.aad, + }; +} + +/** + * Deserializes encrypted data + */ +export function deserialize(serialized: SerializedEncryptedData): EncryptedData { + if (serialized.data.length < NONCE_SIZE + TAG_SIZE) { + throw new Error('Invalid serialized data: too short'); + } + + const nonce = serialized.data.slice(0, NONCE_SIZE); + const ciphertext = serialized.data.slice(NONCE_SIZE); + + return { + ciphertext, + nonce, + aad: serialized.aad, + }; +} + +/** + * Encrypts and serializes data in one step + */ +export function encryptAndSerialize( + plaintext: Uint8Array, + key: Uint8Array, + aad?: Uint8Array +): SerializedEncryptedData { + const encrypted = encrypt(plaintext, key, aad); + return serialize(encrypted); +} + +/** + * Deserializes and decrypts data in one step + */ +export function deserializeAndDecrypt( + serialized: SerializedEncryptedData, + key: Uint8Array +): Uint8Array { + const encrypted = deserialize(serialized); + return decrypt(encrypted, key); +} + +/** + * Converts encrypted data to hex string + */ +export function toHex(encryptedData: EncryptedData): string { + const serialized = serialize(encryptedData); + return bytesToHex(serialized.data); +} + +/** + * Parses encrypted data from hex string + */ +export function fromHex(hex: string, aad?: Uint8Array): EncryptedData { + const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; + const data = hexToBytes(normalized); + + return deserialize({ data, aad }); +} + +/** + * Converts encrypted data to base64 string + */ +export function toBase64(encryptedData: EncryptedData): string { + const serialized = serialize(encryptedData); + + if (typeof btoa === 'function') { + return btoa(String.fromCharCode(...serialized.data)); + } else { + return Buffer.from(serialized.data).toString('base64'); + } +} + +/** + * Parses encrypted data from base64 string + */ +export function fromBase64(base64: string, aad?: Uint8Array): EncryptedData { + let data: Uint8Array; + + if (typeof atob === 'function') { + const binary = atob(base64); + data = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + data[i] = binary.charCodeAt(i); + } + } else { + data = new Uint8Array(Buffer.from(base64, 'base64')); + } + + return deserialize({ data, aad }); +} + +function validateKey(key: Uint8Array): void { + if (!(key instanceof Uint8Array)) { + throw new Error('Key must be a Uint8Array'); + } + + if (key.length !== KEY_SIZE) { + throw new Error(`Invalid key length: expected ${KEY_SIZE}, got ${key.length}`); + } +} + +function validateNonce(nonce: Uint8Array): void { + if (!(nonce instanceof Uint8Array)) { + throw new Error('Nonce must be a Uint8Array'); + } + + if (nonce.length !== NONCE_SIZE) { + throw new Error(`Invalid nonce length: expected ${NONCE_SIZE}, got ${nonce.length}`); + } +} + +/** + * Generates a random encryption key + */ +export function generateKey(): Uint8Array { + return randomBytes(KEY_SIZE); +} + +/** + * Generates a random nonce + */ +export function generateNonce(): Uint8Array { + return randomBytes(NONCE_SIZE); +} + +/** + * Securely clears a key from memory + */ +export function clearKey(key: Uint8Array): void { + key.fill(0); +} + +/** + * Checks if encrypted data appears valid (basic structure check) + */ +export function isValidEncryptedData(data: EncryptedData): boolean { + return ( + data.nonce instanceof Uint8Array && + data.nonce.length === NONCE_SIZE && + data.ciphertext instanceof Uint8Array && + data.ciphertext.length >= TAG_SIZE + ); +} diff --git a/sdk/src/vault/crypto/hkdf.ts b/sdk/src/vault/crypto/hkdf.ts new file mode 100644 index 0000000..f2ba542 --- /dev/null +++ b/sdk/src/vault/crypto/hkdf.ts @@ -0,0 +1,42 @@ +/** + * HKDF Key Derivation + * + * Derives deterministic sub-keys from a master secret using HKDF-SHA256 (RFC 5869). + */ + +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha256'; + +/** Default output length in bytes (256 bits) */ +const DEFAULT_KEY_LENGTH = 32; + +/** Maximum allowed output length (255 * SHA-256 output = 8160 bytes) */ +const MAX_KEY_LENGTH = 255 * 32; + +/** + * Derives a sub-key from input key material using HKDF-SHA256. + * + * @param ikm - Input key material (e.g., wallet private key). MUST be high-entropy. + * @param salt - Domain separation salt. Can be a string or bytes. + * @param info - Context-specific info. Can be a string or bytes. + * @param length - Output key length in bytes (default: 32). + * @returns Derived key as Uint8Array. Caller MUST zero this after use. + */ +export function deriveKeyHKDF( + ikm: Uint8Array, + salt: string | Uint8Array, + info: string | Uint8Array, + length: number = DEFAULT_KEY_LENGTH, +): Uint8Array { + if (!ikm || ikm.length === 0) { + throw new Error('HKDF: input key material must not be empty'); + } + if (length <= 0 || length > MAX_KEY_LENGTH) { + throw new Error(`HKDF: output length must be between 1 and ${MAX_KEY_LENGTH}`); + } + + const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt; + const infoBytes = typeof info === 'string' ? new TextEncoder().encode(info) : info; + + return hkdf(sha256, ikm, saltBytes, infoBytes, length); +} diff --git a/sdk/src/vault/crypto/index.ts b/sdk/src/vault/crypto/index.ts new file mode 100644 index 0000000..1ef3ceb --- /dev/null +++ b/sdk/src/vault/crypto/index.ts @@ -0,0 +1,27 @@ +export { + encrypt, + decrypt, + encryptString, + decryptString, + serialize, + deserialize, + encryptAndSerialize, + deserializeAndDecrypt, + toHex, + fromHex, + toBase64, + fromBase64, + generateKey, + generateNonce, + clearKey, + isValidEncryptedData, + KEY_SIZE, + NONCE_SIZE, + TAG_SIZE, +} from './aes'; +export type { EncryptedData, SerializedEncryptedData } from './aes'; + +export { deriveKeyHKDF } from './hkdf'; + +export { split as shamirSplit, combine as shamirCombine } from './shamir'; +export type { Share as ShamirShare } from './shamir'; diff --git a/sdk/src/vault/crypto/shamir.ts b/sdk/src/vault/crypto/shamir.ts new file mode 100644 index 0000000..63a2c3a --- /dev/null +++ b/sdk/src/vault/crypto/shamir.ts @@ -0,0 +1,173 @@ +/** + * Shamir's Secret Sharing over GF(2^8) + * + * Information-theoretic secret splitting: any K shares reconstruct the secret, + * K-1 shares reveal zero information. + * + * Uses GF(2^8) with irreducible polynomial x^8 + x^4 + x^3 + x + 1 (0x11B), + * same as AES. This is the standard choice for byte-level SSS. + */ + +import { randomBytes } from '@noble/ciphers/webcrypto'; + +// ── GF(2^8) Arithmetic ───────────────────────────────────────────────────── + +const IRREDUCIBLE = 0x11b; + +/** Exponential table: exp[log[a] + log[b]] = a * b */ +const EXP_TABLE = new Uint8Array(512); + +/** Logarithm table: log[a] for a in 1..255 (log[0] is undefined) */ +const LOG_TABLE = new Uint8Array(256); + +// Build log/exp tables using generator 3 +(function buildTables() { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP_TABLE[i] = x; + LOG_TABLE[x] = i; + x = x ^ (x << 1); // multiply by generator (3 is primitive in this field) + if (x >= 256) x ^= IRREDUCIBLE; + } + // Extend exp table for easy modular arithmetic (avoid mod 255) + for (let i = 255; i < 512; i++) { + EXP_TABLE[i] = EXP_TABLE[i - 255]!; + } +})(); + +/** GF(2^8) addition: XOR */ +function gfAdd(a: number, b: number): number { + return a ^ b; +} + +/** GF(2^8) multiplication via log/exp tables */ +function gfMul(a: number, b: number): number { + if (a === 0 || b === 0) return 0; + return EXP_TABLE[LOG_TABLE[a]! + LOG_TABLE[b]!]!; +} + +/** GF(2^8) multiplicative inverse */ +function gfInv(a: number): number { + if (a === 0) throw new Error('GF(2^8): division by zero'); + return EXP_TABLE[255 - LOG_TABLE[a]!]!; +} + +/** GF(2^8) division: a / b */ +function gfDiv(a: number, b: number): number { + if (b === 0) throw new Error('GF(2^8): division by zero'); + if (a === 0) return 0; + return EXP_TABLE[(LOG_TABLE[a]! - LOG_TABLE[b]! + 255) % 255]!; +} + +// ── Share Type ────────────────────────────────────────────────────────────── + +/** A single Shamir share */ +export interface Share { + /** Share index (1..N, never 0) */ + x: number; + /** Share data (same length as secret) */ + y: Uint8Array; +} + +// ── Split ─────────────────────────────────────────────────────────────────── + +/** + * Splits a secret into N shares with threshold K. + * + * @param secret - Secret bytes to split (any length) + * @param n - Total number of shares to create (2..255) + * @param k - Minimum shares needed for reconstruction (2..n) + * @returns Array of N shares + */ +export function split(secret: Uint8Array, n: number, k: number): Share[] { + if (k < 2) throw new Error('Threshold K must be at least 2'); + if (n < k) throw new Error('Share count N must be >= threshold K'); + if (n > 255) throw new Error('Maximum 255 shares (GF(2^8) limit)'); + if (secret.length === 0) throw new Error('Secret must not be empty'); + + const coefficients = new Array(secret.length); + for (let i = 0; i < secret.length; i++) { + const poly = new Uint8Array(k); + poly[0] = secret[i]!; + const rand = randomBytes(k - 1); + poly.set(rand, 1); + coefficients[i] = poly; + } + + const shares: Share[] = []; + for (let xi = 1; xi <= n; xi++) { + const y = new Uint8Array(secret.length); + for (let byteIdx = 0; byteIdx < secret.length; byteIdx++) { + y[byteIdx] = evaluatePolynomial(coefficients[byteIdx]!, xi); + } + shares.push({ x: xi, y }); + } + + for (const poly of coefficients) { + poly.fill(0); + } + + return shares; +} + +function evaluatePolynomial(coeffs: Uint8Array, x: number): number { + let result = 0; + for (let i = coeffs.length - 1; i >= 0; i--) { + result = gfAdd(gfMul(result, x), coeffs[i]!); + } + return result; +} + +// ── Combine ───────────────────────────────────────────────────────────────── + +/** + * Reconstructs a secret from K or more shares using Lagrange interpolation. + * + * @param shares - Array of K or more shares (must all have same y.length) + * @returns Reconstructed secret + */ +export function combine(shares: Share[]): Uint8Array { + if (shares.length < 2) throw new Error('Need at least 2 shares'); + + const secretLength = shares[0]!.y.length; + for (const share of shares) { + if (share.y.length !== secretLength) { + throw new Error('All shares must have the same data length'); + } + if (share.x === 0) { + throw new Error('Share index must not be 0'); + } + } + + const xValues = new Set(shares.map(s => s.x)); + if (xValues.size !== shares.length) { + throw new Error('Duplicate share indices'); + } + + const secret = new Uint8Array(secretLength); + + for (let byteIdx = 0; byteIdx < secretLength; byteIdx++) { + let value = 0; + + for (let i = 0; i < shares.length; i++) { + const xi = shares[i]!.x; + const yi = shares[i]!.y[byteIdx]!; + + let basis = 1; + for (let j = 0; j < shares.length; j++) { + if (i === j) continue; + const xj = shares[j]!.x; + basis = gfMul(basis, gfDiv(xj, gfAdd(xi, xj))); + } + + value = gfAdd(value, gfMul(yi, basis)); + } + + secret[byteIdx] = value; + } + + return secret; +} + +/** @internal Exported for cross-platform test vector validation */ +export const _gf = { add: gfAdd, mul: gfMul, inv: gfInv, div: gfDiv, EXP_TABLE, LOG_TABLE } as const; diff --git a/sdk/src/vault/index.ts b/sdk/src/vault/index.ts new file mode 100644 index 0000000..b0eecdc --- /dev/null +++ b/sdk/src/vault/index.ts @@ -0,0 +1,65 @@ +// High-level vault client +export { VaultClient } from './client'; +export { adaptiveThreshold, writeQuorum } from './quorum'; +export type { + VaultConfig, + SecretMeta, + StoreResult, + RetrieveResult, + ListResult, + DeleteResult, + GuardianResult, +} from './types'; + +// Vault auth (renamed to avoid collision with top-level AuthClient) +export { AuthClient as VaultAuthClient } from './auth'; + +// Transport (guardian communication) +export { GuardianClient, GuardianError } from './transport'; +export { fanOut, fanOutIndexed, withTimeout, withRetry } from './transport'; +export type { + GuardianEndpoint, + GuardianErrorCode, + GuardianInfo, + HealthResponse as GuardianHealthResponse, + StatusResponse as GuardianStatusResponse, + PushResponse, + PullResponse, + StoreSecretResponse, + GetSecretResponse, + DeleteSecretResponse, + ListSecretsResponse, + SecretEntry, + ChallengeResponse as GuardianChallengeResponse, + SessionResponse as GuardianSessionResponse, + FanOutResult, +} from './transport'; + +// Crypto primitives +export { + encrypt, + decrypt, + encryptString, + decryptString, + serialize as serializeEncrypted, + deserialize as deserializeEncrypted, + encryptAndSerialize, + deserializeAndDecrypt, + toHex as encryptedToHex, + fromHex as encryptedFromHex, + toBase64 as encryptedToBase64, + fromBase64 as encryptedFromBase64, + generateKey, + generateNonce, + clearKey, + isValidEncryptedData, + KEY_SIZE, + NONCE_SIZE, + TAG_SIZE, +} from './crypto'; +export type { EncryptedData, SerializedEncryptedData } from './crypto'; + +export { deriveKeyHKDF } from './crypto'; + +export { shamirSplit, shamirCombine } from './crypto'; +export type { ShamirShare } from './crypto'; diff --git a/sdk/src/vault/quorum.ts b/sdk/src/vault/quorum.ts new file mode 100644 index 0000000..f621c33 --- /dev/null +++ b/sdk/src/vault/quorum.ts @@ -0,0 +1,16 @@ +/** + * Quorum calculations for distributed vault operations. + * Must match orama-vault (Zig side). + */ + +/** Adaptive Shamir threshold: max(3, floor(N/3)). */ +export function adaptiveThreshold(n: number): number { + return Math.max(3, Math.floor(n / 3)); +} + +/** Write quorum: ceil(2N/3). Requires majority for consistency. */ +export function writeQuorum(n: number): number { + if (n === 0) return 0; + if (n <= 2) return n; + return Math.ceil((2 * n) / 3); +} diff --git a/sdk/src/vault/transport/fanout.ts b/sdk/src/vault/transport/fanout.ts new file mode 100644 index 0000000..fce3786 --- /dev/null +++ b/sdk/src/vault/transport/fanout.ts @@ -0,0 +1,94 @@ +import { GuardianClient, GuardianError } from './guardian'; +import type { GuardianEndpoint, GuardianErrorCode, FanOutResult } from './types'; + +/** + * Fan out an operation to multiple guardians in parallel. + * Returns results from all guardians (both successes and failures). + */ +export async function fanOut( + guardians: GuardianEndpoint[], + operation: (client: GuardianClient) => Promise, +): Promise[]> { + const results = await Promise.allSettled( + guardians.map(async (endpoint) => { + const client = new GuardianClient(endpoint); + const result = await operation(client); + return { endpoint, result, error: null } as FanOutResult; + }), + ); + + return results.map((r, i) => { + if (r.status === 'fulfilled') return r.value; + const reason = r.reason as Error; + const errorCode: GuardianErrorCode | undefined = reason instanceof GuardianError ? reason.code : undefined; + return { + endpoint: guardians[i]!, + result: null, + error: reason.message, + errorCode, + }; + }); +} + +/** + * Fan out an indexed operation to multiple guardians in parallel. + * The operation receives the index so each guardian can get a different share. + */ +export async function fanOutIndexed( + guardians: GuardianEndpoint[], + operation: (client: GuardianClient, index: number) => Promise, +): Promise[]> { + const results = await Promise.allSettled( + guardians.map(async (endpoint, i) => { + const client = new GuardianClient(endpoint); + const result = await operation(client, i); + return { endpoint, result, error: null } as FanOutResult; + }), + ); + + return results.map((r, i) => { + if (r.status === 'fulfilled') return r.value; + const reason = r.reason as Error; + const errorCode: GuardianErrorCode | undefined = reason instanceof GuardianError ? reason.code : undefined; + return { + endpoint: guardians[i]!, + result: null, + error: reason.message, + errorCode, + }; + }); +} + +/** + * Race a promise against a timeout. + */ +export function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms), + ), + ]); +} + +/** + * Retry a function with exponential backoff. + * Does not retry auth or not-found errors. + */ +export async function withRetry(fn: () => Promise, attempts = 3): Promise { + let lastError: Error | undefined; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + lastError = err as Error; + if (err instanceof GuardianError && (err.code === 'AUTH' || err.code === 'NOT_FOUND')) { + throw err; + } + if (i < attempts - 1) { + await new Promise((r) => setTimeout(r, 200 * Math.pow(2, i))); + } + } + } + throw lastError!; +} diff --git a/sdk/src/vault/transport/guardian.ts b/sdk/src/vault/transport/guardian.ts new file mode 100644 index 0000000..66b06e7 --- /dev/null +++ b/sdk/src/vault/transport/guardian.ts @@ -0,0 +1,285 @@ +import type { + GuardianEndpoint, + GuardianErrorCode, + GuardianErrorBody, + HealthResponse, + StatusResponse, + GuardianInfo, + PushResponse, + PullResponse, + StoreSecretResponse, + GetSecretResponse, + DeleteSecretResponse, + ListSecretsResponse, + ChallengeResponse, + SessionResponse, +} from './types'; + +export class GuardianError extends Error { + constructor(public readonly code: GuardianErrorCode, message: string) { + super(message); + this.name = 'GuardianError'; + } +} + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * HTTP client for a single orama-vault guardian node. + * Supports V1 (push/pull) and V2 (CRUD secrets) endpoints. + */ +export class GuardianClient { + private baseUrl: string; + private timeoutMs: number; + private sessionToken: string | null = null; + + constructor(endpoint: GuardianEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS) { + this.baseUrl = `http://${endpoint.address}:${endpoint.port}`; + this.timeoutMs = timeoutMs; + } + + /** Set a session token for authenticated V2 requests. */ + setSessionToken(token: string): void { + this.sessionToken = token; + } + + /** Get the current session token. */ + getSessionToken(): string | null { + return this.sessionToken; + } + + /** Clear the session token. */ + clearSessionToken(): void { + this.sessionToken = null; + } + + // ── V1 endpoints ──────────────────────────────────────────────────── + + /** GET /v1/vault/health */ + async health(): Promise { + return this.get('/v1/vault/health'); + } + + /** GET /v1/vault/status */ + async status(): Promise { + return this.get('/v1/vault/status'); + } + + /** GET /v1/vault/guardians */ + async guardians(): Promise { + return this.get('/v1/vault/guardians'); + } + + /** POST /v1/vault/push — store a share (V1). */ + async push(identity: string, share: Uint8Array): Promise { + return this.post('/v1/vault/push', { + identity, + share: uint8ToBase64(share), + }); + } + + /** POST /v1/vault/pull — retrieve a share (V1). */ + async pull(identity: string): Promise { + const resp = await this.post('/v1/vault/pull', { identity }); + return base64ToUint8(resp.share); + } + + /** Check if this guardian is reachable. */ + async isReachable(): Promise { + try { + await this.health(); + return true; + } catch { + return false; + } + } + + // ── V2 auth endpoints ─────────────────────────────────────────────── + + /** POST /v2/vault/auth/challenge — request an auth challenge. */ + async requestChallenge(identity: string): Promise { + return this.post('/v2/vault/auth/challenge', { identity }); + } + + /** POST /v2/vault/auth/session — exchange challenge for session token. */ + async createSession(identity: string, nonce: string, created_ns: number, tag: string): Promise { + return this.post('/v2/vault/auth/session', { + identity, + nonce, + created_ns, + tag, + }); + } + + // ── V2 secrets CRUD ───────────────────────────────────────────────── + + /** PUT /v2/vault/secrets/{name} — store a secret. Requires session token. */ + async putSecret(name: string, share: Uint8Array, version: number): Promise { + return this.authedRequest('PUT', `/v2/vault/secrets/${encodeURIComponent(name)}`, { + share: uint8ToBase64(share), + version, + }); + } + + /** GET /v2/vault/secrets/{name} — retrieve a secret. Requires session token. */ + async getSecret(name: string): Promise<{ share: Uint8Array; name: string; version: number; created_ns: number; updated_ns: number }> { + const resp = await this.authedRequest('GET', `/v2/vault/secrets/${encodeURIComponent(name)}`); + return { + share: base64ToUint8(resp.share), + name: resp.name, + version: resp.version, + created_ns: resp.created_ns, + updated_ns: resp.updated_ns, + }; + } + + /** DELETE /v2/vault/secrets/{name} — delete a secret. Requires session token. */ + async deleteSecret(name: string): Promise { + return this.authedRequest('DELETE', `/v2/vault/secrets/${encodeURIComponent(name)}`); + } + + /** GET /v2/vault/secrets — list all secrets. Requires session token. */ + async listSecrets(): Promise { + return this.authedRequest('GET', '/v2/vault/secrets'); + } + + // ── Internal HTTP methods ─────────────────────────────────────────── + + private async authedRequest(method: string, path: string, body?: unknown): Promise { + if (!this.sessionToken) { + throw new GuardianError('AUTH', 'No session token set. Call authenticate() first.'); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const headers: Record = { + 'X-Session-Token': this.sessionToken, + }; + const init: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + const resp = await fetch(`${this.baseUrl}${path}`, init); + + if (!resp.ok) { + const errBody = (await resp.json().catch(() => ({}))) as GuardianErrorBody; + const msg = errBody.error || `HTTP ${resp.status}`; + throw new GuardianError(classifyHttpStatus(resp.status), msg); + } + + return (await resp.json()) as T; + } catch (err) { + throw classifyError(err); + } finally { + clearTimeout(timeout); + } + } + + private async get(path: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const resp = await fetch(`${this.baseUrl}${path}`, { + method: 'GET', + signal: controller.signal, + }); + + if (!resp.ok) { + const body = (await resp.json().catch(() => ({}))) as GuardianErrorBody; + const msg = body.error || `HTTP ${resp.status}`; + throw new GuardianError(classifyHttpStatus(resp.status), msg); + } + + return (await resp.json()) as T; + } catch (err) { + throw classifyError(err); + } finally { + clearTimeout(timeout); + } + } + + private async post(path: string, body: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const resp = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!resp.ok) { + const errBody = (await resp.json().catch(() => ({}))) as GuardianErrorBody; + const msg = errBody.error || `HTTP ${resp.status}`; + throw new GuardianError(classifyHttpStatus(resp.status), msg); + } + + return (await resp.json()) as T; + } catch (err) { + throw classifyError(err); + } finally { + clearTimeout(timeout); + } + } +} + +// ── Error classification ────────────────────────────────────────────────── + +function classifyHttpStatus(status: number): GuardianErrorCode { + if (status === 404) return 'NOT_FOUND'; + if (status === 401 || status === 403) return 'AUTH'; + if (status === 409) return 'CONFLICT'; + if (status >= 500) return 'SERVER_ERROR'; + return 'NETWORK'; +} + +function classifyError(err: unknown): GuardianError { + if (err instanceof GuardianError) return err; + if (err instanceof Error) { + if (err.name === 'AbortError') { + return new GuardianError('TIMEOUT', `Request timed out: ${err.message}`); + } + if (err.name === 'TypeError' || err.message.includes('fetch')) { + return new GuardianError('NETWORK', `Network error: ${err.message}`); + } + return new GuardianError('NETWORK', err.message); + } + return new GuardianError('NETWORK', String(err)); +} + +// ── Base64 helpers ──────────────────────────────────────────────────────── + +function uint8ToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64'); + } + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} + +function base64ToUint8(b64: string): Uint8Array { + if (typeof Buffer !== 'undefined') { + return new Uint8Array(Buffer.from(b64, 'base64')); + } + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/sdk/src/vault/transport/index.ts b/sdk/src/vault/transport/index.ts new file mode 100644 index 0000000..ebdcfeb --- /dev/null +++ b/sdk/src/vault/transport/index.ts @@ -0,0 +1,19 @@ +export { GuardianClient, GuardianError } from './guardian'; +export { fanOut, fanOutIndexed, withTimeout, withRetry } from './fanout'; +export type { + GuardianEndpoint, + GuardianErrorCode, + GuardianInfo, + HealthResponse, + StatusResponse, + PushResponse, + PullResponse, + StoreSecretResponse, + GetSecretResponse, + DeleteSecretResponse, + ListSecretsResponse, + SecretEntry, + ChallengeResponse, + SessionResponse, + FanOutResult, +} from './types'; diff --git a/sdk/src/vault/transport/types.ts b/sdk/src/vault/transport/types.ts new file mode 100644 index 0000000..cb740b5 --- /dev/null +++ b/sdk/src/vault/transport/types.ts @@ -0,0 +1,101 @@ +/** A guardian node endpoint. */ +export interface GuardianEndpoint { + address: string; + port: number; +} + +/** V1 push response. */ +export interface PushResponse { + status: string; +} + +/** V1 pull response. */ +export interface PullResponse { + share: string; // base64 +} + +/** V2 store response. */ +export interface StoreSecretResponse { + status: string; + name: string; + version: number; +} + +/** V2 get response. */ +export interface GetSecretResponse { + share: string; // base64 + name: string; + version: number; + created_ns: number; + updated_ns: number; +} + +/** V2 delete response. */ +export interface DeleteSecretResponse { + status: string; + name: string; +} + +/** V2 list response. */ +export interface ListSecretsResponse { + secrets: SecretEntry[]; +} + +/** An entry in the list secrets response. */ +export interface SecretEntry { + name: string; + version: number; + size: number; +} + +/** Health check response. */ +export interface HealthResponse { + status: string; + version: string; +} + +/** Status response. */ +export interface StatusResponse { + status: string; + version: string; + data_dir: string; + client_port: number; + peer_port: number; +} + +/** Guardian info response. */ +export interface GuardianInfo { + guardians: Array<{ address: string; port: number }>; + threshold: number; + total: number; +} + +/** Challenge response from auth endpoint. */ +export interface ChallengeResponse { + nonce: string; + created_ns: number; + tag: string; +} + +/** Session token response from auth endpoint. */ +export interface SessionResponse { + identity: string; + expiry_ns: number; + tag: string; +} + +/** Error body from guardian. */ +export interface GuardianErrorBody { + error: string; +} + +/** Error classification codes. */ +export type GuardianErrorCode = 'TIMEOUT' | 'NOT_FOUND' | 'AUTH' | 'SERVER_ERROR' | 'NETWORK' | 'CONFLICT'; + +/** Fan-out result for a single guardian. */ +export interface FanOutResult { + endpoint: GuardianEndpoint; + result: T | null; + error: string | null; + errorCode?: GuardianErrorCode; +} diff --git a/sdk/src/vault/types.ts b/sdk/src/vault/types.ts new file mode 100644 index 0000000..febb30d --- /dev/null +++ b/sdk/src/vault/types.ts @@ -0,0 +1,62 @@ +import type { GuardianEndpoint } from './transport/types'; + +/** Configuration for VaultClient. */ +export interface VaultConfig { + /** Guardian endpoints to connect to. */ + guardians: GuardianEndpoint[]; + /** HMAC key for authentication (derived from user's secret). */ + hmacKey: Uint8Array; + /** Identity hash (hex string, 64 chars). */ + identityHex: string; + /** Request timeout in ms (default: 10000). */ + timeoutMs?: number; +} + +/** Metadata for a stored secret. */ +export interface SecretMeta { + name: string; + version: number; + size: number; +} + +/** Result of a store operation. */ +export interface StoreResult { + /** Number of guardians that acknowledged. */ + ackCount: number; + /** Total guardians contacted. */ + totalContacted: number; + /** Number of failures. */ + failCount: number; + /** Whether write quorum was met. */ + quorumMet: boolean; + /** Per-guardian results. */ + guardianResults: GuardianResult[]; +} + +/** Result of a retrieve operation. */ +export interface RetrieveResult { + /** The reconstructed secret data. */ + data: Uint8Array; + /** Number of shares collected. */ + sharesCollected: number; +} + +/** Result of a list operation. */ +export interface ListResult { + secrets: SecretMeta[]; +} + +/** Result of a delete operation. */ +export interface DeleteResult { + /** Number of guardians that acknowledged. */ + ackCount: number; + totalContacted: number; + quorumMet: boolean; +} + +/** Per-guardian operation result. */ +export interface GuardianResult { + endpoint: string; + success: boolean; + error?: string; +} diff --git a/sdk/tests/e2e/auth.test.ts b/sdk/tests/e2e/auth.test.ts new file mode 100644 index 0000000..07dc15b --- /dev/null +++ b/sdk/tests/e2e/auth.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createTestClient, skipIfNoGateway } from "./setup"; + +describe("Auth", () => { + beforeAll(() => { + if (skipIfNoGateway()) { + console.log("Skipping auth tests"); + } + }); + + it("should get whoami", async () => { + const client = await createTestClient(); + const whoami = await client.auth.whoami(); + expect(whoami).toBeDefined(); + expect(whoami.authenticated).toBe(true); + }); + + it("should switch API key and JWT", async () => { + const client = await createTestClient(); + + // Set API key + const apiKey = process.env.GATEWAY_API_KEY; + if (apiKey) { + client.auth.setApiKey(apiKey); + expect(client.auth.getToken()).toBe(apiKey); + } + + // Set JWT (even if invalid, should update the token) + const testJwt = "test-jwt-token"; + client.auth.setJwt(testJwt); + expect(client.auth.getToken()).toBe(testJwt); + }); + + it("should handle logout", async () => { + const client = await createTestClient(); + await client.auth.logout(); + expect(client.auth.getToken()).toBeUndefined(); + }); +}); diff --git a/sdk/tests/e2e/cache.test.ts b/sdk/tests/e2e/cache.test.ts new file mode 100644 index 0000000..f2dc61c --- /dev/null +++ b/sdk/tests/e2e/cache.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestClient, skipIfNoGateway } from "./setup"; + +describe("Cache", () => { + if (skipIfNoGateway()) { + console.log("Skipping cache tests - gateway not available"); + return; + } + + const testDMap = "test-cache"; + + beforeEach(async () => { + // Clean up test keys before each test + const client = await createTestClient(); + try { + const keys = await client.cache.scan(testDMap); + for (const key of keys.keys) { + await client.cache.delete(testDMap, key); + } + } catch (err) { + // Ignore errors during cleanup + } + }, 30000); // 30 second timeout for slow SCAN operations + + it("should check cache health", async () => { + const client = await createTestClient(); + const health = await client.cache.health(); + expect(health.status).toBe("ok"); + expect(health.service).toBe("olric"); + }); + + it("should put and get a value", async () => { + const client = await createTestClient(); + const testKey = "test-key-1"; + const testValue = "test-value-1"; + + // Put value + const putResult = await client.cache.put(testDMap, testKey, testValue); + expect(putResult.status).toBe("ok"); + expect(putResult.key).toBe(testKey); + expect(putResult.dmap).toBe(testDMap); + + // Get value + const getResult = await client.cache.get(testDMap, testKey); + expect(getResult).not.toBeNull(); + expect(getResult!.key).toBe(testKey); + expect(getResult!.value).toBe(testValue); + expect(getResult!.dmap).toBe(testDMap); + }); + + it("should put and get complex objects", async () => { + const client = await createTestClient(); + const testKey = "test-key-2"; + const testValue = { + name: "John", + age: 30, + tags: ["developer", "golang"], + }; + + // Put object + await client.cache.put(testDMap, testKey, testValue); + + // Get object + const getResult = await client.cache.get(testDMap, testKey); + expect(getResult).not.toBeNull(); + expect(getResult!.value).toBeDefined(); + expect(getResult!.value.name).toBe(testValue.name); + expect(getResult!.value.age).toBe(testValue.age); + }); + + it("should put value with TTL", async () => { + const client = await createTestClient(); + const testKey = "test-key-ttl"; + const testValue = "ttl-value"; + + // Put with TTL + const putResult = await client.cache.put( + testDMap, + testKey, + testValue, + "5m" + ); + expect(putResult.status).toBe("ok"); + + // Verify value exists + const getResult = await client.cache.get(testDMap, testKey); + expect(getResult).not.toBeNull(); + expect(getResult!.value).toBe(testValue); + }); + + it("should delete a value", async () => { + const client = await createTestClient(); + const testKey = "test-key-delete"; + const testValue = "delete-me"; + + // Put value + await client.cache.put(testDMap, testKey, testValue); + + // Verify it exists + const before = await client.cache.get(testDMap, testKey); + expect(before).not.toBeNull(); + expect(before!.value).toBe(testValue); + + // Delete value + const deleteResult = await client.cache.delete(testDMap, testKey); + expect(deleteResult.status).toBe("ok"); + expect(deleteResult.key).toBe(testKey); + + // Verify it's deleted (should return null, not throw) + const after = await client.cache.get(testDMap, testKey); + expect(after).toBeNull(); + }); + + it("should scan keys", async () => { + const client = await createTestClient(); + + // Put multiple keys + await client.cache.put(testDMap, "key-1", "value-1"); + await client.cache.put(testDMap, "key-2", "value-2"); + await client.cache.put(testDMap, "key-3", "value-3"); + + // Scan all keys + const scanResult = await client.cache.scan(testDMap); + expect(scanResult.count).toBeGreaterThanOrEqual(3); + expect(scanResult.keys).toContain("key-1"); + expect(scanResult.keys).toContain("key-2"); + expect(scanResult.keys).toContain("key-3"); + expect(scanResult.dmap).toBe(testDMap); + }); + + it("should scan keys with regex match", async () => { + const client = await createTestClient(); + + // Put keys with different patterns + await client.cache.put(testDMap, "user-1", "value-1"); + await client.cache.put(testDMap, "user-2", "value-2"); + await client.cache.put(testDMap, "session-1", "value-3"); + + // Scan with regex match + const scanResult = await client.cache.scan(testDMap, "^user-"); + expect(scanResult.count).toBeGreaterThanOrEqual(2); + expect(scanResult.keys).toContain("user-1"); + expect(scanResult.keys).toContain("user-2"); + expect(scanResult.keys).not.toContain("session-1"); + }); + + it("should handle non-existent key gracefully", async () => { + const client = await createTestClient(); + const nonExistentKey = "non-existent-key"; + + // Cache misses should return null, not throw an error + const result = await client.cache.get(testDMap, nonExistentKey); + expect(result).toBeNull(); + }); + + it("should handle empty dmap name", async () => { + const client = await createTestClient(); + + try { + await client.cache.get("", "test-key"); + expect.fail("Expected get to fail with empty dmap"); + } catch (err: any) { + expect(err.message).toBeDefined(); + } + }); +}); diff --git a/sdk/tests/e2e/db.test.ts b/sdk/tests/e2e/db.test.ts new file mode 100644 index 0000000..995211a --- /dev/null +++ b/sdk/tests/e2e/db.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestClient, skipIfNoGateway, generateTableName } from "./setup"; + +describe("Database", () => { + if (skipIfNoGateway()) { + console.log("Skipping database tests"); + } + + let tableName: string; + + beforeEach(() => { + tableName = generateTableName(); + }); + + it("should create a table", async () => { + const client = await createTestClient(); + + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT)` + ); + + // Verify by querying schema + const schema = await client.db.getSchema(); + expect(schema).toBeDefined(); + }); + + it("should insert and query data", async () => { + const client = await createTestClient(); + + // Create table + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT)` + ); + + // Insert data + const result = await client.db.exec( + `INSERT INTO ${tableName} (name, email) VALUES (?, ?)`, + ["Alice", "alice@example.com"] + ); + expect(result.rows_affected).toBeGreaterThan(0); + + // Query data + const rows = await client.db.query( + `SELECT * FROM ${tableName} WHERE email = ?`, + ["alice@example.com"] + ); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe("Alice"); + }); + + it("should use find() and findOne()", async () => { + const client = await createTestClient(); + + // Create table + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT)` + ); + + // Insert data + await client.db.exec( + `INSERT INTO ${tableName} (name, email) VALUES (?, ?)`, + ["Bob", "bob@example.com"] + ); + + // Find one + const bob = await client.db.findOne(tableName, { + email: "bob@example.com", + }); + expect(bob).toBeDefined(); + expect(bob?.name).toBe("Bob"); + + // Find all + const all = await client.db.find(tableName, {}); + expect(all.length).toBeGreaterThan(0); + }); + + it("should use QueryBuilder", async () => { + const client = await createTestClient(); + + // Create table + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY, name TEXT, email TEXT, active INTEGER)` + ); + + // Insert test data + await client.db.exec( + `INSERT INTO ${tableName} (name, email, active) VALUES (?, ?, ?)`, + ["Charlie", "charlie@example.com", 1] + ); + await client.db.exec( + `INSERT INTO ${tableName} (name, email, active) VALUES (?, ?, ?)`, + ["Diana", "diana@example.com", 0] + ); + + // Query with builder + const qb = client.db.createQueryBuilder(tableName); + const active = await qb + .where("active = ?", [1]) + .orderBy("name") + .getMany(); + + expect(active.length).toBeGreaterThan(0); + expect(active[0].name).toBe("Charlie"); + }); + + it("should use Repository for save/remove", async () => { + const client = await createTestClient(); + + // Create table + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)` + ); + + const repo = client.db.repository<{ + id?: number; + name: string; + email: string; + }>(tableName); + + // Save (insert) + const entity = { name: "Eve", email: "eve@example.com" }; + await repo.save(entity); + expect(entity.id).toBeDefined(); + + // Find one + const found = await repo.findOne({ email: "eve@example.com" }); + expect(found).toBeDefined(); + expect(found?.name).toBe("Eve"); + + // Update + if (found) { + found.name = "Eve Updated"; + await repo.save(found); + } + + // Verify update + const updated = await repo.findOne({ id: entity.id }); + expect(updated?.name).toBe("Eve Updated"); + + // Remove + if (updated) { + await repo.remove(updated); + } + + // Verify deletion + const deleted = await repo.findOne({ id: entity.id }); + expect(deleted).toBeNull(); + }); +}); diff --git a/sdk/tests/e2e/network.test.ts b/sdk/tests/e2e/network.test.ts new file mode 100644 index 0000000..d86de1b --- /dev/null +++ b/sdk/tests/e2e/network.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createTestClient, skipIfNoGateway } from "./setup"; + +describe("Network", () => { + beforeAll(() => { + if (skipIfNoGateway()) { + console.log("Skipping network tests"); + } + }); + + it("should check health", async () => { + const client = await createTestClient(); + const healthy = await client.network.health(); + expect(typeof healthy).toBe("boolean"); + }); + + it("should get network status", async () => { + const client = await createTestClient(); + const status = await client.network.status(); + expect(status).toBeDefined(); + expect(typeof status.connected).toBe("boolean"); + expect(typeof status.peer_count).toBe("number"); + }); + + it("should list peers", async () => { + const client = await createTestClient(); + const peers = await client.network.peers(); + expect(Array.isArray(peers)).toBe(true); + }); + + it("should proxy request through Anyone network", async () => { + const client = await createTestClient(); + + // Test with a simple GET request + const response = await client.network.proxyAnon({ + url: "https://httpbin.org/get", + method: "GET", + headers: { + "User-Agent": "DeBros-SDK-Test/1.0", + }, + }); + + expect(response).toBeDefined(); + expect(response.status_code).toBe(200); + expect(response.body).toBeDefined(); + expect(typeof response.body).toBe("string"); + }); + + it("should handle proxy errors gracefully", async () => { + const client = await createTestClient(); + + // Test with invalid URL + await expect( + client.network.proxyAnon({ + url: "http://localhost:1/invalid", + method: "GET", + }) + ).rejects.toThrow(); + }); +}); diff --git a/sdk/tests/e2e/pubsub.test.ts b/sdk/tests/e2e/pubsub.test.ts new file mode 100644 index 0000000..134d713 --- /dev/null +++ b/sdk/tests/e2e/pubsub.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestClient, skipIfNoGateway, delay } from "./setup"; + +describe("PubSub", () => { + if (skipIfNoGateway()) { + console.log("Skipping PubSub tests"); + } + + let topicName: string; + + beforeEach(() => { + topicName = `test_topic_${Date.now()}_${Math.random().toString(36).substring(7)}`; + }); + + it("should get topics list", async () => { + const client = await createTestClient(); + const topics = await client.pubsub.topics(); + expect(Array.isArray(topics)).toBe(true); + }); + + it("should publish a message", async () => { + const client = await createTestClient(); + const testMessage = "Hello from test"; + + // Should not throw + await client.pubsub.publish(topicName, testMessage); + expect(true).toBe(true); + }); + + it("should subscribe and receive published message", async () => { + const client = await createTestClient(); + const testMessage = "Test message"; + let receivedMessage: any = null; + + // Create subscription with handlers + const subscription = await client.pubsub.subscribe(topicName, { + onMessage: (msg) => { + receivedMessage = msg; + }, + onError: (err) => { + console.error("Subscription error:", err); + }, + }); + + // Give subscription a moment to establish + await delay(500); + + // Publish message + await client.pubsub.publish(topicName, testMessage); + + // Wait for message to arrive + await delay(1000); + + // Should have received the message + expect(receivedMessage).toBeDefined(); + expect(receivedMessage?.topic).toBe(topicName); + + // Cleanup + subscription.close(); + }); + + it("should handle subscription events", async () => { + const client = await createTestClient(); + const events: string[] = []; + + const subscription = await client.pubsub.subscribe(topicName, { + onMessage: () => { + events.push("message"); + }, + onError: (err) => { + events.push("error"); + }, + onClose: () => { + events.push("close"); + }, + }); + + // Publish a message + await delay(300); + await client.pubsub.publish(topicName, "test"); + + // Wait for event + await delay(500); + + // Close and check for close event + subscription.close(); + await delay(300); + + expect(events.length).toBeGreaterThanOrEqual(0); + }); + + it("should get presence information", async () => { + const client = await createTestClient(); + const presence = await client.pubsub.getPresence(topicName); + expect(presence.topic).toBe(topicName); + expect(Array.isArray(presence.members)).toBe(true); + expect(typeof presence.count).toBe("number"); + }); + + it("should handle presence events in subscription", async () => { + const client = await createTestClient(); + const joinedMembers: any[] = []; + const leftMembers: any[] = []; + const memberId = "test-user-" + Math.random().toString(36).substring(7); + const meta = { name: "Test User" }; + + const subscription = await client.pubsub.subscribe(topicName, { + presence: { + enabled: true, + memberId, + meta, + onJoin: (member) => joinedMembers.push(member), + onLeave: (member) => leftMembers.push(member), + }, + }); + + expect(subscription.hasPresence()).toBe(true); + + // Wait for join event + await delay(1000); + + // Some gateways might send the self-join event + // Check if we can get presence from subscription + const members = await subscription.getPresence(); + expect(Array.isArray(members)).toBe(true); + + // Cleanup + subscription.close(); + await delay(500); + }); +}); diff --git a/sdk/tests/e2e/setup.ts b/sdk/tests/e2e/setup.ts new file mode 100644 index 0000000..3372123 --- /dev/null +++ b/sdk/tests/e2e/setup.ts @@ -0,0 +1,54 @@ +import { createClient } from "../../src/index"; +import { SDKError } from "../../src/errors"; + +export function getGatewayUrl(): string { + return process.env.GATEWAY_BASE_URL || "http://localhost:6001"; +} + +export function getApiKey(): string | undefined { + return process.env.GATEWAY_API_KEY; +} + +export function getJwt(): string | undefined { + return process.env.GATEWAY_JWT; +} + +export function skipIfNoGateway() { + const url = getGatewayUrl(); + const apiKey = getApiKey(); + + if (!apiKey) { + console.log("Skipping: GATEWAY_API_KEY not set"); + return true; + } + + return false; +} + +export async function createTestClient() { + const client = createClient({ + baseURL: getGatewayUrl(), + apiKey: getApiKey(), + jwt: getJwt(), + }); + + return client; +} + +export function generateTableName(): string { + return `test_${Date.now()}_${Math.random().toString(36).substring(7)}`; +} + +export async function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function isGatewayReady(): Promise { + try { + const client = await createTestClient(); + const healthy = await client.network.health(); + return healthy; + } catch { + return false; + } +} diff --git a/sdk/tests/e2e/storage.test.ts b/sdk/tests/e2e/storage.test.ts new file mode 100644 index 0000000..2adea9c --- /dev/null +++ b/sdk/tests/e2e/storage.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createTestClient, skipIfNoGateway } from "./setup"; + +describe("Storage", () => { + beforeAll(() => { + if (skipIfNoGateway()) { + console.log("Skipping storage tests"); + } + }); + + it("should upload a file", async () => { + const client = await createTestClient(); + const testContent = "Hello, IPFS!"; + const testFile = new File([testContent], "test.txt", { + type: "text/plain", + }); + + const result = await client.storage.upload(testFile); + + expect(result).toBeDefined(); + expect(result.cid).toBeDefined(); + expect(typeof result.cid).toBe("string"); + expect(result.cid.length).toBeGreaterThan(0); + expect(result.name).toBe("test.txt"); + expect(result.size).toBeGreaterThan(0); + }); + + it("should upload a Blob", async () => { + const client = await createTestClient(); + const testContent = "Test blob content"; + const blob = new Blob([testContent], { type: "text/plain" }); + + const result = await client.storage.upload(blob, "blob.txt"); + + expect(result).toBeDefined(); + expect(result.cid).toBeDefined(); + expect(typeof result.cid).toBe("string"); + expect(result.name).toBe("blob.txt"); + }); + + it("should upload ArrayBuffer", async () => { + const client = await createTestClient(); + const testContent = "Test array buffer"; + const buffer = new TextEncoder().encode(testContent).buffer; + + const result = await client.storage.upload(buffer, "buffer.bin"); + + expect(result).toBeDefined(); + expect(result.cid).toBeDefined(); + expect(typeof result.cid).toBe("string"); + }); + + it("should upload Uint8Array", async () => { + const client = await createTestClient(); + const testContent = "Test uint8array"; + const uint8Array = new TextEncoder().encode(testContent); + + const result = await client.storage.upload(uint8Array, "uint8.txt"); + + expect(result).toBeDefined(); + expect(result.cid).toBeDefined(); + expect(typeof result.cid).toBe("string"); + }); + + it("should pin a CID", async () => { + const client = await createTestClient(); + // First upload a file to get a CID + const testContent = "File to pin"; + const testFile = new File([testContent], "pin-test.txt", { + type: "text/plain", + }); + + const uploadResult = await client.storage.upload(testFile); + const cid = uploadResult.cid; + + // Now pin it + const pinResult = await client.storage.pin(cid, "pinned-file"); + + expect(pinResult).toBeDefined(); + expect(pinResult.cid).toBe(cid); + expect(pinResult.name).toBe("pinned-file"); + }); + + it("should get pin status", async () => { + const client = await createTestClient(); + // First upload and pin a file + const testContent = "File for status check"; + const testFile = new File([testContent], "status-test", { + type: "text/plain", + }); + + const uploadResult = await client.storage.upload(testFile); + await client.storage.pin(uploadResult.cid, "status-test"); + + // Wait a bit for pin to propagate + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const status = await client.storage.status(uploadResult.cid); + + expect(status).toBeDefined(); + expect(status.cid).toBe(uploadResult.cid); + expect(status.name).toBe("status-test"); + expect(status.status).toBeDefined(); + expect(typeof status.status).toBe("string"); + expect(status.replication_factor).toBeGreaterThanOrEqual(0); + expect(Array.isArray(status.peers)).toBe(true); + }); + + it("should retrieve content by CID", async () => { + const client = await createTestClient(); + const testContent = "Content to retrieve"; + const testFile = new File([testContent], "retrieve-test.txt", { + type: "text/plain", + }); + + const uploadResult = await client.storage.upload(testFile); + const cid = uploadResult.cid; + + // Wait for IPFS replication across nodes (30 seconds) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get the content back + const stream = await client.storage.get(cid); + + expect(stream).toBeDefined(); + expect(stream instanceof ReadableStream).toBe(true); + + // Read the stream + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let done = false; + + while (!done) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + if (value) { + chunks.push(value); + } + } + + // Combine chunks + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + + const retrievedContent = new TextDecoder().decode(combined); + expect(retrievedContent).toBe(testContent); + }); + + it("should unpin a CID", async () => { + const client = await createTestClient(); + // First upload and pin a file + const testContent = "File to unpin"; + const testFile = new File([testContent], "unpin-test.txt", { + type: "text/plain", + }); + + const uploadResult = await client.storage.upload(testFile); + await client.storage.pin(uploadResult.cid, "unpin-test"); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Unpin it + await expect(client.storage.unpin(uploadResult.cid)).resolves.not.toThrow(); + }); + + it("should handle upload errors gracefully", async () => { + const client = await createTestClient(); + // Try to upload invalid data + const invalidFile = null as any; + + await expect(client.storage.upload(invalidFile)).rejects.toThrow(); + }); + + it("should handle status errors for non-existent CID", async () => { + const client = await createTestClient(); + const fakeCID = "QmInvalidCID123456789"; + + await expect(client.storage.status(fakeCID)).rejects.toThrow(); + }); + + it("should upload large content", async () => { + const client = await createTestClient(); + // Create a larger file (100KB) + const largeContent = "x".repeat(100 * 1024); + const largeFile = new File([largeContent], "large.txt", { + type: "text/plain", + }); + + const result = await client.storage.upload(largeFile); + + expect(result).toBeDefined(); + expect(result.cid).toBeDefined(); + expect(result.size).toBeGreaterThanOrEqual(100 * 1024); + }); + + it("should upload binary content", async () => { + const client = await createTestClient(); + // Create binary data + const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); // PNG header + const blob = new Blob([binaryData], { type: "image/png" }); + + const result = await client.storage.upload(blob, "image.png"); + + expect(result).toBeDefined(); + expect(result.cid).toBeDefined(); + expect(result.name).toBe("image.png"); + }); +}); diff --git a/sdk/tests/e2e/tx.test.ts b/sdk/tests/e2e/tx.test.ts new file mode 100644 index 0000000..9dff025 --- /dev/null +++ b/sdk/tests/e2e/tx.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestClient, skipIfNoGateway, generateTableName } from "./setup"; + +describe("Transactions", () => { + if (skipIfNoGateway()) { + console.log("Skipping transaction tests"); + } + + let tableName: string; + + beforeEach(() => { + tableName = generateTableName(); + }); + + it("should execute transaction with multiple ops", async () => { + const client = await createTestClient(); + + // Create table + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, balance INTEGER)` + ); + + // Execute transaction with multiple operations + const results = await client.db.transaction([ + { + kind: "exec", + sql: `INSERT INTO ${tableName} (name, balance) VALUES (?, ?)`, + args: ["User A", 100], + }, + { + kind: "exec", + sql: `INSERT INTO ${tableName} (name, balance) VALUES (?, ?)`, + args: ["User B", 200], + }, + { + kind: "query", + sql: `SELECT COUNT(*) as count FROM ${tableName}`, + args: [], + }, + ]); + + expect(results).toBeDefined(); + }); + + it("should support query inside transaction", async () => { + const client = await createTestClient(); + + // Create table + await client.db.createTable( + `CREATE TABLE ${tableName} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, value INTEGER)` + ); + + // Pre-insert data + await client.db.exec( + `INSERT INTO ${tableName} (name, value) VALUES (?, ?)`, + ["item1", 10] + ); + + // Transaction with insert and query + const results = await client.db.transaction([ + { + kind: "exec", + sql: `INSERT INTO ${tableName} (name, value) VALUES (?, ?)`, + args: ["item2", 20], + }, + { + kind: "query", + sql: `SELECT SUM(value) as total FROM ${tableName}`, + args: [], + }, + ]); + + expect(results).toBeDefined(); + }); +}); diff --git a/sdk/tests/unit/vault-auth/auth.test.ts b/sdk/tests/unit/vault-auth/auth.test.ts new file mode 100644 index 0000000..0b3f1dc --- /dev/null +++ b/sdk/tests/unit/vault-auth/auth.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { AuthClient } from '../../../src/vault/auth'; + +describe('AuthClient', () => { + it('constructs with identity', () => { + const auth = new AuthClient('a'.repeat(64)); + expect(auth.getIdentityHex()).toBe('a'.repeat(64)); + }); + + it('clearSessions does not throw', () => { + const auth = new AuthClient('b'.repeat(64)); + expect(() => auth.clearSessions()).not.toThrow(); + }); + + it('authenticateAll returns empty for no endpoints', async () => { + const auth = new AuthClient('c'.repeat(64)); + const results = await auth.authenticateAll([]); + expect(results).toEqual([]); + }); +}); diff --git a/sdk/tests/unit/vault-crypto/aes.test.ts b/sdk/tests/unit/vault-crypto/aes.test.ts new file mode 100644 index 0000000..0757a0a --- /dev/null +++ b/sdk/tests/unit/vault-crypto/aes.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { + encrypt, + decrypt, + encryptString, + decryptString, + generateKey, + clearKey, + serialize, + deserialize, + toHex, + fromHex, + toBase64, + fromBase64, + isValidEncryptedData, + KEY_SIZE, + NONCE_SIZE, +} from '../../../src/vault/crypto/aes'; + +describe('AES-256-GCM', () => { + it('encrypt/decrypt round-trip', () => { + const key = generateKey(); + const plaintext = new TextEncoder().encode('hello vault'); + const encrypted = encrypt(plaintext, key); + const decrypted = decrypt(encrypted, key); + expect(decrypted).toEqual(plaintext); + clearKey(key); + }); + + it('encryptString/decryptString round-trip', () => { + const key = generateKey(); + const msg = 'sensitive data 123'; + const encrypted = encryptString(msg, key); + const decrypted = decryptString(encrypted, key); + expect(decrypted).toBe(msg); + clearKey(key); + }); + + it('different keys cannot decrypt', () => { + const key1 = generateKey(); + const key2 = generateKey(); + const encrypted = encrypt(new Uint8Array([1, 2, 3]), key1); + expect(() => decrypt(encrypted, key2)).toThrow(); + clearKey(key1); + clearKey(key2); + }); + + it('nonce is correct size', () => { + const key = generateKey(); + const encrypted = encrypt(new Uint8Array([42]), key); + expect(encrypted.nonce.length).toBe(NONCE_SIZE); + clearKey(key); + }); + + it('rejects invalid key size', () => { + expect(() => encrypt(new Uint8Array([1]), new Uint8Array(16))).toThrow('Invalid key length'); + }); + + it('serialize/deserialize round-trip', () => { + const key = generateKey(); + const encrypted = encrypt(new Uint8Array([1, 2, 3]), key); + const serialized = serialize(encrypted); + const deserialized = deserialize(serialized); + expect(decrypt(deserialized, key)).toEqual(new Uint8Array([1, 2, 3])); + clearKey(key); + }); + + it('toHex/fromHex round-trip', () => { + const key = generateKey(); + const encrypted = encrypt(new Uint8Array([10, 20, 30]), key); + const hex = toHex(encrypted); + const restored = fromHex(hex); + expect(decrypt(restored, key)).toEqual(new Uint8Array([10, 20, 30])); + clearKey(key); + }); + + it('toBase64/fromBase64 round-trip', () => { + const key = generateKey(); + const encrypted = encrypt(new Uint8Array([99]), key); + const b64 = toBase64(encrypted); + const restored = fromBase64(b64); + expect(decrypt(restored, key)).toEqual(new Uint8Array([99])); + clearKey(key); + }); + + it('isValidEncryptedData checks structure', () => { + const key = generateKey(); + const encrypted = encrypt(new Uint8Array([1]), key); + expect(isValidEncryptedData(encrypted)).toBe(true); + expect(isValidEncryptedData({ ciphertext: new Uint8Array(0), nonce: new Uint8Array(0) })).toBe(false); + clearKey(key); + }); + + it('generateKey produces KEY_SIZE bytes', () => { + const key = generateKey(); + expect(key.length).toBe(KEY_SIZE); + clearKey(key); + }); +}); diff --git a/sdk/tests/unit/vault-crypto/hkdf.test.ts b/sdk/tests/unit/vault-crypto/hkdf.test.ts new file mode 100644 index 0000000..f242f59 --- /dev/null +++ b/sdk/tests/unit/vault-crypto/hkdf.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { deriveKeyHKDF } from '../../../src/vault/crypto/hkdf'; + +describe('HKDF Derivation', () => { + it('derives 32-byte key by default', () => { + const ikm = new Uint8Array(32).fill(0xab); + const key = deriveKeyHKDF(ikm, 'test-salt', 'test-info'); + expect(key.length).toBe(32); + }); + + it('same inputs produce same output', () => { + const ikm = new Uint8Array(32).fill(0x42); + const key1 = deriveKeyHKDF(ikm, 'salt', 'info'); + const key2 = deriveKeyHKDF(ikm, 'salt', 'info'); + expect(key1).toEqual(key2); + }); + + it('different salts produce different keys', () => { + const ikm = new Uint8Array(32).fill(0x42); + const key1 = deriveKeyHKDF(ikm, 'salt-a', 'info'); + const key2 = deriveKeyHKDF(ikm, 'salt-b', 'info'); + expect(key1).not.toEqual(key2); + }); + + it('different info produce different keys', () => { + const ikm = new Uint8Array(32).fill(0x42); + const key1 = deriveKeyHKDF(ikm, 'salt', 'info-a'); + const key2 = deriveKeyHKDF(ikm, 'salt', 'info-b'); + expect(key1).not.toEqual(key2); + }); + + it('custom length', () => { + const ikm = new Uint8Array(32).fill(0x42); + const key = deriveKeyHKDF(ikm, 'salt', 'info', 64); + expect(key.length).toBe(64); + }); + + it('throws on empty ikm', () => { + expect(() => deriveKeyHKDF(new Uint8Array(0), 'salt', 'info')).toThrow('must not be empty'); + }); + + it('accepts Uint8Array salt and info', () => { + const ikm = new Uint8Array(32).fill(0xab); + const salt = new Uint8Array([1, 2, 3]); + const info = new Uint8Array([4, 5, 6]); + const key = deriveKeyHKDF(ikm, salt, info); + expect(key.length).toBe(32); + }); +}); diff --git a/sdk/tests/unit/vault-crypto/shamir.test.ts b/sdk/tests/unit/vault-crypto/shamir.test.ts new file mode 100644 index 0000000..d5941ad --- /dev/null +++ b/sdk/tests/unit/vault-crypto/shamir.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { split, combine } from '../../../src/vault/crypto/shamir'; + +describe('Shamir SSS', () => { + it('2-of-3 round-trip', () => { + const secret = new Uint8Array([42]); + const shares = split(secret, 3, 2); + expect(shares).toHaveLength(3); + + const recovered = combine([shares[0]!, shares[1]!]); + expect(recovered).toEqual(secret); + + const recovered2 = combine([shares[0]!, shares[2]!]); + expect(recovered2).toEqual(secret); + }); + + it('3-of-5 multi-byte', () => { + const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const shares = split(secret, 5, 3); + expect(shares).toHaveLength(5); + const recovered = combine([shares[0]!, shares[2]!, shares[4]!]); + expect(recovered).toEqual(secret); + }); + + it('all C(5,3) subsets reconstruct', () => { + const secret = new Uint8Array([42, 137, 255, 0]); + const shares = split(secret, 5, 3); + for (let i = 0; i < 5; i++) { + for (let j = i + 1; j < 5; j++) { + for (let l = j + 1; l < 5; l++) { + const recovered = combine([shares[i]!, shares[j]!, shares[l]!]); + expect(recovered).toEqual(secret); + } + } + } + }); + + it('share indices are 1..N', () => { + const shares = split(new Uint8Array([42]), 5, 3); + expect(shares.map(s => s.x)).toEqual([1, 2, 3, 4, 5]); + }); + + it('throws on K < 2', () => { + expect(() => split(new Uint8Array([1]), 3, 1)).toThrow('Threshold K must be at least 2'); + }); + + it('throws on N < K', () => { + expect(() => split(new Uint8Array([1]), 2, 3)).toThrow('Share count N must be >= threshold K'); + }); + + it('throws on N > 255', () => { + expect(() => split(new Uint8Array([1]), 256, 2)).toThrow('Maximum 255 shares'); + }); + + it('throws on empty secret', () => { + expect(() => split(new Uint8Array(0), 3, 2)).toThrow('Secret must not be empty'); + }); + + it('throws on duplicate shares', () => { + expect(() => combine([ + { x: 1, y: new Uint8Array([1]) }, + { x: 1, y: new Uint8Array([2]) }, + ])).toThrow('Duplicate share indices'); + }); + + it('throws on mismatched lengths', () => { + expect(() => combine([ + { x: 1, y: new Uint8Array([1, 2]) }, + { x: 2, y: new Uint8Array([3]) }, + ])).toThrow('same data length'); + }); + + it('large secret (256 bytes)', () => { + const secret = new Uint8Array(256); + for (let i = 0; i < 256; i++) secret[i] = i; + const shares = split(secret, 10, 5); + const recovered = combine(shares.slice(0, 5)); + expect(recovered).toEqual(secret); + }); +}); diff --git a/sdk/tests/unit/vault-transport/fanout.test.ts b/sdk/tests/unit/vault-transport/fanout.test.ts new file mode 100644 index 0000000..852b9ae --- /dev/null +++ b/sdk/tests/unit/vault-transport/fanout.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { withTimeout } from '../../../src/vault/transport/fanout'; + +describe('withTimeout', () => { + it('resolves when promise completes before timeout', async () => { + const result = await withTimeout(Promise.resolve('ok'), 1000); + expect(result).toBe('ok'); + }); + + it('rejects when timeout expires', async () => { + const slow = new Promise((resolve) => setTimeout(() => resolve('late'), 500)); + await expect(withTimeout(slow, 50)).rejects.toThrow('timeout after 50ms'); + }); + + it('propagates original error', async () => { + const failing = Promise.reject(new Error('original')); + await expect(withTimeout(failing, 1000)).rejects.toThrow('original'); + }); +}); diff --git a/sdk/tests/unit/vault-transport/guardian.test.ts b/sdk/tests/unit/vault-transport/guardian.test.ts new file mode 100644 index 0000000..879e41b --- /dev/null +++ b/sdk/tests/unit/vault-transport/guardian.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { GuardianClient, GuardianError } from '../../../src/vault/transport/guardian'; + +describe('GuardianClient', () => { + it('constructs with endpoint', () => { + const client = new GuardianClient({ address: '127.0.0.1', port: 7500 }); + expect(client).toBeDefined(); + }); + + it('session token management', () => { + const client = new GuardianClient({ address: '127.0.0.1', port: 7500 }); + expect(client.getSessionToken()).toBeNull(); + + client.setSessionToken('test-token'); + expect(client.getSessionToken()).toBe('test-token'); + + client.clearSessionToken(); + expect(client.getSessionToken()).toBeNull(); + }); + + it('GuardianError has code and message', () => { + const err = new GuardianError('TIMEOUT', 'request timed out'); + expect(err.code).toBe('TIMEOUT'); + expect(err.message).toBe('request timed out'); + expect(err.name).toBe('GuardianError'); + expect(err instanceof Error).toBe(true); + }); + + it('putSecret throws without session token', async () => { + const client = new GuardianClient({ address: '127.0.0.1', port: 7500 }); + await expect(client.putSecret('test', new Uint8Array([1]), 1)).rejects.toThrow('No session token'); + }); + + it('getSecret throws without session token', async () => { + const client = new GuardianClient({ address: '127.0.0.1', port: 7500 }); + await expect(client.getSecret('test')).rejects.toThrow('No session token'); + }); + + it('deleteSecret throws without session token', async () => { + const client = new GuardianClient({ address: '127.0.0.1', port: 7500 }); + await expect(client.deleteSecret('test')).rejects.toThrow('No session token'); + }); + + it('listSecrets throws without session token', async () => { + const client = new GuardianClient({ address: '127.0.0.1', port: 7500 }); + await expect(client.listSecrets()).rejects.toThrow('No session token'); + }); +}); diff --git a/sdk/tests/unit/vault/client.test.ts b/sdk/tests/unit/vault/client.test.ts new file mode 100644 index 0000000..0850528 --- /dev/null +++ b/sdk/tests/unit/vault/client.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { VaultClient } from '../../../src/vault/client'; + +describe('VaultClient', () => { + it('constructs with config', () => { + const client = new VaultClient({ + guardians: [{ address: '127.0.0.1', port: 7500 }], + hmacKey: new Uint8Array(32), + identityHex: 'a'.repeat(64), + }); + expect(client).toBeDefined(); + }); + + it('clearSessions does not throw', () => { + const client = new VaultClient({ + guardians: [], + hmacKey: new Uint8Array(32), + identityHex: 'b'.repeat(64), + }); + expect(() => client.clearSessions()).not.toThrow(); + }); +}); diff --git a/sdk/tests/unit/vault/quorum.test.ts b/sdk/tests/unit/vault/quorum.test.ts new file mode 100644 index 0000000..e7913cf --- /dev/null +++ b/sdk/tests/unit/vault/quorum.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { adaptiveThreshold, writeQuorum } from '../../../src/vault/quorum'; + +describe('adaptiveThreshold', () => { + it('returns max(3, floor(N/3))', () => { + expect(adaptiveThreshold(3)).toBe(3); + expect(adaptiveThreshold(9)).toBe(3); + expect(adaptiveThreshold(12)).toBe(4); + expect(adaptiveThreshold(30)).toBe(10); + expect(adaptiveThreshold(100)).toBe(33); + }); + + it('minimum is 3', () => { + for (let n = 0; n <= 9; n++) { + expect(adaptiveThreshold(n)).toBeGreaterThanOrEqual(3); + } + }); + + it('monotonically non-decreasing', () => { + let prev = adaptiveThreshold(0); + for (let n = 1; n <= 255; n++) { + const current = adaptiveThreshold(n); + expect(current).toBeGreaterThanOrEqual(prev); + prev = current; + } + }); +}); + +describe('writeQuorum', () => { + it('returns ceil(2N/3) for N >= 3', () => { + expect(writeQuorum(3)).toBe(2); + expect(writeQuorum(6)).toBe(4); + expect(writeQuorum(10)).toBe(7); + expect(writeQuorum(100)).toBe(67); + }); + + it('returns 0 for N=0', () => { + expect(writeQuorum(0)).toBe(0); + }); + + it('returns N for N <= 2', () => { + expect(writeQuorum(1)).toBe(1); + expect(writeQuorum(2)).toBe(2); + }); + + it('always > N/2 for N >= 3', () => { + for (let n = 3; n <= 255; n++) { + expect(writeQuorum(n)).toBeGreaterThan(n / 2); + } + }); + + it('never exceeds N', () => { + for (let n = 0; n <= 255; n++) { + expect(writeQuorum(n)).toBeLessThanOrEqual(n); + } + }); +}); diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..86c9965 --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/sdk/tsup.config.ts b/sdk/tsup.config.ts new file mode 100644 index 0000000..7139a74 --- /dev/null +++ b/sdk/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + shims: true, + outDir: "dist", +}); diff --git a/sdk/vitest.config.ts b/sdk/vitest.config.ts new file mode 100644 index 0000000..d0c2f88 --- /dev/null +++ b/sdk/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import dotenv from "dotenv"; + +dotenv.config(); + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 30000, + }, +});