npm package: @seaspark/assets-sdk
Description: SeaVerse Assets API SDK - Unified multi-tenant asset and credit management service
Author: SeaSpark Team
Latest version: 0.1.0

# @seaspark/assets-sdk

SeaVerse Asset Hub SDK - Unified multi-tenant credit management service (4-pool system)

## Overview

Asset Hub SDK provides comprehensive credit management capabilities, supporting a 4-pool credit system (daily/event/monthly/permanent) and traditional credit account queries. All applications (including the SeaVerse platform itself) use the same SDK routing and multi-tenant architecture.

**Main API Endpoints:**
- `/sdk/v1/credits/detail` - Get 4-pool credit details (recommended)
- `/sdk/v1/credits/account` - Get credit account information (deprecated)
- `/sdk/v1/credits/transactions` - Query transaction history

### Core Concepts

#### Application ID
Each application has a unique `app_id`:
- **SeaVerse Platform**: `appId = "seaverse"` (official platform apps like ops-frontend, meta-product)
- **Third-party Apps**: `appId = "game-abc123"`, `appId = "social-xyz789"`, etc.

#### Multi-tenant Isolation
- Credit accounts are isolated by `app_id`
- Each application has its own credit pools and transaction history
- The same user can have different credit accounts across different applications

## Installation

```bash
npm install @seaspark/assets-sdk
# or
pnpm add @seaspark/assets-sdk
```

## Features

- **4-Pool Credit System**: Supports daily/event/monthly/permanent credit pools
- **Expiration Time Tracking**: Each credit pool has independent expiration timestamps
- **Consumption Priority**: Daily → Event → Monthly → Permanent
- Query user credit account information with multi-tenant support
- List transaction history with filtering and pagination
- TypeScript support with complete type definitions
- Read-only API (credit consumption/distribution handled by platform backend)

## Quick Start

```typescript
import { AssetsClient } from '@seaspark/assets-sdk';

// Initialize client for SeaVerse platform
const client = new AssetsClient({
  appId: 'seaverse',
  token: 'your-bearer-token'
});

// Or initialize for third-party application
const gameClient = new AssetsClient({
  appId: 'game-abc123',
  token: 'your-bearer-token'
});

// Get credit details (recommended - 4-pool system)
const detail = await client.getCreditDetail();
console.log(`Total Balance: ${detail.total_balance} credits`);

// Display details for each credit pool
detail.pools.forEach(pool => {
  const expiryLabel = pool.expires_at === 0
    ? 'Never expires'
    : `Expires in ${Math.floor((pool.expires_at - Date.now()) / 86400000)} days`;
  console.log(`${pool.type}: ${pool.balance} (${expiryLabel})`);
});

// List recent transactions
const result = await client.listTransactions({ page_size: 10 });
console.log(`Found ${result.transactions.length} transactions`);
console.log(`Has more: ${result.has_more}`);
```

## 4-Pool Credit System

Asset Hub SDK uses a 4-pool credit system, each with different expiration rules and purposes:

### Credit Pool Types

| Pool Type | Description | Expiration Rules |
|-----------|-------------|------------------|
| `daily` | Daily credits | Expire at 00:00 UTC next day |
| `event` | Event credits | Expire at event deadline |
| `monthly` | Monthly credits | Expire 30 days after last grant |
| `permanent` | General credits | Never expire |

### Consumption Priority

When users consume credits, the system deducts in the following priority:

```
Daily → Event → Monthly → Permanent
```

This ensures credits about to expire are consumed first, maximizing user credit value.

### Usage Example

```typescript
const detail = await client.getCreditDetail();

// Calculate total balance
console.log(`Total: ${detail.total_balance} credits`);

// Iterate through credit pools
for (const pool of detail.pools) {
  const remaining = pool.expires_at === 0
    ? Infinity
    : pool.expires_at - Date.now();

  if (remaining === Infinity) {
    console.log(`${pool.type}: ${pool.balance} credits (Never expires)`);
  } else {
    const days = Math.floor(remaining / 86400000);
    const hours = Math.floor((remaining % 86400000) / 3600000);
    console.log(`${pool.type}: ${pool.balance} credits (Expires in ${days}d ${hours}h)`);
  }
}

// Check for credits expiring soon
const expiringPools = detail.pools.filter(pool => {
  if (pool.expires_at === 0) return false;
  const daysLeft = (pool.expires_at - Date.now()) / 86400000;
  return daysLeft < 3; // Expiring within 3 days
});

if (expiringPools.length > 0) {
  console.log('⚠️ Some credits are expiring soon, please use them!');
  expiringPools.forEach(pool => {
    console.log(`  - ${pool.type}: ${pool.balance} credits`);
  });
}
```

