# Starchild Auth

> Starchild Auth SDK: add OAuth login to any web app with one SDK. Use when integrating Starchild login into a project (e.g. add Starchild sign-in to my React app, set up OAuth with iamstarchild.com, implement login/logout with Starchild Auth SDK). Also use for local OAuth/CORS testing guidance (localhost:6066 web, third-party localhost origins, browser vs Node).

- Skill: `starchild-ai-agent/starchild-auth` (Agent Skill)
- Install (CLI): `npx skillmds add starchild-ai-agent/starchild-auth`
- Raw SKILL.md: https://api.skillmd.com/api/skills/starchild-ai-agent/starchild-auth/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: starchild-ai-agent (https://skillmd.com/u/starchild-ai-agent)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/starchild-ai-agent/starchild-auth

---


# 🔑 Starchild Auth SDK — 完整开发指南

Integrate Starchild OAuth login into any web application. The SDK handles OAuth popup flow, token refresh, unified `StarchildAuthError`, namespaced helpers (`auth.chat` / `auth.credit` …), and 60+ API methods for full Agent access.

### 版本策略

| 产物 | 当前版本 | 何时 bump |
|------|---------|-----------|
| npm `starchild-auth-sdk` | **0.4.1** | 代码 / 公开 API 变更 |
| 本 Skill `starchild-auth` | **1.11.0** | 集成指南 / 场景文档变更（可与 package 独立） |

两套 semver **互不绑定**：只改文档可只升 skill；只改实现必须升 package（skill 通常同步升 minor/patch 说明新能力）。

---

## 架构概览

```
第三方网站 (your-app.com)
    │
    ├─ StarchildAuth SDK (starchild-auth-sdk)
    │   ├─ auth.login()       → 弹出 starchild-web 授权页面
    │   ├─ auth.logout()      → go-api POST /v1/oauth/logout
    │   ├─ auth.bindAccount() → 跳转主站 Linked accounts 绑定正式账号
    │   └─ token refresh      → go-api POST /v1/oauth/refresh
    │
    ├─ API 调用 (chat scope)
    │   ├─ chat/stream      → clawd (SSE 流式响应)
    │   ├─ /api/clawd/*     → ai-agent (线程/消息)
    │   ├─ /api/cloud/*     → ai-agent (容器管理)
    │   └─ WebSocket        → clawd (文件同步/终端/指标)
    │
    ├─ Credits API (credit:read / credit:write)
    │   └─ https://credit.iamstarchild.com
    │       ├─ GET  /api/credits|charges|topups|usage/daily|pending|tx/{hash}
    │       ├─ POST /api/stripe/create-session | gift-cards/redeem | points/exchange
    │       ├─ GET/POST /api/kyc/* | /api/referral/* | /api/migration/reward/*
    │       └─ GET  /api/public/users/{id}/woo-bonus
    │
    └─ 用户信息
        └─ /v1/oauth/userinfo → ai-agent
```

## 应用注册与审核

### 注册流程

