Added orama sdk

This commit is contained in:
anonpenguin23 2026-03-26 18:40:20 +02:00
parent 1ca779880b
commit 7d5ccc0678
87 changed files with 10950 additions and 0 deletions

80
.github/workflows/publish-sdk.yml vendored Normal file
View File

@ -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 }}"

5
.gitignore vendored
View File

@ -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/

View File

@ -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:

View File

@ -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 |

4
sdk/.env.example Normal file
View File

@ -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)

32
sdk/.github/workflows/publish-npm.yml vendored Normal file
View File

@ -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 }}

18
sdk/.gitignore vendored Normal file
View File

@ -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/

2
sdk/.npmrc Normal file
View File

@ -0,0 +1,2 @@
@network:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

21
sdk/LICENSE Normal file
View File

@ -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.

170
sdk/QUICKSTART.md Normal file
View File

@ -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<T>` | 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<User>("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

665
sdk/README.md Normal file
View File

@ -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<User>("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<WSClientConfig>; // 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<string, any | null> — 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<PushInput, PushOutput>(
"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/).

100
sdk/examples/basic-usage.ts Normal file
View File

@ -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);

View File

@ -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<User>(
'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<User>();
console.log('Active users over 25:', activeUsers);
// Get single user
const singleUser = await client.db
.createQueryBuilder('users')
.where('email = ?', ['charlie@example.com'])
.getOne<User>();
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<User>('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);

140
sdk/examples/pubsub-chat.ts Normal file
View File

@ -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);

82
sdk/package.json Normal file
View File

@ -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"
}
}

3077
sdk/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

231
sdk/src/auth/client.ts Normal file
View File

@ -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<WhoAmI> {
try {
const response = await this.httpClient.get<WhoAmI>("/v1/auth/whoami");
return response;
} catch {
return { authenticated: false };
}
}
async refresh(): Promise<string> {
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<void> {
// 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<void> {
// 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<void> {
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;
}
}

3
sdk/src/auth/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { AuthClient } from "./client";
export type { AuthConfig, WhoAmI, StorageAdapter } from "./types";
export { MemoryStorage, LocalStorageAdapter } from "./types";

62
sdk/src/auth/types.ts Normal file
View File

@ -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<string | null>;
set(key: string, value: string): Promise<void>;
clear(): Promise<void>;
}
export class MemoryStorage implements StorageAdapter {
private storage: Map<string, string> = new Map();
async get(key: string): Promise<string | null> {
return this.storage.get(key) ?? null;
}
async set(key: string, value: string): Promise<void> {
this.storage.set(key, value);
}
async clear(): Promise<void> {
this.storage.clear();
}
}
export class LocalStorageAdapter implements StorageAdapter {
private prefix = "@network/sdk:";
async get(key: string): Promise<string | null> {
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
return globalThis.localStorage.getItem(this.prefix + key);
}
return null;
}
async set(key: string, value: string): Promise<void> {
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
globalThis.localStorage.setItem(this.prefix + key, value);
}
}
async clear(): Promise<void> {
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));
}
}
}

203
sdk/src/cache/client.ts vendored Normal file
View File

@ -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<CacheHealthResponse> {
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<CacheGetResponse | null> {
try {
return await this.httpClient.post<CacheGetResponse>("/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<CachePutResponse> {
return this.httpClient.post<CachePutResponse>("/v1/cache/put", {
dmap,
key,
value,
ttl,
});
}
/**
* Delete a value from cache
*/
async delete(dmap: string, key: string): Promise<CacheDeleteResponse> {
return this.httpClient.post<CacheDeleteResponse>("/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<Map<string, any | null>> {
try {
if (keys.length === 0) {
return new Map();
}
const response = await this.httpClient.post<CacheMultiGetResponse>(
"/v1/cache/mget",
{
dmap,
keys,
}
);
// Convert array to Map
const resultMap = new Map<string, any | null>();
// 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<string, any | null>();
keys.forEach((key) => {
resultMap.set(key, null);
});
return resultMap;
}
// Log and return empty results for other errors
const resultMap = new Map<string, any | null>();
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<CacheScanResponse> {
return this.httpClient.post<CacheScanResponse>("/v1/cache/scan", {
dmap,
match,
});
}
}

14
sdk/src/cache/index.ts vendored Normal file
View File

@ -0,0 +1,14 @@
export { CacheClient } from "./client";
export type {
CacheGetRequest,
CacheGetResponse,
CachePutRequest,
CachePutResponse,
CacheDeleteRequest,
CacheDeleteResponse,
CacheMultiGetRequest,
CacheMultiGetResponse,
CacheScanRequest,
CacheScanResponse,
CacheHealthResponse,
} from "./client";

541
sdk/src/core/http.ts Normal file
View File

@ -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<string, string> {
const headers: Record<string, string> = {};
// 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<T = any>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
options: {
body?: any;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
timeout?: number; // Per-request timeout override
} = {}
): Promise<T> {
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<string, string> = {
"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<any> {
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<T = any>(
path: string,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("GET", path, options);
}
async post<T = any>(
path: string,
body?: any,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("POST", path, { ...options, body });
}
async put<T = any>(
path: string,
body?: any,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("PUT", path, { ...options, body });
}
async delete<T = any>(
path: string,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("DELETE", path, options);
}
/**
* Upload a file using multipart/form-data
* This is a special method for file uploads that bypasses JSON serialization
*/
async uploadFile<T = any>(
path: string,
formData: FormData,
options?: {
timeout?: number;
}
): Promise<T> {
const startTime = performance.now(); // Track upload start time
const url = new URL(this.baseURL + path);
const headers: Record<string, string> = {
...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<Response> {
const url = new URL(this.baseURL + path);
const headers: Record<string, string> = {
...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();
}
}

10
sdk/src/core/index.ts Normal file
View File

@ -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";

View File

@ -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<string, string>;
/**
* Set API key
*/
setApiKey(apiKey?: string): void;
/**
* Set JWT token
*/
setJwt(jwt?: string): void;
}

View File

@ -0,0 +1,73 @@
/**
* HTTP Request options
*/
export interface RequestOptions {
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
timeout?: number;
}
/**
* HTTP Transport abstraction interface
* Provides a testable abstraction layer for HTTP operations
*/
export interface IHttpTransport {
/**
* Perform GET request
*/
get<T = any>(path: string, options?: RequestOptions): Promise<T>;
/**
* Perform POST request
*/
post<T = any>(path: string, body?: any, options?: RequestOptions): Promise<T>;
/**
* Perform PUT request
*/
put<T = any>(path: string, body?: any, options?: RequestOptions): Promise<T>;
/**
* Perform DELETE request
*/
delete<T = any>(path: string, options?: RequestOptions): Promise<T>;
/**
* Upload file using multipart/form-data
*/
uploadFile<T = any>(
path: string,
formData: FormData,
options?: { timeout?: number }
): Promise<T>;
/**
* Get binary response (returns Response object for streaming)
*/
getBinary(path: string): Promise<Response>;
/**
* 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;
}

View File

@ -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;
}

View File

@ -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<void>;
/**
* 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;
}

View File

@ -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";

View File

@ -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<string, string> {
const headers: Record<string, string> = {};
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";
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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!"
);
}
}
}
}

View File

@ -0,0 +1,4 @@
export { PathBasedAuthStrategy } from "./AuthHeaderStrategy";
export { ExponentialBackoffRetryPolicy } from "./RequestRetryPolicy";
export { RequestLogger } from "./RequestLogger";
export { TLSConfiguration } from "./TLSConfiguration";

246
sdk/src/core/ws.ts Normal file
View File

@ -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<WSMessageHandler> = new Set();
private errorHandlers: Set<WSErrorHandler> = new Set();
private closeHandlers: Set<WSCloseHandler> = new Set();
private openHandlers: Set<WSOpenHandler> = 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<void> {
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<string, any> = { 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;
}
}

126
sdk/src/db/client.ts Normal file
View File

@ -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<T = any>(sql: string, args: any[] = []): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/query",
{ sql, args }
);
return response.items || [];
}
/**
* Find rows with map-based criteria.
*/
async find<T = any>(
table: string,
criteria: Record<string, any> = {},
options: FindOptions = {}
): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/find",
{
table,
criteria,
options,
}
);
return response.items || [];
}
/**
* Find a single row with map-based criteria.
*/
async findOne<T = any>(
table: string,
criteria: Record<string, any>
): Promise<T | null> {
return this.httpClient.post<T | null>("/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<T extends Record<string, any>>(
tableName: string,
primaryKey = "id"
): Repository<T> {
return new Repository(this.httpClient, tableName, primaryKey);
}
/**
* Execute multiple operations atomically.
*/
async transaction(
ops: TransactionOp[],
returnResults = true
): Promise<any[]> {
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<void> {
await this.httpClient.post("/v1/rqlite/create-table", { schema });
}
/**
* Drop a table.
*/
async dropTable(table: string): Promise<void> {
await this.httpClient.post("/v1/rqlite/drop-table", { table });
}
/**
* Get current database schema.
*/
async getSchema(): Promise<any> {
return this.httpClient.get("/v1/rqlite/schema");
}
}

13
sdk/src/db/index.ts Normal file
View File

@ -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";

111
sdk/src/db/qb.ts Normal file
View File

@ -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<T = any>(ctx?: any): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/select",
{
table: this.table,
...this.options,
}
);
return response.items || [];
}
async getOne<T = any>(ctx?: any): Promise<T | null> {
const response = await this.httpClient.post<QueryResponse>(
"/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<number> {
const response = await this.httpClient.post<QueryResponse>(
"/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;
}
}

128
sdk/src/db/repository.ts Normal file
View File

@ -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<T extends Record<string, any>> {
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<string, any> = {},
options: FindOptions = {}
): Promise<T[]> {
const response = await this.httpClient.post<QueryResponse>(
"/v1/rqlite/find",
{
table: this.tableName,
criteria,
options,
}
);
return response.items || [];
}
async findOne(criteria: Record<string, any>): Promise<T | null> {
try {
const response = await this.httpClient.post<T | null>(
"/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<T> {
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<string, any>): Promise<void> {
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;
}
}

67
sdk/src/db/types.ts Normal file
View File

@ -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<SelectOptions, "select" | "joins" | "one">;
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;
}

38
sdk/src/errors.ts Normal file
View File

@ -0,0 +1,38 @@
export class SDKError extends Error {
public readonly httpStatus: number;
public readonly code: string;
public readonly details: Record<string, any>;
constructor(
message: string,
httpStatus: number = 500,
code: string = "SDK_ERROR",
details: Record<string, any> = {}
) {
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,
};
}
}

View File

@ -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<TInput = any, TOutput = any>(
functionName: string,
input: TInput
): Promise<TOutput> {
const url = this.gatewayURL
? `${this.gatewayURL}/v1/invoke/${this.namespace}/${functionName}`
: `/v1/invoke/${this.namespace}/${functionName}`;
try {
const response = await this.httpClient.post<TOutput>(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)
);
}
}
}

View File

@ -0,0 +1,2 @@
export { FunctionsClient, type FunctionsClientConfig } from "./client";
export type { FunctionResponse, SuccessResponse } from "./types";

View File

@ -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<T = unknown> {
success: boolean;
error?: string;
data?: T;
}
/**
* Standard success/error response used by many functions
*/
export interface SuccessResponse {
success: boolean;
error?: string;
}

201
sdk/src/index.ts Normal file
View File

@ -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<HttpClientConfig, "fetch"> {
apiKey?: string;
jwt?: string;
storage?: StorageAdapter;
wsConfig?: Partial<Omit<WSClientConfig, "wsURL">>;
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";

119
sdk/src/network/client.ts Normal file
View File

@ -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<string, string>;
body?: string;
}
export interface ProxyResponse {
status_code: number;
headers: Record<string, string>;
body: string;
error?: string;
}
export class NetworkClient {
private httpClient: HttpClient;
constructor(httpClient: HttpClient) {
this.httpClient = httpClient;
}
/**
* Check gateway health.
*/
async health(): Promise<boolean> {
try {
await this.httpClient.get("/v1/health");
return true;
} catch {
return false;
}
}
/**
* Get network status.
*/
async status(): Promise<NetworkStatus> {
const response = await this.httpClient.get<NetworkStatus>(
"/v1/network/status"
);
return response;
}
/**
* Get connected peers.
*/
async peers(): Promise<PeerInfo[]> {
const response = await this.httpClient.get<{ peers: PeerInfo[] }>(
"/v1/network/peers"
);
return response.peers || [];
}
/**
* Connect to a peer.
*/
async connect(peerAddr: string): Promise<void> {
await this.httpClient.post("/v1/network/connect", { peer_addr: peerAddr });
}
/**
* Disconnect from a peer.
*/
async disconnect(peerId: string): Promise<void> {
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<ProxyResponse> {
const response = await this.httpClient.post<ProxyResponse>(
"/v1/proxy/anon",
request
);
// Check if the response contains an error
if (response.error) {
throw new Error(`Proxy request failed: ${response.error}`);
}
return response;
}
}

7
sdk/src/network/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { NetworkClient } from "./client";
export type {
PeerInfo,
NetworkStatus,
ProxyRequest,
ProxyResponse,
} from "./client";

361
sdk/src/pubsub/client.ts Normal file
View File

@ -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<WSClientConfig>;
constructor(httpClient: HttpClient, wsConfig: Partial<WSClientConfig> = {}) {
this.httpClient = httpClient;
this.wsConfig = wsConfig;
}
/**
* Publish a message to a topic via HTTP
*/
async publish(topic: string, data: string | Uint8Array): Promise<void> {
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<string[]> {
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<PresenceResponse> {
const response = await this.httpClient.get<PresenceResponse>(
`/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<Subscription> {
// 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<MessageHandler> = new Set();
private errorHandlers: Set<ErrorHandler> = new Set();
private closeHandlers: Set<CloseHandler> = 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<PresenceResponse>;
constructor(
wsClient: WSClient,
topic: string,
presenceOptions: PresenceOptions | undefined,
getPresenceFn: () => Promise<PresenceResponse>
) {
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<PresenceMember[]> {
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();
}
}

12
sdk/src/pubsub/index.ts Normal file
View File

@ -0,0 +1,12 @@
export { PubSubClient, Subscription } from "./client";
export type {
PubSubMessage,
RawEnvelope,
MessageHandler,
ErrorHandler,
CloseHandler,
PresenceMember,
PresenceResponse,
PresenceOptions,
SubscribeOptions,
} from "./types";

46
sdk/src/pubsub/types.ts Normal file
View File

@ -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<string, unknown>;
}
export interface PresenceMember {
memberId: string;
joinedAt: number;
meta?: Record<string, unknown>;
}
export interface PresenceResponse {
topic: string;
members: PresenceMember[];
count: number;
}
export interface PresenceOptions {
enabled: boolean;
memberId: string;
meta?: Record<string, unknown>;
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;

272
sdk/src/storage/client.ts Normal file
View File

@ -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<Uint8Array>,
name?: string,
options?: {
pin?: boolean;
}
): Promise<StorageUploadResponse> {
// 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<StorageUploadResponse>(
"/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<StoragePinResponse> {
return this.httpClient.post<StoragePinResponse>("/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<StorageStatus> {
return this.httpClient.get<StorageStatus>(`/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<ReadableStream<Uint8Array>> {
// 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<Response> {
// 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<void> {
await this.httpClient.delete(`/v1/storage/unpin/${cid}`);
}
}

7
sdk/src/storage/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { StorageClient } from "./client";
export type {
StorageUploadResponse,
StoragePinRequest,
StoragePinResponse,
StorageStatus,
} from "./client";

68
sdk/src/utils/codec.ts Normal file
View File

@ -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";
}
}

3
sdk/src/utils/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { Base64Codec } from "./codec";
export { retryWithBackoff, type RetryConfig } from "./retry";
export { Platform } from "./platform";

44
sdk/src/utils/platform.ts Normal file
View File

@ -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";
},
};

58
sdk/src/utils/retry.ts Normal file
View File

@ -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<T>(
operation: () => Promise<T>,
config: RetryConfig
): Promise<T> {
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");
}

98
sdk/src/vault/auth.ts Normal file
View File

@ -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: `<identity_hex>:<expiry_ns>:<tag_hex>`
*/
export class AuthClient {
private sessions = new Map<string, { token: string; expiryNs: number }>();
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<GuardianClient> {
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;
}
}

197
sdk/src/vault/client.ts Normal file
View File

@ -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<StoreResult> {
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<RetrieveResult> {
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<ListResult> {
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<DeleteResult> {
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();
}
}

271
sdk/src/vault/crypto/aes.ts Normal file
View File

@ -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
);
}

View File

@ -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);
}

View File

@ -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';

View File

@ -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<Uint8Array>(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;

65
sdk/src/vault/index.ts Normal file
View File

@ -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';

16
sdk/src/vault/quorum.ts Normal file
View File

@ -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);
}

View File

@ -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<T>(
guardians: GuardianEndpoint[],
operation: (client: GuardianClient) => Promise<T>,
): Promise<FanOutResult<T>[]> {
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<T>;
}),
);
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<T>(
guardians: GuardianEndpoint[],
operation: (client: GuardianClient, index: number) => Promise<T>,
): Promise<FanOutResult<T>[]> {
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<T>;
}),
);
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<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, 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<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
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!;
}

View File

@ -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<HealthResponse> {
return this.get<HealthResponse>('/v1/vault/health');
}
/** GET /v1/vault/status */
async status(): Promise<StatusResponse> {
return this.get<StatusResponse>('/v1/vault/status');
}
/** GET /v1/vault/guardians */
async guardians(): Promise<GuardianInfo> {
return this.get<GuardianInfo>('/v1/vault/guardians');
}
/** POST /v1/vault/push — store a share (V1). */
async push(identity: string, share: Uint8Array): Promise<PushResponse> {
return this.post<PushResponse>('/v1/vault/push', {
identity,
share: uint8ToBase64(share),
});
}
/** POST /v1/vault/pull — retrieve a share (V1). */
async pull(identity: string): Promise<Uint8Array> {
const resp = await this.post<PullResponse>('/v1/vault/pull', { identity });
return base64ToUint8(resp.share);
}
/** Check if this guardian is reachable. */
async isReachable(): Promise<boolean> {
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<ChallengeResponse> {
return this.post<ChallengeResponse>('/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<SessionResponse> {
return this.post<SessionResponse>('/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<StoreSecretResponse> {
return this.authedRequest<StoreSecretResponse>('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<GetSecretResponse>('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<DeleteSecretResponse> {
return this.authedRequest<DeleteSecretResponse>('DELETE', `/v2/vault/secrets/${encodeURIComponent(name)}`);
}
/** GET /v2/vault/secrets — list all secrets. Requires session token. */
async listSecrets(): Promise<ListSecretsResponse> {
return this.authedRequest<ListSecretsResponse>('GET', '/v2/vault/secrets');
}
// ── Internal HTTP methods ───────────────────────────────────────────
private async authedRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
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<string, string> = {
'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<T>(path: string): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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;
}

View File

@ -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';

View File

@ -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<T> {
endpoint: GuardianEndpoint;
result: T | null;
error: string | null;
errorCode?: GuardianErrorCode;
}

62
sdk/src/vault/types.ts Normal file
View File

@ -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;
}

View File

@ -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();
});
});

166
sdk/tests/e2e/cache.test.ts Normal file
View File

@ -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();
}
});
});

149
sdk/tests/e2e/db.test.ts Normal file
View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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);
});
});

54
sdk/tests/e2e/setup.ts Normal file
View File

@ -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<boolean> {
try {
const client = await createTestClient();
const healthy = await client.network.health();
return healthy;
} catch {
return false;
}
}

View File

@ -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");
});
});

75
sdk/tests/e2e/tx.test.ts Normal file
View File

@ -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();
});
});

View File

@ -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([]);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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<string>((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');
});
});

View File

@ -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');
});
});

View File

@ -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();
});
});

View File

@ -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);
}
});
});

22
sdk/tsconfig.json Normal file
View File

@ -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"]
}

11
sdk/tsup.config.ts Normal file
View File

@ -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",
});

13
sdk/vitest.config.ts Normal file
View File

@ -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,
},
});