## API Reference

### AssetsClient

#### Constructor

```typescript
new AssetsClient(options: AssetsClientOptions)
```

**Parameters:**
- `appId` (string, required): Application ID for multi-tenant isolation
  - `'seaverse'` for SeaVerse platform
  - `'game-abc123'` for third-party applications
- `token` (string, required): Bearer token for authentication
- `baseURL` (string, optional): Base URL for Asset Hub API
  - Default: `'https://seaspark-wallet-service.dev.seaspark.ai'`
  - <!-- Production: TBD -->
  - Pass empty string `''` to use relative URLs (useful with dev proxy)
- `timeout` (number, optional): Request timeout in milliseconds, default 30000

#### Methods

##### getCreditDetail()

Get credit details (4-pool system) - **Recommended**

Returns detailed information for 4 credit pools, each with balance and expiration timestamp.

**API Endpoint:** `GET /sdk/v1/credits/detail`

**Multi-tenant Behavior:**
- Credit pools belong to the application specified by `appId`
- Each application has independent 4-pool system
- Pool data is isolated by `app_id`

**Request Headers:**
- `Authorization: Bearer <token>` - Required
- `X-App-ID: <app_id>` - Required

```typescript
async getCreditDetail(): Promise<CreditDetailResponse>
```

**Returns:**
```typescript
{
  total_balance: string           // Total balance across all pools (string for precision)
  pools: [                        // Array of credit pools (only pools with balance > 0)
    {
      type: 'daily' | 'event' | 'monthly' | 'permanent'
      balance: string             // Pool balance (string for precision)
      expires_at: number          // Expiration timestamp in milliseconds
                                  // 0 = never expires
                                  // > 0 = specific expiration time
    }
  ]
}
```

**Credit Pool Types:**
- `daily` - Daily credits (expire at 00:00 UTC next day)
- `event` - Event credits (expire at event deadline)
- `monthly` - Monthly credits (expire 30 days after last grant)
- `permanent` - General credits (never expire)

**Response Features:**
- Only returns credit pools with `balance > 0`
- `expires_at` is in milliseconds timestamp (0 means never expires)
- Frontend can calculate remaining time based on timestamp

**Recommended Refresh Frequency:** 30 seconds

**Example:**
```typescript
const detail = await client.getCreditDetail();
console.log(`Total Balance: ${detail.total_balance}`);

detail.pools.forEach(pool => {
  const label = pool.expires_at === 0
    ? 'Never expires'
    : `Expires in ${Math.floor((pool.expires_at - Date.now()) / 86400000)} days`;
  console.log(`${pool.type}: ${pool.balance} (${label})`);
});

// Example output:
// Total Balance: 5240
// daily: 150 (Expires in 0 days)
// event: 500 (Expires in 5 days)
// monthly: 800 (Expires in 28 days)
// permanent: 3790 (Never expires)
```

##### getCreditAccount()

> ⚠️ **Deprecated**: Use `getCreditDetail()` for the new 4-pool credit system.

Get authenticated user's credit account information for the specified application.

**API Endpoint:** `GET /sdk/v1/credits/account`

**Multi-tenant Behavior:**
- Credit account belongs to the application specified by `appId`
- Each application has isolated credit pools
- Account data is isolated by `app_id`

**Request Headers:**
- `Authorization: Bearer <token>` - Required
- `X-App-ID: <app_id>` - Required

```typescript
async getCreditAccount(): Promise<CreditAccount>
```

**Returns:**
```typescript
{
  id: string
  user_id: string
  app_id: string | null           // Application ID (null for platform users)
  balance: number                  // Current available balance
  total_earned: number             // Total credits earned
  total_spent: number              // Total credits spent
  frozen_balance: number           // Frozen balance (unavailable)
  last_transaction_id?: string
  status: 'active' | 'suspended' | 'frozen'
  status_reason?: string           // Only present in non-active states
  created_at: string               // UTC timestamp
  updated_at: string               // UTC timestamp
  last_activity_at?: string        // UTC timestamp
}
```

**Account Status:**
- `active` - Normal, available
- `suspended` - Suspended (contact support)
- `frozen` - Frozen (risk control)

**Notes:**
- Some fields may be `null` or `undefined`, handle null values properly
- Number fields may be returned as strings, SDK automatically handles type conversion
- Use optional chaining operator `?.` to access optional fields