1. 在 [iamstarchild.com](https://iamstarchild.com) → More → **OAuth Apps** → Create App
2. 填写信息：
   - **Name** *(必填)*: 应用名称
   - **Allowed Origin** *(必填)*: **第三方应用自己的页面 Origin**（不是 starchild-web）。例：生产 `https://your-app.com`；本地 `http://localhost:5173` / `http://localhost:3333`。只允许 origin（scheme://host[:port]，无路径/query/hash）；非 localhost 必须 `https://`。可配多个。**不要**把 `http://localhost:6066` 当成第三方 origin——6066 是主站 web 本地端口，已在服务端静态 CORS 中放行。
   - **Scopes**: 勾选需要的权限
   - **System Prompt** *(可选)*: 自定义 Agent 行为
3. 仅选 `profile` → 自动通过，立即获得 Client ID
4. 选了 `chat` / `credit:read` / `credit:write` → 进入**管理员审核**，审核通过后才生成 Client ID

### Scope 权限体系

| Scope | 权限范围 | 审核 |
|-------|---------|:--:|
| `profile` | 查看用户名、头像、ID | 自动通过 |
| `chat` | Agent 对话、线程管理、容器管理、技能、媒体、定时任务、钱包读取、计费、WebSocket | 需审核 |
| `credit:read` | 查看 Credits 余额和账户状态 | 需审核 |
| `credit:write` | 充值/购买 Credits、兑换 Points（隐含 credit:read） | 需审核 |

> **注意**: 容器**删除**操作对所有 OAuth token 均被拦截（返回 403）。这是服务端硬限制。

---

## 安装

### npm / yarn / pnpm

```bash
npm install starchild-auth-sdk
# or: yarn add starchild-auth-sdk
# or: pnpm add starchild-auth-sdk
```

### CDN (plain HTML)

```html
<!-- UMD build — use with plain <script> tags -->
<script src="https://unpkg.com/starchild-auth-sdk/dist/starchild-auth.umd.cjs"></script>

<!-- China mirror -->
<script src="https://registry.npmmirror.com/starchild-auth-sdk/latest/files/dist/starchild-auth.umd.cjs"></script>
```

> UMD 构建导出 `window.StarchildAuth`（构造函数本身，不是 namespace）。
> ESM 构建 (`starchild-auth.js`) 用于 `<script type="module">` 或 bundler。

---

## 初始化与登录

```typescript
import { StarchildAuth } from 'starchild-auth-sdk'

const auth = new StarchildAuth({
  clientId: 'your-client-id',          // 必填
  scope: 'profile chat credit:read credit:write', // 空格分隔；需要 Credits 时加上 credit scopes
  // clawdApiBase: 'https://preview.iamstarchild.com', // chat/stream HTTP
  // clawdWsBase: 'wss://preview.iamstarchild.com',   // /ws/sync|terminal|metrics
  // creditApiBase: 'https://credit.iamstarchild.com', // 可选，默认生产域名

  // 登录成功回调（popup 或 autoLogin 恢复 session 时触发）
  onLogin: ({ accessToken, refreshToken, expiresIn, userInfo }) => {
    console.log('Logged in:', userInfo.agentName, 'guest=', userInfo.isGuest)
    // userInfo = { userInfoId, agentName, agentAvatar, isGuest }
  },

  onLogout: () => { /* 清除本地状态 */ },
  onTokenRefresh: (newToken) => { /* 更新本地 token */ },
  onTokenRefreshFailed: () => { /* session 过期 */ },

  // 可选配置
  autoLogin: true,          // 默认 true — 从 localStorage 恢复 session
  origin: 'https://iamstarchild.com',  // Starchild 站点
  refreshInterval: 720000,  // 自动刷新间隔 (ms)，默认 12 分钟
})
```

### Token 生命周期

- **Access Token**: 15 分钟有效，自动每 12 分钟刷新；`auth.getToken()`
- **Refresh Token**: 7 天有效，存储在 `localStorage` 的 `starchild_rt_{clientId}` key 中；`auth.getRefreshToken()` 仅暴露内存中的同一值
- **autoLogin**: 页面加载时自动用 refresh token 恢复 session
- **visibilitychange**: 从后台切回时自动刷新 token

#### `getRefreshToken()` 安全模型

- Refresh token **本来就**写在集成方 origin 的 `localStorage`（autoLogin 需要）；公开 getter **不扩大**威胁面，只是可读内存副本。
- **优先**让 SDK 自己刷新：`refreshToken()` / 定时 auto-refresh / visibility 刷新。
- 若你拷贝到自有存储：当作密码——禁止日志、禁止发给第三方后端、禁止放进 URL。
- 集成方 origin 上的 XSS 本来就能读 `localStorage`；用 CSP、避免 inline script 缓解。

### Guest 账号与绑定正式登录方式

> **没有 `loginAsGuest()`**。Guest 与正式账号走**同一条** `auth.login()` 主站 popup 流程；用户在主站选择 continue-as-guest（或等价入口）时才会拿到 `isGuest: true`。第三方不要自建 guest 登录。

OAuth 登录可能返回 **Guest（临时）账号**（`userInfo.isGuest === true`）。Guest 可正常使用已授权 scope，但未绑定永久登录方式（Google / X / Email / Phone / Wallet）。

**绑定必须在 Starchild 主站完成**，第三方不要自建绑定页。SDK 提供跳转方法：

```typescript
// 登录后检查是否为 Guest
const user = auth.getUserInfo()
// 或刷新：const user = await auth.fetchUserInfo()

if (auth.isGuest() || user?.isGuest) {
  // 打开主站 Account management → Linked accounts
  // URL: https://iamstarchild.com/?account_tab=linked-accounts
  const win = auth.bindAccount()
  if (!win) {
    // 弹窗/新标签被拦截时，可自行跳转
    window.location.href = auth.getBindAccountUrl()
  }
}

// 仅需要 URL（例如自己渲染按钮 href）
const bindUrl = auth.getBindAccountUrl()
// => `${origin}/?account_tab=linked-accounts`
```

| 方法 | 返回 | 说明 |
|------|------|------|
| `isGuest()` | `boolean` | 当前用户是否为 Guest；未登录为 `false` |
| `getBindAccountUrl()` | `string` | 主站绑定页 URL（Linked accounts tab） |
| `bindAccount()` | `Window \| null` | 新标签打开主站绑定流；被拦截时返回 `null` |

> 主站打开后会根据 `?account_tab=linked-accounts` 自动打开账号管理并切到 **Linked accounts**。用户完成绑定后，第三方应用下次 `fetchUserInfo()` / token refresh 后应看到 `isGuest: false`。

---

## 核心 API 调用模式

### 请求格式

所有 SDK 方法自动处理 token 注入。手动发送请求时：

```typescript
const headers = {
  'Authorization': `Bearer ${accessToken}`,
  'Content-Type': 'application/json',
}
```

> **clawd 端点必须带 `fly-force-instance-id`**：clawd（`preview.iamstarchild.com`）每个 Fly Machine 是单用户容器，容器归属（IDOR）检查要求请求落到当前用户容器，否则返回 403「Access denied: you do not own this resource」。OAuth access token 不含 `containerId`，SDK 会自动通过 `GET /api/cloud/containers` 解析并注入 `fly-force-instance-id: <container_id>` header；手动 curl 测 clawd 端点时需显式带该 header，否则会 403。

**端点地址**：
- ai-agent REST API: `https://ai-api.iamstarchild.com`（线程/消息/容器/技能等）
- clawd HTTP API: `https://preview.iamstarchild.com`（chat/stream、scheduled-jobs、models）
- Token 端点: `https://go-api.iamstarchild.com/v1`（go-api）

---

## 命名空间 API（兼容层）

Flat 方法全部保留。命名空间是 plan 303 风格的分组别名，二者等价：

```typescript
await auth.sendMessage('hi')
await auth.chat.send('hi')          // alias

await auth.getCredits()
await auth.credit.getBalance()      // alias

await auth.listThreads()
await auth.threads.list()
```

| Namespace | 主要方法 |
|-----------|---------|
| `auth.profile` | `fetchUserInfo`, `getUserInfo`, `isGuest`, `bindAccount`, `getBindAccountUrl` |
| `auth.chat` | `send`/`sendMessage`, `reconnect`/`reconnectStream`, `cancelRun`, `getModel`/`setModel`, WS factories |
| `auth.threads` | `create`, `list`, `get`, `delete`, `search`, `pin`, `updateTitle` |
| `auth.messages` | `list`, `delete` |
| `auth.containers` | `list`, `status`, `metrics`, `deploy`, `start`, `stop`, `restart`, `wake`, `rename`, `delete`, … |
| `auth.skills` | `catalog`, `search`, `detail` |
| `auth.media` | `uploadImage`, `transcribeAudio`, `synthesizeSpeech` |
| `auth.shares` | `create`, `list`, `get`, `delete`, `fork` |
| `auth.feedback` | `rate`, `delete` |
| `auth.jobs` | `list`, `create`, `get`, `pause`, `resume`, `restart` |
| `auth.wallet` | `getPortfolio`, `list`, `create`, `delete`, `exportPrivateKey`, `createOnrampSession` |
| `auth.credit` | 余额/流水/Stripe/礼品卡/**Points**/**KYC**/**Referral**/migration/WOO（见场景八） |

> Points 兑换、KYC、Referral **已对 OAuth 开放**（需 `credit:read` / `credit:write`），不是主站专属。

---

## 场景一：发送消息并读取 SSE 流响应

这是最核心的交互模式。消息通过 SSE (Server-Sent Events) 流式返回。

### SDK 方式

```typescript
const stream: Response = await auth.sendMessage('Hello, analyze this data')

// SSE 是流式响应，需要逐块读取
const reader = stream.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  buffer += decoder.decode(value, { stream: true })
  const lines = buffer.split('\n')
  buffer = lines.pop() || ''  // 保留未完成的行

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const event = JSON.parse(line.slice(6))
      // event.type 决定处理方式
      handleStreamEvent(event)
    }
  }
}
```

### 原生 fetch 方式（不使用 SDK）

```typescript
// POST /chat/stream — SSE 流式聊天（clawd 端点）
const response = await fetch('https://preview.iamstarchild.com/chat/stream', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    message: 'Hello, analyze this data',
    thread_id: threadId,    // 可选，不传则创建新 thread
  }),
})

// 读取 SSE 流（同上）
```

### SSE 事件类型

| Event Type | 含义 | 关键字段 |
|-----------|------|---------|
| `agent_start` | Agent 开始处理 | `session_key` — 用于后续重连 |
| `text_delta` | 文本增量输出 | `text` — 新产生的文本片段 |
| `tool_use` | 调用工具 | `tool_name`, `tool_input` |
| `tool_output` | 工具返回结果 | `tool_output` — 工具输出 |
| `agent_end` | Agent 完成 | `stop_reason` — end_turn / tool_use |
| `error` | 错误 | `message` — 错误描述 |

```typescript
function handleStreamEvent(event: any) {
  switch (event.type) {
    case 'agent_start':
      console.log('Agent started, session:', event.session_key)
      // 保存 session_key 用于断线重连
      break
    case 'text_delta':
      process.stdout.write(event.text)  // 实时输出
      break
    case 'tool_use':
      console.log(`Using tool: ${event.tool_name}(${event.tool_input})`)
      break
    case 'tool_output':
      console.log('Tool result:', event.tool_output)
      break
    case 'agent_end':
      console.log('Done, reason:', event.stop_reason)
      break
    case 'error':
      console.error('Stream error:', event.message)
      break
  }
}
```

### 重连 SSE 流

当 SSE 连接断开（网络问题、页面切换等），用 `session_key` 重连：

```typescript
// POST /chat/stream/reconnect?session_key=xxx&channel=web
const stream = await auth.reconnectStream(sessionKey)
// 读取方式同 sendMessage
```

---

## 场景二：管理对话线程

```typescript
// 创建线程
const thread = await auth.createThread('My analysis')
// thread = { id: string, title: string, created_at: string, ... }

// 列出所有线程
const { threads } = await auth.listThreads()

// 获取线程消息
const { messages } = await auth.listMessages(thread.id, 50)  // 最近 50 条
// messages[0] = { id, role: 'user'|'assistant', content: [...], created_at }

// 搜索线程
const result = await auth.searchThreads('analysis')

// 删除线程
await auth.deleteThread(thread.id)

// 删除消息
await auth.deleteMessages(thread.id)
```

---

## 场景三：管理容器

容器是运行 Agent 的 Fly.io 虚拟机。

```typescript
// 部署新容器
const container = await auth.deployContainer()
// container = { container_id, name, state, region, ... }

// 列出所有容器
const { containers } = await auth.listContainers()

// 获取容器状态
const status = await auth.getContainerStatus(container.container_id)
// status = { state: 'started'|'stopped'|'suspended'|..., ... }

// 启动/停止/重启
await auth.startContainer(container_id)
await auth.stopContainer(container_id)
await auth.restartContainer(container_id)

// 重命名
await auth.renameContainer(container_id, 'production-agent')

// 获取指标
const metrics = await auth.getContainerMetrics(container_id)
// metrics = { cpu: { series: [...] }, memory: { series: [...] }, disk: { series: [...] } }

// ⚠️ 删除容器 — OAuth token 无法执行（服务端返回 403）
// await auth.deleteContainer(container_id)  // 总是失败
```

---

## 场景四：WebSocket 连接

### 实时指标 (CPU/内存/磁盘)

```typescript
const ws = auth.createMetricsWebSocket()

ws.onopen = () => console.log('Metrics connected')
ws.onmessage = (e) => {
  const { cpu_percent, memory_used_bytes, memory_total_bytes, disk_used_bytes } = JSON.parse(e.data)
  console.log(`CPU: ${cpu_percent}%, Mem: ${memory_used_bytes}/${memory_total_bytes}`)
}
ws.onclose = () => console.log('Disconnected — implement reconnection logic')
```

### 文件同步

```typescript
const ws = auth.createSyncWebSocket()

ws.onopen = () => {
  // 订阅文件变更
  ws.send(JSON.stringify({
    type: 'sync:subscribe',
    payload: { paths: ['/src'] }
  }))
}

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data)
  switch (msg.type) {
    case 'sync:connected':
      console.log('Sync ready, session:', msg.payload.sessionId)
      break
    case 'file:created':
      console.log('New file:', msg.payload.path)
      break
    case 'file:updated':
      // msg.payload = { path, type: 'file'|'directory', triggeredBy: 'watcher'|'agent'|'user' }
      console.log('Changed:', msg.payload.path, 'by', msg.payload.triggeredBy)
      break
    case 'file:deleted':
      console.log('Deleted:', msg.payload.path)
      break
    case 'file:moved':
      console.log('Moved:', msg.payload.oldPath, '→', msg.payload.newPath)
      break
  }
}
```

### 终端

```typescript
// sessionId 从 SSE chat stream 的 terminal:connected 事件获取
const ws = auth.createTerminalWebSocket(sessionId)

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data)
  switch (msg.type) {
    case 'connected':
      console.log('Terminal ready, session:', msg.sessionId)
      break
    case 'output':
      process.stdout.write(msg.data)
      break
    case 'error':
      console.error('Terminal error:', msg.message)
      break
  }
}

// 发送命令
ws.send(JSON.stringify({ type: 'input', data: 'ls -la\n' }))
// 调整终端大小
ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 }))
```

### WebSocket 重连

浏览器 WebSocket 不支持自动重连，需要自行实现：

```typescript
class WSReconnect {
  private ws: WebSocket | null = null
  private attempts = 0
  private maxAttempts = 5

  connect(factory: () => WebSocket) {
    this.ws = factory()
    this.ws.onclose = () => {
      if (this.attempts < this.maxAttempts) {
        const delay = Math.min(1000 * 2 ** this.attempts, 30000)
        setTimeout(() => {
          this.attempts++
          this.connect(factory)
        }, delay)
      }
    }
    this.ws.onopen = () => { this.attempts = 0 }
  }
}
```

---

## 场景五：技能与媒体

```typescript
// 浏览技能目录
const catalog = await auth.getSkillsCatalog()
// catalog = { official: [{ source, name, description, ... }], community: [...], installed: [...] }

// 搜索技能
const results = await auth.searchSkills('trading')

// 获取技能详情
const detail = await auth.getSkillDetail('official', 'orderly-trading')

// 上传图片
const image = await auth.uploadImage(base64data, 'image/png')
// image = { url: string, filename: string }

// 语音转文字
const text = await auth.transcribeAudio(audioBase64)
// text = { text: string }

// 文字转语音
const audio = await auth.synthesizeSpeech('Hello world')
// audio = { audio_base64: string, format: 'mp3' }
```

---

## 场景六：分享与反馈

```typescript
// 创建对话分享
const share = await auth.createShare(threadId)
// share = { share_id: string, share_url: string }

const share = await auth.createShare(threadId, ['msg-1', 'msg-2'])  // 指定消息

// 列出分享
const { shares } = await auth.listShares()

// 删除分享
await auth.deleteShare(shareId)

// 复制分享
await auth.forkShare(shareId)

// 点赞/踩消息
await auth.rateMessage(messageId, 'like')
await auth.rateMessage(messageId, 'dislike', 'Not accurate')

// 取消反馈
await auth.deleteFeedback(messageId)
```

---

## 场景七：钱包与计费

```typescript
// 获取投资组合
const portfolio = await auth.getPortfolio()
// portfolio = { total_value_usd, tokens: [...] }

// 列出钱包
const { wallets } = await auth.listWallets()

// 创建/删除钱包
const wallet = await auth.createWallet()
await auth.deleteWallet(walletAddress)

// Coinbase Onramp
const session = await auth.createOnrampSession({
  amount: '100',  // USD
  currency: 'USD',
})
// session = { url: string } — redirect user to this URL
```

---


## 场景八：Credits（余额 / 充值 / Points / KYC / Referral）

> **服务**: `starchild-credit-api`  
> **Base**: `creditApiBase`，默认 `https://credit.iamstarchild.com`  
> **鉴权**: `Authorization: Bearer <oauth_access_token>`  
> **Scope**:
> - `credit:read` — 所有 GET（余额、流水、pending、tx、points 余额、KYC 状态、referral 查询、migration 状态）
> - `credit:write` — 写操作（Stripe 会话、礼品卡、points 兑换、KYC 写、referral bind、migration claim）；**隐含 read**
>
> OAuth App 注册时需勾选对应 scope，审核通过后 token 才会带上。
>
> **开放范围**：余额/流水、Stripe、礼品卡、**Points 兑换**、**KYC**、**Referral**、migration reward、WOO bonus（public）均已对 OAuth 开放；命名空间写法：`auth.credit.*`。

### 初始化

```typescript
const auth = new StarchildAuth({
  clientId: 'your-client-id',
  scope: 'profile chat credit:read credit:write',
  // creditApiBase: 'https://credit.iamstarchild.com', // 默认值，本地可改
  onLogin: ({ userInfo }) => console.log(userInfo),
})
```

### 余额与流水（credit:read）

```typescript
// 当前余额
const bal = await auth.getCredits()
// bal.credit_balance — 可用余额
// bal.pending_credit — 待入账
// bal.total_recharged / bal.total_used
// bal.daily_balance — 订阅日额度（若有）
// bal.container_id / bal.user_id

// 扣费记录（默认近 24h，可分页 + 时间窗）
const charges = await auth.getCreditCharges({
  page: 1,
  page_size: 20,
  start_time: '2026-01-01T00:00:00Z', // 可选 ISO8601
  end_time: '2026-01-31T23:59:59Z',
})
// charges.charges[]: { amount, api_type, balance_after, description, created_at, ... }
// charges.pagination: { page, page_size, has_more }
// charges.time_range: { start_time, end_time }

// 充值记录
const topups = await auth.getCreditTopups({ page: 1, page_size: 20 })
// topups.topups[]: { amount, chain, tx_hash, balance_after, created_at, ... }

// 每日用量
const usage = await auth.getCreditDailyUsage({ days: 7 })
// usage.daily[] / usage.by_api[]

// 待入账
const pending = await auth.getPendingCredit()
// pending.status === 'no_pending' | 'pending_sync' | (有 machine 时直接带 pending_credit)

// 轮询链上/支付 tx
const tx = await auth.getCreditTxStatus(txHash)
// 1) status==='not_detected' && !credited && !pending → 继续轮询
// 2) pending && !container_id → 已进 pending_credit，可停
// 3) pending && container_id → 等 flush，继续轮询
// 4) credited === true → 已入账，停
```

### 字段速查：`CreditBalance`（GET /api/credits）

| 字段 | 类型 | 说明 |
|------|------|------|
| `user_id` | string? | 用户 ID |
| `container_id` | string | 关联容器（可能为空） |
| `credit_balance` | number | 可用 Credits |
| `daily_balance` | number? | 订阅日额度剩余 |
| `total_recharged` | number | 累计充值 |
| `total_used` | number | 累计消耗 |
| `pending_credit` | number? | 待入账 |
| `ipv6` / `name` / `is_active` / `status` / `hint` | optional | 机器/状态信息 |

### Stripe 充值（credit:write）

```typescript
const { url } = await auth.createStripeSession({
  amount_usd: 20,
  success_url: 'https://your-app.com/billing?ok=1',
  cancel_url: 'https://your-app.com/billing?cancel=1',
})
window.location.href = url
// 支付完成后可用 getCreditTxStatus / getCredits / getPendingCredit 确认到账
```

| 请求字段 | 类型 | 说明 |
|----------|------|------|
| `amount_usd` | number | 美元金额，如 `10` = $10 |
| `success_url` | string | 支付成功回跳绝对 URL |
| `cancel_url` | string | 取消回跳绝对 URL |

响应：`{ url: string }` — Stripe Checkout 地址。

### 礼品卡（credit:write）

```typescript
const r = await auth.redeemGiftCard('GIFT-CODE-XXX')
// 或 auth.redeemGiftCard({ code: 'GIFT-CODE-XXX' })
// r.amount, r.credited_to: 'machine' | 'pending'
// r.new_balance / r.pending_credit
```

### Points 兑换 Credits（read 查余额 / write 兑换）

```typescript
const pts = await auth.getPointsExchangeBalance()
// pts.available_points, pts.exchange_rate, pts.exchanged_credits, ...

const ex = await auth.exchangePoints(
  { points: 1000 },
  crypto.randomUUID(), // 推荐传 Idempotency-Key，防重试双花
)
// 也可 auth.exchangePoints(1000, idemKey)
// ex.credits_received, ex.new_credit_balance, ex.idempotent?
```

### KYC（points 大额兑换可能要求）

```typescript
const kyc = await auth.getKycStatus()
// kyc.verified / kyc.exempt / kyc.exchanged_credits / kyc.threshold_credits

if (!kyc.verified && !kyc.exempt) {
  const intent = await auth.createKycSetupIntent()
  // intent.client_secret → 交给 Stripe.js / Payment Element 完成绑卡
  // 成功后:
  await auth.verifyKyc(intent.setup_intent_id)
}
```

### Referral

```typescript
const ref = await auth.getReferralStatus()
// ref.my_referral_code, ref.can_bind, ref.has_bound_inviter, ref.invited_by

if (ref.can_bind) {
  await auth.bindReferralCode('INVITE-CODE') // credit:write，仅一次
}

const invitees = await auth.getReferralInvitees()
// invitees.invitees[], invitees.total_bonus_earned, ...
```

### Migration 奖励

```typescript
const st = await auth.getMigrationRewardStatus()
if (st.eligible && !st.already_claimed) {
  const claim = await auth.claimMigrationReward() // credit:write
  // claim.amount, claim.credited_to, claim.new_balance
}
```

### WOO Staking Bonus（公开接口）

```typescript
const woo = await auth.getWooBonus() // 默认当前登录 userInfoId
// woo.bonus_percent, woo.max_staked_woo, woo.matched_wallet
```


### 字段速查：其它常用响应

#### `CreditChargeItem` / `getCreditCharges`

| 字段 | 类型 | 说明 |
|------|------|------|
| `amount` | number | 扣费 Credits |
| `api_type` | string | 计费 API 类别 |
| `balance_after` | number | 扣费后余额 |
| `description` | string | 描述 |
| `created_at` | string | ISO8601 |
| `machine_ipv6` | string | 机器 IPv6 |
| `call_type` / `agent_id` | string? | 可选调用元数据 |
| `pagination.has_more` | boolean | 是否还有下一页 |

#### `CreditTopupItem` / `getCreditTopups`

| 字段 | 类型 | 说明 |
|------|------|------|
| `amount` | number | 充值金额 |
| `chain` | string | 链 / 渠道（含 stripe） |
| `tx_hash` | string | 交易哈希 |
| `balance_after` | number | 入账后余额 |
| `created_at` | string | ISO8601 |

#### `CreditTxStatus` / `getCreditTxStatus`

| 字段 | 类型 | 说明 |
|------|------|------|
| `credited` | boolean | 是否已入账 |
| `pending` | boolean | 是否处理中 |
| `status` | `'not_detected'?` | 未检测到链上 tx |
| `balance_after` | number? | 入账后余额 |
| `amount` / `chain` / `tx_hash` | optional | 检测到后的详情 |
| `container_id` | string? | 空字符串表示无容器、进 pending |

#### `RedeemGiftCardResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | string | 礼品卡码 |
| `amount` | number | 到账 Credits |
| `credited_to` | `'machine' \| 'pending'` | 入账目标 |
| `new_balance` / `pending_credit` | number \| null | 对应余额 |

#### `PointsExchangeBalance` / `PointsExchangeResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `available_points` | number | 可兑换积分 |
| `exchange_rate` | string | 汇率文案 |
| `points_spent` | number | 本次消耗积分 |
| `credits_received` | number | 本次获得 Credits |
| `new_credit_balance` | number | 兑换后余额 |
| `idempotent` | boolean? | 幂等重放 |

#### `KycStatus` / `KycVerifyResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `verified` | boolean | 是否已 KYC |
| `exempt` | boolean | 是否豁免 |
| `exchanged_credits` / `threshold_credits` | number | 已兑 / 阈值 |
| `card_last4` / `card_brand` | string | 验卡结果 |

#### `ReferralStatus` / `ReferralInviteesResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `my_referral_code` | string \| null | 自己的邀请码 |
| `can_bind` | boolean | 是否还能绑邀请人 |
| `invitee_count` | number | 邀请人数 |
| `total_bonus_earned` | number | 累计 referral bonus |

#### `MigrationRewardClaimResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `amount` | number | 奖励 Credits |
| `credited_to` | `'machine' \| 'pending' \| 'none'` | 入账位置 |
| `already_claimed` | boolean | 是否已领过 |
| `message` | string | 状态说明 |

#### `WooBonusResponse`

| 字段 | 类型 | 说明 |
|------|------|------|
| `bonus_percent` | number | 额外 credit 百分比 |
| `max_staked_woo` | number | 最高质押 WOO |
| `matched_wallet` | string | 命中档位的钱包 |
| `wallets_checked[]` | `{ wallet, staked_woo }` | 检查明细 |

> 完整 TypeScript 定义与字段 JSDoc 见 SDK：`starchild-auth-sdk/src/types.ts`（构建后 `dist/index.d.ts`）。

### Scope 不足时

服务端返回 **403**，body 类似：

```json
{ "detail": "Insufficient scope: credit:read or credit:write is required." }
```

写接口缺 `credit:write` 时：

```json
{ "detail": "Insufficient scope: credit:write is required for this operation." }
```

集成方应引导用户重新 `login()` 并申请完整 credit scopes，或在 OAuth App 控制台勾选后重新授权。

---
## 场景九：消息排队与注入（Agent 运行中发送新消息）

当 Agent 正在处理消息时（SSE 流未结束），用户可能发送新消息。这时不能直接调用 `/chat/stream`，而是将消息加入**排队队列**。

### 前端实现模式（参考 starchild-web）

```typescript
// 1. 检查 Agent 是否正在运行
const isAgentActive = isStreaming || !!agentBackgroundRunning[threadId]

if (isAgentActive) {
  // 2. 消息加入本地队列（不立即发送到后端）
  const queuedId = `queued-${Date.now()}`
  dispatch(addToMessageQueue({
    threadId,
    message: {
      id: queuedId,
      content: message,
      images: images,      // 可选：base64 图片
      files: files,        // 可选：已上传文件引用
      quote: quoteOptions, // 可选：引用的消息
      status: 'pending',   // pending → sending → sent
      createdAt: Date.now(),
    },
  }))

  // 3. 显示 "当前消息已排队，Agent 完成后自动发送" 提示
  dispatch(updateQueuedMessageStatus({ threadId, messageId: queuedId, status: 'sending' }))

  // 4. 后端在 /chat/stream 完成后，检查 messageQueue
  //    取出 FIFO 的第一条，调用下一个 /chat/stream
} else {
  // Agent 空闲，直接发送
  await sendToChatStream(message, images, files)
}
```

### /chat/stream 请求体格式（完整）

```json
{
  "message": "Hello, analyze this data",
  "thread_id": "thread-uuid",
  "channel": "web",
  "message_id": "queued-1234567890",
  "model": "claude-3-5-sonnet-20241022",
  "images": [
    {
      "base64_data": "...",
      "media_type": "image/png"
    }
  ],
  "files": [
    {
      "name": "data.csv",
      "workspace_path": "/workspace/data.csv",
      "mime_type": "text/csv",
      "size": 1024
    }
  ],
  "quote": {
    "source_message_id": "msg-abc123",
    "quoted_text": "The original message text...",
    "source_role": "user"
  }
}
```

### /chat/stream 响应流程

```
POST /chat/stream → SSE 连接建立
  ← event: agent_start    { session_key: "sess-xxx" }
  ← event: text_delta     { text: "I'll analyze..." }
  ← event: tool_use       { tool_name: "read_file", tool_input: {...} }
  ← event: tool_output    { tool_output: "file content..." }
  ← event: text_delta     { text: "Based on the data..." }
  ← event: agent_end      { stop_reason: "end_turn" }
SSE 连接关闭
→ 后端检查 messageQueue[threadId]
→ 如果有排队消息 → 自动开始下一个 /chat/stream
```

### SSE 事件类型详解

| 事件 | 含义 | payload 示例 |
|------|------|-------------|
| `agent_start` | Agent 开始处理，返回 session_key 用于重连 | `{session_key: "sess-abc123"}` |
| `text_delta` | 增量文本输出（逐 token） | `{text: "Hello"}` |
| `tool_use` | Agent 调用工具 | `{tool_name: "read_file", tool_input: {path: "/a.txt"}}` |
| `tool_output` | 工具返回结果 | `{tool_output: "file contents..."}` |
| `agent_end` | Agent 完成一轮对话 | `{stop_reason: "end_turn" \| "tool_use"}` |
| `error` | 流错误 | `{message: "Error description"}` |
| `agent:interrupted` | Agent 被中断（用户取消/超时） | `{reason: "..."}` |

### /chat/runs/cancel 取消运行

```typescript
// POST /chat/runs/cancel?thread_id=xxx
await auth.cancelRun(threadId)
// 这会中断当前正在运行的 SSE 流
// SSE 连接会收到 agent_end 或 agent:interrupted 事件后关闭
```

### /chat/stream/reconnect 断线重连

当 SSE 连接意外断开（网络问题、页面切换），用 `session_key` 重连：

```typescript
// POST /chat/stream/reconnect?session_key=sess-xxx&channel=web
const stream = await auth.reconnectStream(sessionKey)

// reconnect 返回的 SSE 事件格式相同
// 它会从中断点继续推送剩余的事件
// 如果 Agent 已完成，会立即收到 agent_end
```

---

## 场景十：Agent 集成（直接 token 注入）

Agent 可以在没有浏览器 popup 的情况下使用 SDK：

```typescript
import { StarchildAuth } from 'starchild-auth-sdk'

// Agent 从环境变量或 OAuth 回调获取 token
const accessToken = process.env.STARCHILD_TOKEN!

const auth = new StarchildAuth({
  clientId: process.env.CLIENT_ID!,
  scope: 'profile chat',
  autoLogin: false,  // 不弹出浏览器窗口
  onLogin: () => {},
})

// 注入 token（绕过 popup 流程）
;(auth as any)._accessToken = accessToken

// 现在可以调用所有方法
const threads = await auth.listThreads()
const message = await auth.sendMessage('Summarize my threads')
```

---

## 场景十一：推荐 Chat UI 组件集成

> Auth SDK 是纯 API 层（无 UI 组件）。以下推荐两个开源 React Chat 组件库，并给出与 SDK SSE 流对接的完整适配器代码，帮助第三方应用快速搭建 ChatGPT 风格的对话界面。
>
> 官方文档：
> - assistant-ui: https://www.assistant-ui.com/docs/runtimes/custom/local-runtime
> - MUI X Chat: https://mui.com/x/react-chat/backend/adapters/

### 选型对比

| 维度 | assistant-ui | MUI X Chat |
|------|-------------|------------|
| 包名 | `@assistant-ui/react` | `@mui/x-chat` |
| 集成难度 | **低** — 一个 `async *run()` generator 即可 | 中 — 需将 SSE 转为 `ReadableStream<ChatMessageChunk>` |
| UI 依赖 | 框架无关（shadcn / Tailwind 风格，无主题绑定） | **强绑 MUI Material 主题**（带入 `@mui/material` + `@emotion/*`） |
| React Native | 支持（`@assistant-ui/react-native`） | 不支持 |
| 流式协议 | yield 累积内容（每次替换上一次） | chunk 协议（`start` → `text-delta` → `finish`） |
| 工具调用 | `yield { type: 'tool-call', ... }` 直观 | `tool-call-*` chunk 序列 |
| 多会话 | `RemoteThreadListAdapter` 或 AssistantCloud | `listConversations` + 内置侧边栏 |
| 许可证 | MIT | MIT（全功能免费，无 Pro/Premium） |
| 社区规模 | 1.4M+ 周下载量，YC 背书，Anthropic / LangChain 在用 | 较新，MUI 生态背书 |
| **推荐** | **首选** — 适合大多数第三方应用 | 备选 — 仅推荐已在用 MUI Material 的项目 |

### 共享：SSE 流解析 helper

两个适配器都需要解析 SDK `sendMessage()` 返回的 SSE `Response`。提取为共享函数：

```typescript
// lib/starchild-sse.ts
import type { SSEEvent } from 'starchild-auth-sdk'

/**
 * Parse Starchild SSE Response into an async iterable of events.
 * Stops early if the abort signal fires.
 *
 * SDK sendMessage() returns a raw Response whose body is an SSE stream.
 * Each line starting with "data: " contains a JSON event:
 *   agent_start | text_delta | tool_use | tool_output | agent_end | error
 */
export async function* parseStarchildSSE(
  response: Response,
  signal?: AbortSignal,
): AsyncGenerator<SSEEvent> {
  if (!response.ok) {
    const body = await response.text().catch(() => '')
    throw new Error(`Chat API ${response.status}: ${body.slice(0, 200)}`)
  }
  const reader = response.body!.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  while (true) {
    if (signal?.aborted) {
      reader.cancel()
      break
    }
    const { done, value } = await reader.read()
    if (done) break

    buffer += decoder.decode(value, { stream: true })
    const lines = buffer.split('\n')
    buffer = lines.pop() || ''

    for (const line of lines) {
      if (!line.startsWith('data: ')) continue
      try {
        yield JSON.parse(line.slice(6)) as SSEEvent
      } catch {
        // Skip malformed JSON lines
      }
    }
  }
}
```

### 方案 A：assistant-ui（推荐）

#### 安装

```bash
# 推荐使用 pnpm（更快、磁盘占用更小）；yarn / bun 亦可
pnpm add @assistant-ui/react
# 或: yarn add @assistant-ui/react
# 或: npm install @assistant-ui/react

# 脚手架生成 Thread 组件（shadcn / Tailwind 风格）
npx assistant-ui init
npx assistant-ui add thread
```

#### ChatModelAdapter 实现

assistant-ui 的 `LocalRuntime` 只需实现一个 `ChatModelAdapter.run()` 函数（`async *` generator）。在循环中 yield **累积内容**（每次替换上一次，不是 delta）：

```tsx
// runtime/starchild-adapter.ts
import type { ChatModelAdapter } from '@assistant-ui/react'
import { StarchildAuth } from 'starchild-auth-sdk'
import { parseStarchildSSE } from '@/lib/starchild-sse'

/**
 * Create an assistant-ui ChatModelAdapter backed by Starchild Auth SDK.
 * The auth instance is initialized in the Provider and passed in.
 */
export function createStarchildAdapter(auth: StarchildAuth): ChatModelAdapter {
  return {
    async *run({ messages, abortSignal, unstable_threadId }) {
      // 1. Extract last user message text
      const lastUser = [...messages].reverse().find(m => m.role === 'user')
      const text = lastUser?.content
        .filter(c => c.type === 'text')
        .map(c => c.text)
        .join('\n') ?? ''

      if (!text) {
        yield { content: [{ type: 'text', text: '' }] }
        return
      }

      // 2. Call SDK sendMessage — returns SSE Response
      //    threadId: continue existing thread, or omit to create new
      const response = await auth.sendMessage(text, {
        threadId: unstable_threadId,
      })

      // 3. Parse SSE stream and yield cumulative content
      let fullText = ''
      const toolCalls = new Map<string, any>()

      for await (const event of parseStarchildSSE(response, abortSignal)) {
        switch (event.type) {
          case 'agent_start':
            // event.session_key — save for reconnect if needed
            break

          case 'text_delta':
            fullText += event.text
            yield {
              content: [
                ...(fullText ? [{ type: 'text' as const, text: fullText }] : []),
                ...Array.from(toolCalls.values()),
              ],
            }
            break

          case 'tool_use': {
            // Accumulate tool calls outside the loop (per assistant-ui best practice)
            const id = event.tool_use_id || crypto.randomUUID()
            toolCalls.set(id, {
              type: 'tool-call' as const,
              toolCallId: id,
              toolName: event.tool_name,
              args: event.tool_input,
              argsText: JSON.stringify(event.tool_input),
            })
            yield {
              content: [
                ...(fullText ? [{ type: 'text' as const, text: fullText }] : []),
                ...Array.from(toolCalls.values()),
              ],
            }
            break
          }

          case 'tool_output': {
            // Update the matching tool call with its result.
            // IMPORTANT: create a new object (not mutate) so React detects the change.
            const id = event.tool_use_id
            if (id && toolCalls.has(id)) {
              const existing = toolCalls.get(id)
              toolCalls.set(id, { ...existing, result: event.tool_output })
              yield {
                content: [
                  ...(fullText ? [{ type: 'text' as const, text: fullText }] : []),
                  ...Array.from(toolCalls.values()),
                ],
              }
            }
            break
          }

          case 'agent_end':
            // Stream complete — final yield already done above
            break

          case 'error':
            throw new Error(event.message)

          case 'agent:interrupted':
            // User cancelled or timeout — stop yielding
            break
        }
      }

      // Ensure at least one yield with content
      if (!fullText && toolCalls.size === 0) {
        yield { content: [{ type: 'text', text: '' }] }
      }
    },
  }
}
```

#### RuntimeProvider 组装

```tsx
// runtime/StarchildRuntimeProvider.tsx
'use client'
import type { ReactNode } from 'react'
import {
  AssistantRuntimeProvider,
  useLocalRuntime,
} from '@assistant-ui/react'
import { StarchildAuth } from 'starchild-auth-sdk'
import { createStarchildAdapter } from './starchild-adapter'

// Singleton auth instance — SDK handles token refresh internally
const auth = new StarchildAuth({
  clientId: process.env.NEXT_PUBLIC_STARCHILD_CLIENT_ID!,
  scope: 'profile chat',
  onLogin: ({ userInfo }) => console.log('Logged in:', userInfo.agentName),
  onTokenRefreshFailed: () => {
    // Session expired — redirect to login or show login button
    window.location.reload()
  },
})

export function StarchildRuntimeProvider({
  children,
}: Readonly<{ children: ReactNode }>) {
  const runtime = useLocalRuntime(createStarchildAdapter(auth))
  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {children}
    </AssistantRuntimeProvider>
  )
}
```

#### 多线程列表（可选）

如需左侧线程列表（类似 ChatGPT 侧边栏），实现 `RemoteThreadListAdapter` 并传入 `useLocalRuntime` 的 `adapters.threadList`。

> ⚠️ **接口验证提醒**：`RemoteThreadListAdapter` 的方法签名可能随 assistant-ui 版本变化。以下示例基于公开文档的常见模式，**集成前请务必查阅最新文档**：https://www.assistant-ui.com/docs/runtimes/concepts/threads

```tsx
import { useLocalRuntime } from '@assistant-ui/react'
import type { RemoteThreadListAdapter } from '@assistant-ui/react'

// Thread list adapter — bridges SDK thread API to assistant-ui sidebar
const threadListAdapter: RemoteThreadListAdapter = {
  // Called on mount — load thread list from SDK
  async getThreads() {
    const { threads } = await auth.listThreads()
    return threads.map(t => ({
      id: t.thread_id,
      title: t.title || 'New Chat',
      createdAt: t.created_at ? new Date(t.created_at) : new Date(),
    }))
  },

  // Called when user clicks a thread in the sidebar
  async switchToThread(threadId: string) {
    // assistant-ui will call this to switch the active thread.
    // Messages are loaded separately via the adapter's run() or initialMessages.
    // If you need to pre-load history, use auth.listMessages(threadId)
    // and pass them as initialMessages to the runtime.
  },

  // Called when user creates a new thread
  async createThread() {
    const thread = await auth.createThread()
    return { id: thread.thread_id }
  },

  // Called when user deletes a thread
  async deleteThread(threadId: string) {
    await auth.deleteThread(threadId)
  },
}

// In provider:
const runtime = useLocalRuntime(createStarchildAdapter(auth), {
  adapters: { threadList: threadListAdapter },
})
```

#### 页面中使用

```tsx
// app/chat/page.tsx
import { Thread } from '@/components/assistant-ui/thread'
import { StarchildRuntimeProvider } from '@/runtime/StarchildRuntimeProvider'

export default function ChatPage() {
  return (
    <StarchildRuntimeProvider>
      <Thread />
    </StarchildRuntimeProvider>
  )
}
```

### 方案 B：MUI X Chat

> 仅推荐给已在用 MUI Material 主题的项目。`@mui/x-chat` 会强制带入 `@mui/material` + `@emotion/*` 依赖树，非 MUI 项目引入成本高。

#### 安装

```bash
# 推荐使用 pnpm（更快、磁盘占用更小）；yarn / bun 亦可
pnpm add @mui/x-chat @mui/material @emotion/react @emotion/styled
# 或: yarn add @mui/x-chat @mui/material @emotion/react @emotion/styled
# 或: npm install @mui/x-chat @mui/material @emotion/react @emotion/styled
```

#### ChatAdapter 实现

MUI X Chat 的 `ChatAdapter.sendMessage()` 必须返回 `Promise<ReadableStream<ChatMessageChunk>>`。需要将 SDK 的 SSE 流转换为 MUI 的 chunk 协议（`start` → `text-start` → `text-delta` → `text-end` → `finish`）：

```tsx
// adapters/starchild-mui-adapter.ts
import type { ChatAdapter, ChatMessageChunk } from '@mui/x-chat/headless'
import { StarchildAuth } from 'starchild-auth-sdk'
import { parseStarchildSSE } from '@/lib/starchild-sse'

// Module-level state for reconnect / cancel
let lastSessionKey = ''
let currentThreadId = ''

export function createStarchildMuiAdapter(auth: StarchildAuth): ChatAdapter {
  return {
    async sendMessage({ message, signal }) {
      // Extract text from user message.
      // ChatMessage.content type varies by MUI version — handle both
      // string and structured content arrays defensively.
      const text = typeof message.content === 'string'
        ? message.content
        : Array.isArray(message.content)
            ? (message.content as Array<{ type: string; text?: string }>)
                .filter(c => c.type === 'text' && c.text)
                .map(c => c.text!)
                .join('\n')
            : String(message.content ?? '')

      // Call SDK sendMessage — returns SSE Response
      const response = await auth.sendMessage(text, {
        threadId: currentThreadId || undefined,
      })

      const messageId = crypto.randomUUID()
      const textId = crypto.randomUUID()

      // Transform SSE → MUI ReadableStream<ChatMessageChunk>
      return new ReadableStream<ChatMessageChunk>({
        async start(controller) {
          controller.enqueue({ type: 'start', messageId })

          let textStarted = false
          try {
            for await (const event of parseStarchildSSE(response, signal)) {
              switch (event.type) {
                case 'agent_start':
                  lastSessionKey = event.session_key
                  if (event.thread_id) currentThreadId = event.thread_id
                  break

                case 'text_delta':
                  if (!textStarted) {
                    controller.enqueue({ type: 'text-start', id: textId })
                    textStarted = true
                  }
                  controller.enqueue({
                    type: 'text-delta',
                    id: textId,
                    delta: event.text,
                  })
                  break

                case 'tool_use': {
                  // ⚠️ Tool-call chunk types are MUI-version-specific.
                  // The names below follow the MUI X Chat streaming protocol
                  // convention but may differ — always verify aga

…(truncated)
