mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-27 13:04:12 +00:00
Added orama sdk
This commit is contained in:
parent
1ca779880b
commit
7d5ccc0678
80
.github/workflows/publish-sdk.yml
vendored
Normal file
80
.github/workflows/publish-sdk.yml
vendored
Normal 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
5
.gitignore
vendored
@ -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/
|
||||
|
||||
10
Makefile
10
Makefile
@ -27,6 +27,16 @@ website-dev:
|
||||
website-build:
|
||||
cd website && pnpm build
|
||||
|
||||
# === SDK (TypeScript) ===
|
||||
.PHONY: sdk sdk-build sdk-test
|
||||
sdk: sdk-build
|
||||
|
||||
sdk-build:
|
||||
cd sdk && pnpm install && pnpm build
|
||||
|
||||
sdk-test:
|
||||
cd sdk && pnpm test
|
||||
|
||||
# === Vault (Zig) ===
|
||||
.PHONY: vault vault-build vault-test
|
||||
vault-build:
|
||||
|
||||
@ -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
4
sdk/.env.example
Normal 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
32
sdk/.github/workflows/publish-npm.yml
vendored
Normal 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
18
sdk/.gitignore
vendored
Normal 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
2
sdk/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
@network:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
|
||||
21
sdk/LICENSE
Normal file
21
sdk/LICENSE
Normal 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
170
sdk/QUICKSTART.md
Normal 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
665
sdk/README.md
Normal 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
100
sdk/examples/basic-usage.ts
Normal 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);
|
||||
170
sdk/examples/database-crud.ts
Normal file
170
sdk/examples/database-crud.ts
Normal 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
140
sdk/examples/pubsub-chat.ts
Normal 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
82
sdk/package.json
Normal 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
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
231
sdk/src/auth/client.ts
Normal 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
3
sdk/src/auth/index.ts
Normal 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
62
sdk/src/auth/types.ts
Normal 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
203
sdk/src/cache/client.ts
vendored
Normal 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
14
sdk/src/cache/index.ts
vendored
Normal 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
541
sdk/src/core/http.ts
Normal 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
10
sdk/src/core/index.ts
Normal 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";
|
||||
28
sdk/src/core/interfaces/IAuthStrategy.ts
Normal file
28
sdk/src/core/interfaces/IAuthStrategy.ts
Normal 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;
|
||||
}
|
||||
73
sdk/src/core/interfaces/IHttpTransport.ts
Normal file
73
sdk/src/core/interfaces/IHttpTransport.ts
Normal 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;
|
||||
}
|
||||
20
sdk/src/core/interfaces/IRetryPolicy.ts
Normal file
20
sdk/src/core/interfaces/IRetryPolicy.ts
Normal 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;
|
||||
}
|
||||
60
sdk/src/core/interfaces/IWebSocketClient.ts
Normal file
60
sdk/src/core/interfaces/IWebSocketClient.ts
Normal 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;
|
||||
}
|
||||
4
sdk/src/core/interfaces/index.ts
Normal file
4
sdk/src/core/interfaces/index.ts
Normal 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";
|
||||
108
sdk/src/core/transport/AuthHeaderStrategy.ts
Normal file
108
sdk/src/core/transport/AuthHeaderStrategy.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
116
sdk/src/core/transport/RequestLogger.ts
Normal file
116
sdk/src/core/transport/RequestLogger.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
sdk/src/core/transport/RequestRetryPolicy.ts
Normal file
53
sdk/src/core/transport/RequestRetryPolicy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
sdk/src/core/transport/TLSConfiguration.ts
Normal file
53
sdk/src/core/transport/TLSConfiguration.ts
Normal 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!"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
sdk/src/core/transport/index.ts
Normal file
4
sdk/src/core/transport/index.ts
Normal 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
246
sdk/src/core/ws.ts
Normal 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
126
sdk/src/db/client.ts
Normal 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
13
sdk/src/db/index.ts
Normal 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
111
sdk/src/db/qb.ts
Normal 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
128
sdk/src/db/repository.ts
Normal 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
67
sdk/src/db/types.ts
Normal 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
38
sdk/src/errors.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
62
sdk/src/functions/client.ts
Normal file
62
sdk/src/functions/client.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
sdk/src/functions/index.ts
Normal file
2
sdk/src/functions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FunctionsClient, type FunctionsClientConfig } from "./client";
|
||||
export type { FunctionResponse, SuccessResponse } from "./types";
|
||||
21
sdk/src/functions/types.ts
Normal file
21
sdk/src/functions/types.ts
Normal 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
201
sdk/src/index.ts
Normal 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
119
sdk/src/network/client.ts
Normal 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
7
sdk/src/network/index.ts
Normal 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
361
sdk/src/pubsub/client.ts
Normal 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
12
sdk/src/pubsub/index.ts
Normal 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
46
sdk/src/pubsub/types.ts
Normal 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
272
sdk/src/storage/client.ts
Normal 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
7
sdk/src/storage/index.ts
Normal 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
68
sdk/src/utils/codec.ts
Normal 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
3
sdk/src/utils/index.ts
Normal 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
44
sdk/src/utils/platform.ts
Normal 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
58
sdk/src/utils/retry.ts
Normal 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
98
sdk/src/vault/auth.ts
Normal 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
197
sdk/src/vault/client.ts
Normal 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
271
sdk/src/vault/crypto/aes.ts
Normal 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
|
||||
);
|
||||
}
|
||||
42
sdk/src/vault/crypto/hkdf.ts
Normal file
42
sdk/src/vault/crypto/hkdf.ts
Normal 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);
|
||||
}
|
||||
27
sdk/src/vault/crypto/index.ts
Normal file
27
sdk/src/vault/crypto/index.ts
Normal 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';
|
||||
173
sdk/src/vault/crypto/shamir.ts
Normal file
173
sdk/src/vault/crypto/shamir.ts
Normal 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
65
sdk/src/vault/index.ts
Normal 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
16
sdk/src/vault/quorum.ts
Normal 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);
|
||||
}
|
||||
94
sdk/src/vault/transport/fanout.ts
Normal file
94
sdk/src/vault/transport/fanout.ts
Normal 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!;
|
||||
}
|
||||
285
sdk/src/vault/transport/guardian.ts
Normal file
285
sdk/src/vault/transport/guardian.ts
Normal 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;
|
||||
}
|
||||
19
sdk/src/vault/transport/index.ts
Normal file
19
sdk/src/vault/transport/index.ts
Normal 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';
|
||||
101
sdk/src/vault/transport/types.ts
Normal file
101
sdk/src/vault/transport/types.ts
Normal 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
62
sdk/src/vault/types.ts
Normal 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;
|
||||
}
|
||||
39
sdk/tests/e2e/auth.test.ts
Normal file
39
sdk/tests/e2e/auth.test.ts
Normal 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
166
sdk/tests/e2e/cache.test.ts
Normal 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
149
sdk/tests/e2e/db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
60
sdk/tests/e2e/network.test.ts
Normal file
60
sdk/tests/e2e/network.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
131
sdk/tests/e2e/pubsub.test.ts
Normal file
131
sdk/tests/e2e/pubsub.test.ts
Normal 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
54
sdk/tests/e2e/setup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
214
sdk/tests/e2e/storage.test.ts
Normal file
214
sdk/tests/e2e/storage.test.ts
Normal 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
75
sdk/tests/e2e/tx.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
sdk/tests/unit/vault-auth/auth.test.ts
Normal file
20
sdk/tests/unit/vault-auth/auth.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
99
sdk/tests/unit/vault-crypto/aes.test.ts
Normal file
99
sdk/tests/unit/vault-crypto/aes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
sdk/tests/unit/vault-crypto/hkdf.test.ts
Normal file
49
sdk/tests/unit/vault-crypto/hkdf.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
sdk/tests/unit/vault-crypto/shamir.test.ts
Normal file
80
sdk/tests/unit/vault-crypto/shamir.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
sdk/tests/unit/vault-transport/fanout.test.ts
Normal file
19
sdk/tests/unit/vault-transport/fanout.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
48
sdk/tests/unit/vault-transport/guardian.test.ts
Normal file
48
sdk/tests/unit/vault-transport/guardian.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
sdk/tests/unit/vault/client.test.ts
Normal file
22
sdk/tests/unit/vault/client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
57
sdk/tests/unit/vault/quorum.test.ts
Normal file
57
sdk/tests/unit/vault/quorum.test.ts
Normal 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
22
sdk/tsconfig.json
Normal 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
11
sdk/tsup.config.ts
Normal 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
13
sdk/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user