**Example:**
```typescript
const account = await client.getCreditAccount();
console.log(`Balance: ${account.balance ?? 0}`);
console.log(`App: ${account.app_id ?? 'N/A'}`);
console.log(`Status: ${account.status}`);

// Safely access optional fields
if (account.last_activity_at) {
  console.log(`Last Activity: ${account.last_activity_at}`);
}
```

##### listTransactions()

List credit transactions for the specified application, with filtering and pagination support.

**API Endpoint:** `GET /sdk/v1/credits/transactions`

**Multi-tenant Behavior:**
- Transactions are filtered by the application specified by `appId`
- Only returns transactions for the authenticated user in this application
- Transaction history is isolated by `app_id`

**Request Headers:**
- `Authorization: Bearer <token>` - Required
- `X-App-ID: <app_id>` - Required

```typescript
async listTransactions(request?: ListTransactionsRequest): Promise<TransactionListResponse>
```

**Request Parameters:**
- `type` (optional): Filter by transaction type
  - `'earn'` - Credit earned
  - `'spend'` - Credit deducted
  - `'freeze'` - Credit frozen
  - `'unfreeze'` - Credit unfrozen
  - `'refund'` - Refund returned
  - `'adjust'` - Admin adjustment
- `source` (optional): Filter by transaction source (e.g., `'api_call'`, `'purchase'`)
- `status` (optional): Filter by status
  - `'pending'` - Processing
  - `'completed'` - Completed
  - `'failed'` - Failed
  - `'cancelled'` - Cancelled
- `start_date` (optional): Start date filter (RFC3339 format, e.g., `'2024-01-01T00:00:00Z'`)
- `end_date` (optional): End date filter (RFC3339 format, e.g., `'2024-12-31T23:59:59Z'`)
- `page` (optional): Page number (starts at 1, default: 1)
- `page_size` (optional): Items per page (min: 1, max: 50, default: 20)

**Returns:**
```typescript
{
  transactions: CreditTransaction[]
  page: number
  page_size: number
  has_more: boolean
}
```

**CreditTransaction:**
```typescript
{
  id: string
  user_id: string
  app_id: string | null              // Application ID (null for platform users)
  type: 'spend' | 'earn' | 'refund' | 'adjust' | 'freeze' | 'unfreeze'
  amount: number                     // Negative for deduction, positive for addition
  balance_before: number
  balance_after: number
  source: string                     // Transaction source identifier
  source_id?: string                 // Associated source ID (order/task/request ID)
  description: string
  status: 'pending' | 'completed' | 'failed' | 'cancelled'
  status_reason?: string             // Only present in non-completed states
  metadata?: Record<string, any>     // Custom metadata
  created_at: string                 // UTC timestamp
  completed_at?: string              // UTC timestamp
}
```

**Example:**
```typescript
// Get recent transactions
const result = await client.listTransactions({ page_size: 20 });

// Filter by type
const spending = await client.listTransactions({
  type: 'spend',
  page: 1,
  page_size: 10
});

// Filter by date range
const recent = await client.listTransactions({
  start_date: '2024-01-01T00:00:00Z',
  end_date: '2024-12-31T23:59:59Z',
  status: 'completed'
});

// Pagination
const page2 = await client.listTransactions({
  page: 2,
  page_size: 20
});
if (page2.has_more) {
  // More results available
}
```

##### setToken()

Update the bearer token for authentication.

Use this method when the token expires or needs to be refreshed.

```typescript
setToken(token: string): void
```

**Example:**
```typescript
client.setToken('new-bearer-token');
```

##### setBaseURL()

Update the API base URL.

Use this method to switch between different environments (development/testing/production).

```typescript
setBaseURL(baseURL: string): void
```

**Example:**
```typescript
client.setBaseURL('https://seaspark-wallet-service.dev.seaspark.ai');
```

##### setAppId()

Update the application ID for multi-tenant isolation.

Use this method to switch between different applications at runtime.

```typescript
setAppId(appId: string): void
```

**Example:**
```typescript
// Switch to SeaVerse platform
client.setAppId('seaverse');

// Switch to third-party application
client.setAppId('game-abc123');
```

## Error Handling

SDK throws `AssetsAPIError` for API errors:

```typescript
import { AssetsAPIError } from '@seaspark/assets-sdk';

try {
  const account = await client.getCreditAccount();
} catch (error) {
  if (error instanceof AssetsAPIError) {
    console.error(`API Error [${error.code}]: ${error.message}`);
    console.error('Response:', error.response);
  } else {
    console.error('Unexpected error:', error);
  }
}
```

**Common Error Codes:**
- `400` - Bad Request (invalid parameters)
- `401` - Unauthorized (invalid or expired token)
- `403` - Forbidden
- `500` - Internal Server Error

## Environment Configuration

Different environments have different base URLs:

```typescript
// Development/Testing environment (default)
const client = new AssetsClient({
  appId: 'seaverse',
  token: devToken
  // baseURL defaults to 'https://seaspark-wallet-service.dev.seaspark.ai'
});

// Production environment
const client = new AssetsClient({
  appId: 'seaverse',
  baseURL: 'https://seaspark-wallet-service.prod.seaspark.ai', // TODO: 待确认生产环境地址
  token: prodToken
});

// Local development
const client = new AssetsClient({
  appId: 'seaverse',
  baseURL: 'http://localhost:8080',
  token: localToken
});
```

**Environment Description:**
- **Development/Testing**: `https://seaspark-wallet-service.dev.seaspark.ai` (SDK default)
- **Production**: TBD (待确认)
- **Local**: `http://localhost:8080` (local debugging)

## Multi-tenant Usage Examples

### SeaVerse Platform Application

```typescript
// Official SeaVerse platform (ops-frontend, meta-product, etc.)
const platformClient = new AssetsClient({
  appId: 'seaverse',
  token: userToken
});

const account = await platformClient.getCreditAccount();
console.log(`Platform Balance: ${account.balance}`);
```

### Third-party Game Application

```typescript
// Third-party game with its own credit pool
const gameClient = new AssetsClient({
  appId: 'game-abc123',
  token: userToken
});

const gameAccount = await gameClient.getCreditAccount();
console.log(`Game Balance: ${gameAccount.balance}`);

// Same user, different application - isolated credit accounts
// gameAccount.balance may differ from platformClient balance
```

### Switching Between Applications

```typescript
const client = new AssetsClient({
  appId: 'seaverse',
  token: userToken
});

// Query SeaVerse platform account
const platformAccount = await client.getCreditAccount();

// Switch to game application
client.setAppId('game-abc123');

// Query same user's game account
const gameAccount = await client.getCreditAccount();

// Both accounts belong to same user but are isolated
```

## Credit Units

- 1 credit = 1 unit
- All amounts are represented as integers (no decimals)

## Authentication

- Token obtained through SeaVerse auth-service
- Bearer token required in `Authorization` header
- Default token validity: 10 days
- Required scope: `account`

## Security

### Domain Whitelist (Recommended)
For production apps, register allowed domains to prevent unauthorized usage:
- `Origin` header validated against application whitelist
- Prevents `app_id` theft and abuse

### Rate Limiting
- Rate limiting applied per `app_id`
- Prevents abuse

## Troubleshooting

### Common Issues

**1. `Cannot read properties of undefined` error**

If you encounter errors like `Cannot read properties of undefined (reading 'toLocaleString')`, it means some API fields are `undefined` or `null`.

**Solution:**
```typescript
// ✅ Recommended: Use nullish coalescing operator
const balance = account.balance ?? 0;
const totalEarned = account.total_earned ?? 0;

// ✅ Recommended: Use optional chaining
const lastActivity = account.last_activity_at ?? 'N/A';

// ✅ Recommended: Check value exists before display
if (account.balance !== null && account.balance !== undefined) {
  console.log(`Balance: ${account.balance.toLocaleString()}`);
}
```

**2. CORS errors**

If you encounter CORS errors in the browser, ensure:
- Your domain is added to the application's whitelist
- `X-App-ID` header is set correctly
- Using correct baseURL

**3. 401 Unauthorized error**

**Possible causes:**
- Token expired (default validity 10 days)
- Token format incorrect
- Token missing correct scope (needs `account` scope)

**Solution:**
```typescript
// Re-obtain token
const newToken = await authService.getToken();
client.setToken(newToken);
```

**4. Data type mismatch**

API may return numbers as strings, ensure your code can handle this:

```typescript
// ✅ Safe type conversion
const balance = typeof account.balance === 'string'
  ? parseFloat(account.balance)
  : account.balance;

// ✅ Use SDK-provided type checking
const formatNumber = (value: string | number | null | undefined): number => {
  if (value === null || value === undefined) return 0;
  const num = typeof value === 'string' ? parseFloat(value) : value;
  return isNaN(num) ? 0 : num;
};
```

## License

MIT