npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

connectbase-client

v6.4.0

Published

Connect Base JavaScript/TypeScript SDK for browser and Node.js

Readme

connectbase-client

Connect Base JavaScript/TypeScript SDK for building real-time multiplayer games and applications.

Installation

npm install connectbase-client
# or
pnpm add connectbase-client
# or
yarn add connectbase-client

Key Types

Connect Base provides two types of Keys. Use the right key for your use case:

| Type | Prefix | Use For | Permissions | Safe to Expose? | |------|--------|---------|-------------|-----------------| | Public Key | cb_pk_ | SDK / Web apps | Limited (RLS enforced) | ✅ Yes — safe in frontend code | | Secret Key | cb_sk_ | MCP / Admin tools | Full access (bypasses RLS) | ❌ Never expose in frontend or public repos |

Which key should I use?

| Context | Key Type | Example | |---------|----------|---------| | Frontend SDK (new ConnectBase()) | Public Key (cb_pk_) | Web/app: DB queries, auth, file uploads | | .env file (VITE_CONNECTBASE_PUBLIC_KEY) | Public Key (cb_pk_) | React, Vue, etc. | | CLI deploy (.connectbaserc) | Public Key + Secret Key (cb_pk_ + cb_sk_) | npx connectbase-client deploy | | MCP server (AI tools) | Secret Key (cb_sk_) | Claude, Cursor, Windsurf | | Server-side admin tasks | Secret Key (cb_sk_) | Backend full data access |

⚠️ MCP server rejects Public Keys — you must use a Secret Key (cb_sk_).

⚠️ Never use Secret Keys in frontend code — RLS is bypassed, exposing all data.

🔐 Web storage deploys need both keys. A Public Key ships inside your browser bundle, so anyone can read it out of a deployed site's JS — it cannot prove you may replace that site. The web storage list/create/deploy/promote routes therefore require Authorization: Bearer cb_sk_* in addition to X-Public-Key; without it they return 401 SECRET_KEY_REQUIRED. The CLI sends it for you — see CLI.

Create Keys in the Console under Settings > API tab. Choose Public or Secret type when creating. The full key is shown only once at creation time.

Server-side admin context (v3.22.0+)

When you create the SDK with both publicKey and secretKey, the client attaches X-Public-Key (app identity) and Authorization: Bearer cb_sk_* (privilege escalation) on every request. The server's OptionalAdminSecretKey middleware verifies the secret key, sets an admin context, and skips RLS for that request — useful for backend sync scripts, admin tools, and cb.auth.adminUpdateMember().

// SERVER-SIDE ONLY — never ship this in a browser/mobile bundle
const cb = new ConnectBase({
  publicKey: process.env.CB_PUBLIC_KEY!,  // cb_pk_
  secretKey: process.env.CB_SECRET_KEY!,  // cb_sk_  (admin)
})

// Bypasses RLS .write/.read rules
await cb.database.createData('orders', { ... })

Without secretKey, every request is RLS-evaluated as normal — there is no behavioral change for browser clients.

Quick Start

import ConnectBase from 'connectbase-client'

// Initialize the SDK — use a Public Key (cb_pk_)
const cb = new ConnectBase({
  publicKey: 'cb_pk_your-public-key'
})

// Create a game room client
const gameClient = cb.game.createClient({
  clientId: 'player-123'
})

// Set up event handlers
gameClient
  .on('onConnect', () => console.log('Connected!'))
  .on('onStateUpdate', (state) => console.log('State:', state))
  .on('onDelta', (delta) => console.log('Delta:', delta.changes))
  .on('onAction', (action) => console.log('Action:', action.type, action.clientId))
  .on('onPlayerJoined', (player) => console.log('Player joined:', player.clientId))
  .on('onPlayerLeft', (player) => console.log('Player left:', player.clientId))

// Connect and create a room
await gameClient.connect()
const state = await gameClient.createRoom({
  maxPlayers: 4,
  tickRate: 64,
  scriptName: 'my-script', // Optional: attach a lua script (must be pre-uploaded + active)
})

// 3.14+ — Attached script 의 이름/버전을 검증하고 싶으면 createRoomDetailed 사용
import { GameError } from 'connectbase-client'
try {
  const r = await gameClient.createRoomDetailed({ scriptName: 'my-script' })
  console.log('attached', r.scriptName, 'v', r.scriptVersion)
} catch (e) {
  if (e instanceof GameError && e.code === 'SCRIPT_NOT_FOUND') {
    console.error('script missing — candidates:', e.available)
  }
}

Features

  • Real-time Game Server: WebSocket-based multiplayer game state synchronization
  • Authentication: ID/Password and OAuth social login support
  • Database: JSON-based NoSQL database with real-time queries
  • Storage: File storage with CDN support
  • Push Notifications: Cross-platform push notification support
  • WebRTC: Real-time audio/video communication
  • Payments: Subscription and one-time payment support
  • Organizations: End-user workspaces/teams with invitations and an RLS organization context (cb.organizations.*)
  • AI Streaming: Real-time AI text generation via WebSocket (multi-provider: Gemini, OpenAI, Claude, Ollama, LM Studio, OpenAI-compatible)
  • Knowledge Base (RAG): Document indexing + BM25 search with nori 한국어 형태소. PDF / DOCX / text file upload via addDocumentFromFile
  • Endpoint: Call your own GPU models on your own PC through one cb_pk_* key — ConnectBase forwards the payload as-is (dumb pipe)
  • Support: End-user feedback/issue reporting — users send issues to app operators, AI auto-classifies summary/urgency/category
  • Server-side (Admin): App-owner APIs for members (incl. email), roles/RBAC, public keys, payments and push stats — via console JWT or a serverless function's ctx.cbAdmin with least-privilege management_scopes
  • CLI: Command-line tool for deploying web storage and tunneling local services

CLI

Deploy your web application to Connect Base Web Storage with a single command.

After npx, always use the package name connectbase-client. This package installs two bins — connectbase and connectbase-client — but connectbase is a bin name, not a package name. npx checks the local node_modules/.bin first and otherwise fetches a package by that name, so npx connectbase init only works in a project that already has this package installed; in an empty folder it fails with npm 404. A global install does not help — npx does not look at PATH.

Use the short connectbase after installing: globally, inside npm run scripts, or via node_modules/.bin.

npx connectbase-client init   # nothing installed yet — package name
npm install                   # installs what init added to devDependencies
npm run deploy                # runs `connectbase deploy ./dist` — short name is fine here

Quick Start

# 1. Initialize (one-time setup)
npx connectbase-client init

# 2. Deploy
npm run deploy

The init command will:

  • Ask for your Secret Key (cb_sk_) — or take it from --secret-key / CONNECTBASE_SECRET_KEY
  • Issue a Public Key for the app
  • List existing web storages or create a new one automatically
  • Create a .connectbaserc config file (holds both keys)
  • Add .connectbaserc to .gitignore
  • Add a deploy script to package.json (includes build if available)

Commands

| Command | Description | |---------|-------------| | init | Interactive project setup (creates config, adds deploy script) | | deploy <dir> | Deploy files to web storage | | tunnel <port> | Expose a local service to the internet via WebSocket tunnel |

Manual Usage

If you prefer not to use init, you can pass options directly:

npx connectbase-client deploy ./dist -s <storage-id> -k <public-key> --secret-key <secret-key>

The Secret Key can also come from CONNECTBASE_SECRET_KEY — preferred in CI, so it never appears in shell history or CI logs:

CONNECTBASE_SECRET_KEY=cb_sk_... npx connectbase-client deploy ./dist -s <storage-id> -k <public-key>

Options

| Option | Alias | Description | |--------|-------|-------------| | --storage <id> | -s | Storage ID | | --public-key <key> | -k | Public Key (cb_pk_) — identifies the app | | --secret-key <key> | | Secret Key (cb_sk_) — required by deploy / promote / storage list & create. Prefer CONNECTBASE_SECRET_KEY in CI | | --base-url <url> | -u | Custom server URL | | --timeout <sec> | -t | Tunnel request timeout in seconds (tunnel only) | | --max-body <MB> | | Tunnel max body size in MB (tunnel only) | | --label <name> | | Auto-register the issued tunnel as an endpoint binding (tunnel only). Requires Secret Key. SDK callers can then use cb.endpoint.call(label, …) | | --description <text> | | Endpoint binding description (only valid with --label) | | --help | -h | Show help | | --version | -v | Show version |

Tunnel

Expose a local server to the internet through a secure WebSocket tunnel. Useful for sharing local MCP servers, development servers, or any HTTP service.

# Expose local port 8084 to the internet
npx connectbase-client tunnel 8084 -k <public-key>

# With environment variable
export CONNECTBASE_PUBLIC_KEY=your-public-key
npx connectbase-client tunnel 8084

# For GPU servers or long-running tasks (e.g., image generation)
npx connectbase-client tunnel 7860 --timeout 300 --max-body 50

The tunnel creates a public URL like https://tunnel.connectbase.world/<tunnel-id>/ that proxies all HTTP requests to your local service.

Plan-based limits: Timeout and body size are clamped to your plan's maximum:

| Plan | Max Timeout | Max Body | |------|-------------|----------| | Free | 60s | 10MB | | Starter | 120s | 25MB | | Pro | 300s | 50MB | | Business | 600s | 100MB |

Features:

  • Per-tunnel timeout and body size configuration
  • Automatic reconnection with exponential backoff
  • Request/response logging in terminal
  • Graceful shutdown with Ctrl+C
  • No external dependencies (uses Node.js built-in modules)

Auto-register an endpoint binding (--label)

For workflows where you want the SDK to call your local model by a stable name (cb.endpoint.call("comfyui-main", …)) instead of a random tunnel URL, pass --label <name>. The CLI registers the issued tunnel_id as an endpoint binding on the server, so your SDK only needs the Public Key.

# Start ComfyUI on port 8188, expose it as endpoint label "comfyui-main"
npx connectbase-client tunnel 8188 --label comfyui-main --description "ComfyUI on my desktop"

Authentication uses your User Secret Key (cb_sk_*); the CLI calls the dual-auth route POST /v1/apps/:appID/endpoints/cli. If the label already exists, the CLI warns and keeps the tunnel running — update the binding to the new tunnel_id from the console if needed.

Configuration File

The init command creates .connectbaserc automatically. You can also create it manually:

{
  "publicKey": "cb_pk_your-public-key",
  "secretKey": "cb_sk_your-secret-key",
  "storageId": "your-storage-id",
  "deployDir": "./dist"
}

⚠️ .connectbaserc now holds a Secret Key — keep it in .gitignore (init adds it) and never commit it. On a shared machine or in CI, drop secretKey from the file and pass CONNECTBASE_SECRET_KEY instead.

Environment Variables

export CONNECTBASE_PUBLIC_KEY=cb_pk_your-public-key
export CONNECTBASE_SECRET_KEY=cb_sk_your-secret-key   # required by deploy / promote
export CONNECTBASE_STORAGE_ID=your-storage-id
npx connectbase-client deploy ./dist

In CI, read the Secret Key from a repository secret:

# GitHub Actions
- run: npx connectbase-client deploy ./dist
  env:
    CONNECTBASE_PUBLIC_KEY: ${{ vars.CONNECTBASE_PUBLIC_KEY }}
    CONNECTBASE_SECRET_KEY: ${{ secrets.CONNECTBASE_SECRET_KEY }}
    CONNECTBASE_STORAGE_ID: ${{ vars.CONNECTBASE_STORAGE_ID }}

Requirements

  • index.html must exist in the root of the deploy directory
  • Supported file types: .html, .css, .js, .json, .svg, .png, .jpg, .gif, .webp, .woff, .woff2, .ttf, .mp3, .mp4, .pdf, .wasm, 3D assets (.glb, .gltf, .bin), browser ML models (.onnx, .tflite, .task, .safetensors), etc.
  • Files with other extensions are skipped, and deploy prints the skipped list as a warning. Hidden files and folders (.env, .git/) are skipped silently.
  • Binary files over the per-file limit (~6 MB) are uploaded directly to object storage via presigned URLs.

SPA Mode

Web Storage supports SPA (Single Page Application) mode, which is enabled by default. When enabled, requests to non-existent paths return index.html instead of 404, allowing client-side routers (React Router, Vue Router, etc.) to handle routing.

You can toggle SPA mode in the Connect Base Console under Storage > Security Settings, or via the API:

# Disable SPA mode (for static sites)
curl -X PUT "https://api.connectbase.world/v1/apps/{appID}/storages/webs/{storageID}" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"spa_mode": false}'

Important: When using SPA mode, ensure all asset paths in your HTML are absolute (/assets/...), not relative (./assets/...). For Vite projects, set base: '/' in vite.config.ts.

API Reference

Game Server

cb.game.config — Feature Opt-in (v3.1+, SDK 3.3.0+)

The game server's 7 features (matchqueue / leaderboard / entity / scripts / voice / replay / spectator) are explicit opt-in per app as of v3.1 (2026-04-30). Disabled features return HTTP 403 feature_disabled. New apps default to all-OFF; existing apps without a config row fall back to all-ON for compatibility.

// Inspect current toggles
const cfg = await cb.game.config.get(appId)
// → { matchqueue_enabled, leaderboard_enabled, entity_enabled, scripts_enabled,
//     voice_enabled, replay_enabled, spectator_enabled }

// Partial update — only the keys you send are applied; others are preserved.
await cb.game.config.set(appId, {
  matchqueue_enabled: true,
  leaderboard_enabled: true,
})

// Single-toggle convenience wrappers
await cb.game.config.enable(appId, 'matchqueue_enabled')
await cb.game.config.disable(appId, 'voice_enabled')

The PATCH publishes a NATS invalidation so game-server caches drop the entry immediately (30s TTL is the safety net).

| HTTP code | Body error | Meaning | Client action | |-----------|--------------|---------|---------------| | 403 | feature_disabled | Feature is not enabled for this app | Toggle via console or cb.game.config.set(...) | | 429 | cap_exceeded | Per-app cardinality cap reached | Remove old rows or ask the operator to raise the cap env | | 402 | quota_exceeded | Plan limit reached on a write route | Upgrade plan |

See docs/game-server/OPT_IN.md for the full policy.

GameRoom

The main class for real-time game communication.

const gameClient = cb.game.createClient({
  clientId: 'unique-player-id',    // Required: Unique identifier for this player
  gameServerUrl: 'wss://...',      // Optional: Custom game server URL
  autoReconnect: true,             // Optional: Auto-reconnect on disconnect (default: true)
  maxReconnectAttempts: 5,         // Optional: Max reconnect attempts (default: 5)
  reconnectInterval: 1000,         // Optional: Base reconnect interval in ms (default: 1000)
  connectionTimeout: 10000,        // Optional: Connection timeout in ms (default: 10000)
})
Properties

| Property | Type | Description | |----------|------|-------------| | roomId | string \| null | Current room ID | | state | GameState \| null | Current game state | | isConnected | boolean | Connection status | | isOfflineMode | boolean | Offline mode status | | latency | number | Current latency in ms | | connectionState | ConnectionState | Detailed connection state |

Methods

connect(roomId?: string): Promise<void>

Connect to the game server. Optionally specify a room ID to join immediately.

await gameClient.connect()
// or
await gameClient.connect('existing-room-id')

disconnect(): void

Disconnect from the game server.

gameClient.disconnect()

createRoom(config?: GameRoomConfig): Promise<GameState>

Create a new game room.

const state = await gameClient.createRoom({
  roomId: 'my-custom-room',        // Optional: Custom room ID
  categoryId: 'battle-royale',     // Optional: Room category
  maxPlayers: 100,                 // Optional: Max players (default: 100)
  tickRate: 64,                    // Optional: Server tick rate (default: 64)
  scriptName: 'main',              // Optional (3.11.0+): Lua script attached to the room
                                   // (uploaded+activated via console or POST /v1/game/:appID/scripts).
                                   // Required for onTick / onPlayerJoin / onAction etc. to fire.
  metadata: { map: 'forest' }      // Optional: Custom metadata
})

joinRoom(roomId: string, metadata?: Record<string, string>): Promise<GameState>

Join an existing room.

const state = await gameClient.joinRoom('room-id', {
  team: 'blue',
  displayName: 'Player1'
})

leaveRoom(): Promise<void>

Leave the current room.

await gameClient.leaveRoom()

sendAction(action: GameAction): void

Send a game action to the server.

gameClient.sendAction({
  type: 'move',
  data: { x: 100, y: 200 }
})

gameClient.sendAction({
  type: 'attack',
  data: { targetId: 'enemy-1', damage: 50 }
})

sendChat(message: string): void

Send a chat message to the room.

gameClient.sendChat('Hello everyone!')

requestState(): Promise<GameState>

Request the full current state from the server.

const state = await gameClient.requestState()

listRooms(): Promise<GameRoomInfo[]>

List all available rooms.

const rooms = await gameClient.listRooms()
rooms.forEach(room => {
  console.log(`${room.id}: ${room.playerCount}/${room.maxPlayers}`)
})

ping(): Promise<number>

Measure round-trip time to the server.

const rtt = await gameClient.ping()
console.log(`Latency: ${rtt}ms`)
Event Handlers
gameClient
  .on('onConnect', () => {
    // Called when connected to the server
  })
  .on('onDisconnect', (event: CloseEvent) => {
    // Called when disconnected
  })
  .on('onStateUpdate', (state: GameState) => {
    // Called when full state is received
  })
  .on('onDelta', (delta: GameDelta) => {
    // Called for incremental state updates
    // Use this for efficient state synchronization
  })
  .on('onPlayerJoined', (player: GamePlayer) => {
    // Called when a player joins the room
  })
  .on('onPlayerLeft', (player: GamePlayer) => {
    // Called when a player leaves the room
  })
  .on('onChat', (message: ChatMessage) => {
    // Called when a chat message is received
  })
  .on('onMessage', (msg) => {
    // Called for custom broadcast messages from the server-side Lua script
    // (room.broadcast / room.send_to). Standard types (delta/chat/...) go to
    // their dedicated handlers; only unknown `type` messages arrive here.
    // Branch on msg.type for game-specific protocols.
  })
  .on('onError', (error: ErrorMessage) => {
    // Called on errors
  })
  .on('onPong', (pong: PongMessage) => {
    // Called when pong is received
  })

Offline Mode

Test your game logic locally without a server connection.

// Enable offline mode
gameClient.enableOfflineMode({
  tickRate: 64,
  initialState: {
    players: {},
    objects: []
  },
  simulatedPlayers: [
    { clientId: 'bot-1', joinedAt: Date.now(), metadata: { isBot: 'true' } }
  ]
})

// Update state directly
gameClient.setOfflineState('players.player-1.position', { x: 100, y: 200 })

// Add/remove simulated players
gameClient.addSimulatedPlayer({
  clientId: 'bot-2',
  joinedAt: Date.now(),
  metadata: {}
})
gameClient.removeSimulatedPlayer('bot-2')

// Disable offline mode
gameClient.disableOfflineMode()

Authentication

// ID/Password signup
const result = await cb.auth.signUpMember({
  login_id: 'myuser123',
  password: 'password123',
  nickname: 'John'
})

// ID/Password login
const result = await cb.auth.signInMember({
  login_id: 'myuser123',
  password: 'password123'
})

// OAuth login (redirect - recommended)
await cb.oauth.signIn('google', 'https://myapp.com/oauth/callback')

// OAuth login (popup - COOP restrictions may apply)
const result = await cb.oauth.signInWithPopup('google', 'https://myapp.com/oauth/callback')

// Sign out
await cb.auth.signOut()

Admin: update another member (v3.22.0+)

Set another member's nickname, role, or custom_data from a server-side admin context. Requires the SDK to be initialized with secretKey — calling this without one throws synchronously. Self-update is rejected by the server.

// SERVER-SIDE ONLY — admin context required (publicKey + secretKey)
const cb = new ConnectBase({
  publicKey: process.env.CB_PUBLIC_KEY!,
  secretKey: process.env.CB_SECRET_KEY!,
})

// Grant role used by RLS `auth.role` expressions
await cb.auth.adminUpdateMember('019abc12-...', { role: 'editor' })

// Clear the role
await cb.auth.adminUpdateMember('019abc12-...', { role: '' })

// Multi-field update
await cb.auth.adminUpdateMember('019abc12-...', {
  nickname: 'Alice',
  role: 'admin',
  custom_data: { level: 5 },
})

role is the only way to populate the RLS expression variable auth.role — end-users can't set it on themselves through the public profile API.

Database

// Query data
const { data, total_count } = await cb.database.getData('table-id', {
  where: { status: 'active' },
  limit: 10
})

// Query with field selection (Projection) - improves response speed
const { data } = await cb.database.getData('table-id', {
  select: ['id', 'name', 'thumbnail'],  // Only return these fields
  limit: 20
})

// Exclude specific fields (e.g., large HTML/CSS content)
const { data } = await cb.database.getData('table-id', {
  exclude: ['html_content', 'css_content'],
  limit: 20
})

// Insert data — returns the created DataItem (id + data + created_at + updated_at)
const newItem = await cb.database.createData('table-id', {
  data: { name: 'John', email: '[email protected]' }
})
console.log(newItem.id) // use immediately for navigation / cache updates

// Bulk insert — returns { created: DataItem[], total, success, failed? }
const bulk = await cb.database.createMany('table-id', [
  { data: { name: 'User1' } },
  { data: { name: 'User2' } }
])

// Update data — returns the updated DataItem with merged fields
const updated = await cb.database.updateData('table-id', 'data-id', {
  data: { name: 'Jane' }
})

// Delete data
await cb.database.deleteData('table-id', 'data-id')

Aggregation (MongoDB-style Pipeline)

const result = await cb.database.aggregate('table-id', [
  { match: { status: 'active' } },
  { group: { _id: '$category', total: { $sum: '$price' }, count: { $sum: 1 } } },
  { sort: { total: -1 } },
  { limit: 10 }
])
console.log(result.results) // [{ _id: 'electronics', total: 5000, count: 12 }, ...]

Full-Text Search

// Fuzzy search with highlighting
const results = await cb.database.search('table-id', 'smrt phone', ['name', 'description'], {
  fuzzy: true,
  fuzzy_distance: 2,
  highlight: true,
  limit: 10
})

results.results.forEach(item => {
  console.log(item.data.name, item.score, item.highlights)
})

// Autocomplete
const suggestions = await cb.database.autocomplete('table-id', 'sma', 'name', { limit: 5 })

Geo Queries

// Find locations within 5km radius (query: one of near / box / polygon)
const nearby = await cb.database.geoQuery('table-id', 'location', {
  near: {
    center: { lat: 37.5665, lng: 126.9780 },
    max_distance: 5000   // meters
  }
}, { limit: 20 })

nearby.results.forEach(place => {
  console.log(place.data.name, `${place.distance}m away`)
})

// Within a rectangle
await cb.database.geoQuery('table-id', 'location', {
  box: {
    bottom_left: { lat: 37.54, lng: 126.96 },
    top_right: { lat: 37.58, lng: 127.00 }
  }
})

Batch & Transactions

table_id 는 항상 UUID. 콘솔/REST 로 생성한 테이블의 UUID 를 그대로 사용한다.

v3.12+ 부터 server 가 부분 실패(success: false)를 응답하면 SDK 가 첫 실패 op 의 error 메시지로 throw 한다 — silent success 회귀 방지 차원. 호출자는 try/catch 로 감싼다.

// Batch: atomic multi-table operations
try {
  const result = await cb.database.batch([
    { type: 'create', table_id: ORDERS_TABLE_ID, data: { product: 'A', qty: 1 } },
    { type: 'update', table_id: INVENTORY_TABLE_ID, doc_id: 'item-a', operators: {
      qty: { type: 'increment', value: -1 }
    }},
    { type: 'update', table_id: STATS_TABLE_ID, doc_id: 'daily', operators: {
      order_count: { type: 'increment', value: 1 },
      last_order: { type: 'serverTimestamp' }
    }}
  ])
  // result.success, result.results[i].{success, doc_id, error}
} catch (e) {
  // RLS 거부 / 검증 실패 / table_id 오타 등 — 전체 batch 가 atomic 하게 롤백
  console.error('batch failed:', (e as Error).message)
}

// Transaction: read-then-write with ACID guarantees
try {
  await cb.database.transaction(
    [{ table_id: ACCOUNTS_TABLE_ID, doc_id: 'user-1', alias: 'sender' }],
    [{ type: 'update', table_id: ACCOUNTS_TABLE_ID, doc_id: 'user-1', operators: {
      balance: { type: 'increment', value: -100 }
    }}]
  )
} catch (e) {
  console.error('transaction failed:', (e as Error).message)
}

Populate (Relation Query / JOIN)

// Query with related data populated
const posts = await cb.database.getDataWithPopulate('posts-table', {
  limit: 10,
  populate: [
    { field: 'author_id', from: 'users', as: 'author', select: ['name', 'avatar'] },
    { field: 'id', from: 'comments', as: 'comments', limit: 5, orderBy: 'created_at', order: 'desc' }
  ]
})

Security Rules (RLS)

// Set row-level security rules
await cb.database.createSecurityRule('app-id', {
  table_name: 'posts',
  rules: {
    read: 'true',                              // Anyone can read
    create: 'auth.member_id != null',          // Only authenticated users
    update: 'auth.member_id == data.author_id', // Only author
    delete: 'auth.member_id == data.author_id'
  }
})

// List rules
const rules = await cb.database.listSecurityRules('app-id')

Indexes

// Create unique index
await cb.database.createIndex('app-id', 'table-id', {
  name: 'idx_email_unique',
  fields: ['email'],
  unique: true
})

// Analyze and get recommendations
const analysis = await cb.database.analyzeIndexes('app-id', 'table-id')
analysis.recommendations.forEach(rec => {
  console.log(`Recommended: ${rec.fields.join(', ')} — ${rec.reason}`)
})

Triggers

// Auto-execute function on data change
await cb.database.createTrigger('app-id', {
  name: 'on-order-created',
  table_name: 'orders',
  event: 'create',
  handler_type: 'function',
  handler_id: 'send-notification-fn-id'
})

Lifecycle (TTL / Retention)

// Auto-delete expired sessions
await cb.database.setTTL('app-id', {
  table_name: 'sessions',
  field: 'expires_at',
  enabled: true
})

// Archive old logs after 90 days
await cb.database.setRetentionPolicy('app-id', {
  table_name: 'logs',
  retention_days: 90,
  action: 'archive',
  archive_table: 'archived_logs',
  enabled: true
})

Storage

// 파일 업로드 (UUID 기반 URL - 매번 변경됨)
const result = await cb.storage.uploadFile('storage-id', file)
console.log(result.url)

// 특정 폴더에 업로드
const result = await cb.storage.uploadFile('storage-id', file, 'folder-id')

// 업로드 진행률(%) 표시 (옵션 객체 — 문자열 parentId 와 하위 호환)
const result = await cb.storage.uploadFile('storage-id', file, {
    parentId: 'folder-id',                                 // 선택
    onProgress: (p) => console.log(`${p.percentage}%`),    // { loaded, total, percentage }
})
// 진행률은 브라우저(XMLHttpRequest) 환경에서만 실시간 보고, 그 외에는 0%→100% 만 통지

// 경로 기반 업로드 (고정 URL - Firebase Storage 스타일)
// 같은 경로에 다시 업로드하면 URL이 유지된 채로 파일만 교체
const result = await cb.storage.uploadByPath(
    'storage-id',
    '/profiles/user123/avatar.png',
    file
)
console.log(result.url) // 항상 동일한 URL

// 경로로 파일 조회
const file = await cb.storage.getByPath('storage-id', '/profiles/user123/avatar.png')

// 경로로 URL만 가져오기 (없으면 null)
const url = await cb.storage.getUrlByPath('storage-id', '/profiles/user123/avatar.png')

// 파일 목록 조회
const files = await cb.storage.getFiles('storage-id')

// 파일 삭제
await cb.storage.deleteFile('storage-id', 'file-id')

// 페이지 메타 설정 (SEO / OG 태그 - 웹 스토리지용)
await cb.storage.setPageMeta('web-storage-id', {
    path: '/products/123',
    title: '최신 스마트폰',
    description: '최고의 성능, 최저가 보장',
    image: 'https://example.com/product.jpg',
    og_type: 'product',
    json_ld: JSON.stringify({ "@context": "https://schema.org", "@type": "Product", "name": "스마트폰" }),
    robots_noindex: false        // true면 검색 결과에서 제외
})

// 여러 페이지 일괄 등록
await cb.storage.batchSetPageMeta('web-storage-id', {
    pages: [
        { path: '/products/1', title: '상품 1', description: '설명 1' },
        { path: '/products/2', title: '상품 2', description: '설명 2' },
    ]
})

// 페이지 메타 조회/삭제
const { pages } = await cb.storage.listPageMetas('web-storage-id')
await cb.storage.deletePageMeta('web-storage-id', '/products/123')

Knowledge Base (RAG)

문서를 등록하고 BM25 풀텍스트 검색으로 관련 청크를 찾는 RAG 인프라. 한국어는 nori 형태소 분석기 적용. AI 채팅에 컨텍스트로 연결하려면 cb.ai.chatStream({ knowledgeBaseId }) 를 사용한다.

// 텍스트 문서 추가 (즉시 처리)
const doc = await cb.knowledge.addDocument('kb-id', {
    name: '환불 정책',
    source_type: 'text',
    content: '환불은 구매 후 7일 이내 신청 가능합니다...',
    metadata: { category: 'policy' }
})
// doc.status: 'pending' → 백그라운드 처리 후 'ready'

// URL 에서 가져오기
await cb.knowledge.addDocument('kb-id', {
    name: '도움말',
    source_type: 'url',
    source_url: 'https://example.com/help.html',
})

// PDF / DOCX / text 파일 업로드 (3.17.0+)
// 브라우저: <input type="file"> 결과를 그대로 전달
const file = (document.querySelector('input[type=file]') as HTMLInputElement).files![0]
await cb.knowledge.addDocumentFromFile('kb-id', file, {
    metadata: { tag: 'manual' },
})

// Node.js: fs.readFileSync 로 읽은 Buffer
import { readFileSync } from 'node:fs'
await cb.knowledge.addDocumentFromFile('kb-id', {
    data: readFileSync('./report.pdf'),
    mimeType: 'application/pdf',
    name: 'report.pdf',
})

// 문서 목록 / 삭제
const { documents } = await cb.knowledge.listDocuments('kb-id')
await cb.knowledge.deleteDocument('kb-id', 'doc-id')

// 문서 수정 — content/file_content/metadata 변경 시 전체 재색인, name 만 보내면 라벨만 변경
await cb.knowledge.updateDocument('kb-id', 'doc-id', {
    content: '환불은 구매 후 14일 이내에 가능합니다...',
})

// 키워드 검색 (BM25)
const results = await cb.knowledge.search('kb-id', {
    query: '환불 정책이 어떻게 되나요?',
    top_k: 5,
})
results.results.forEach(r => console.log(`[${r.score.toFixed(2)}] ${r.document_name}: ${r.content.slice(0, 80)}...`))

// Agentic Search — AI 가 다중 쿼리 자동 생성 (앱에 AI 프로바이더 설정 필요)
await cb.knowledge.search('kb-id', { query: '회원 등급별 혜택 비교', agentic: true })

// GET 방식 (간단한 사용)
await cb.knowledge.searchGet('kb-id', '환불', 3)

파일 업로드 제약 (addDocumentFromFile)

  • 지원 MIME: application/pdf (텍스트 PDF), DOCX, text/* (plain/markdown/csv/html), application/json
  • 미지원: 스캔 이미지 PDF / OCR / HWP / XLSX → unsupported mime type for text extraction 에러
  • 크기 상한: 50MB (원본 바이너리 기준)
  • 추출 결과 빈 텍스트일 경우 extracted text is empty 에러 (스캔 PDF 등)

사용자별 격리 (다중 사용자 RAG)

Authorization: Bearer <appmember-jwt> 를 함께 보내면 서버가 자동으로 본인 metadata.user_id 로 결과를 제한하고, addDocument 시에도 자동 태깅. search 의 where 에 '$auth.member_id' 토큰 사용 시 서버가 인증된 AppMember ID 로 치환한다.

Realtime

// Connect to WebSocket
await cb.realtime.connect()

// Subscribe to a category
const subscription = await cb.realtime.subscribe('chat-room')

// Listen for messages
subscription.onMessage((message) => {
  console.log('New message:', message.data)
})

// Send message
await subscription.send({ text: 'Hello!' })

// Unsubscribe
await subscription.unsubscribe()

// Disconnect
await cb.realtime.disconnect()

Connection state (v6.0.0)

WS 가 차단된 망에서 SDK 는 SSE 폴백으로 내려갑니다. 이 모드에서는 AI 스트리밍만 되고 pub/sub 계열(subscribe / sendMessage / setPresence / typing)은 예외를 던집니다. 그래서 판정이 세 갈래입니다:

cb.realtime.isConnected()    // pub/sub 가능? — WS 로 연결됐을 때만 true
cb.realtime.isStreamReady()  // AI 스트리밍 가능? — WS/SSE 무관, 연결만 서 있으면 true
cb.realtime.isDegraded()     // SSE 폴백으로 내려갔나? — 사용자 안내용
cb.realtime.transport        // 'ws' | 'sse' | null

v5 → v6 BREAKING: v5 까지 isConnected() 는 SSE 폴백에서도 true 였습니다. 스트리밍 게이팅에 쓰고 있었다면 isStreamReady() 로 바꾸세요.

Presence / Typing

Presence(온라인 상태) 와 typing(입력 중 표시) 은 cb.realtime.* 가 단일 SoT 입니다. v2.0.0 에서 cb.database.setPresence / subscribePresence 는 제거되었습니다. v1.x 에서 마이그레이션은 MIGRATION-v2.md 참고.

// 본인 온라인 상태 publish
await cb.realtime.setPresence('online', { device: 'web', metadata: { nickname: '홍길동' } })

// 다른 사용자 상태 구독
const unsub = await cb.realtime.subscribePresence('user-id', (info) => {
  console.log(info.userId, info.status, info.eventType) // join | leave | update
})

// 룸 단위 typing indicator
await cb.realtime.startTyping('room-id')
await cb.realtime.stopTyping('room-id')
const unsubTyping = await cb.realtime.onTypingChange('room-id', (typing) => {
  console.log(typing.users) // 현재 입력 중인 사용자 ID 목록
})

AI Streaming

Real-time AI text generation through WebSocket. The provider and model are resolved from your app's AI config on the server; you can optionally override them per request (provider / model). Supported providers: Gemini, OpenAI, Claude, Ollama, LM Studio, and any OpenAI-compatible endpoint.

// Connect first
await cb.realtime.connect()

// Start AI streaming
const session = await cb.realtime.stream(
  [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: 'Explain quantum computing in simple terms.' }
  ],
  {
    onToken: (token, index) => {
      // Called for each generated token
      process.stdout.write(token)
    },
    onDone: (result) => {
      // Called when generation completes
      console.log('\n\nFull text:', result.fullText)
      console.log('Total tokens:', result.totalTokens)
      console.log('Duration:', result.duration, 'ms')
    },
    onError: (error) => {
      console.error('Stream error:', error.message)
    }
  },
  {
    // All fields optional. When omitted, the server uses your app's AI config.
    provider: 'openai',  // Optional: override the app's configured provider
    model: 'gpt-4o',     // Optional: override the app's configured model
    temperature: 0.7,    // Optional: 0.0-2.0
    maxTokens: 1000      // Optional: max output tokens
  }
)

// Stop streaming early if needed
await session.stop()

Stream Options: (all optional — defaults are resolved server-side from your app's AI config, not by the SDK) | Option | Type | Default | Description | |--------|------|---------|-------------| | provider | 'gemini' \| 'openai' \| 'claude' \| 'ollama' \| 'lm_studio' \| 'openai_compatible' | app config | Override the app's configured AI provider | | model | string | app config | Override the app's configured model | | system | string | - | System prompt | | temperature | number | app config | Creativity (0.0-2.0) | | maxTokens | number | - | Max output tokens | | sessionId | string | auto | Session tracking ID | | metadata | object | - | Custom metadata | | mcpGroup | string | - | MCP group slug — enables AI Agent mode (registered MCP server tools) |

Stream Result (onDone): | Field | Type | Description | |-------|------|-------------| | sessionId | string | Session ID | | fullText | string | Complete generated text | | totalTokens | number | Total tokens generated | | promptTokens | number | Input prompt tokens | | duration | number | Generation time in ms |

Server Functions

Invoke a deployed function from the SDK, or expose it as a raw HTTP webhook that external services (Discord, Stripe, GitHub, Slack Events, etc.) can call directly.

// Invoke a function (publicKey-authenticated; runs in your Knative pod)
const result = await cb.functions.invoke('019abc12-...', { orderId: '...' })

Raw HTTP webhook URL (v3.22.0+)

For external SaaS webhooks where you can't customize the request shape (raw body, vendor-specific signature headers, arbitrary HTTP methods), enable http_trigger_enabled on the function (Console or MCP update_function) and register the URL returned by getWebhookURL() with the upstream service.

const url = cb.functions.getWebhookURL('019abc12-...')
// → https://api.connectbase.world/v1/public/functions/019abc12-.../webhook

| http_trigger_auth | Required header | Use for | |---|---|---| | none | (none) | External SaaS webhooks (function verifies signature itself) | | public_key | X-Public-Key: cb_pk_* | Your own clients/services | | secret_key | Authorization: Bearer cb_sk_* | Server-to-server admin calls |

The endpoint forwards the raw request body (no JSON wrap), preserves method/path/query, and forwards all headers — so signature checks (Ed25519, HMAC-SHA256, Stripe-Signature, X-Hub-Signature-256) work end-to-end. Body limit is 10MB.

Return { statusCode, headers, body } from the handler to emit a custom HTTP response (for example, Discord Interactions requires a 200 with a JSON body within 3 seconds):

export async function handler(rawBody, ctx) {
  // The first arg is the raw request body as a UTF-8 string (parse it yourself:
  // JSON.parse(rawBody) for JSON, new URLSearchParams(rawBody) for form-encoded).
  // ctx.method / ctx.path / ctx.query / ctx.headers / ctx.rawBody (Buffer) are
  // populated for webhook invocations. The first arg is the body itself (a
  // string), not a request object with a `.body` field.
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ type: 1 }),  // Discord PING ack
  }
}

Endpoint (Local Model Tunnel)

cb.endpoint.* is a dumb pipe to your own GPU/model server running behind a ConnectBase tunnel. ConnectBase doesn't know your model, payload, or response shape — it routes a cb_pk_* call by label to the registered tunnel and forwards the body and headers as-is.

Setup: run connectbase tunnel <port> --label <name> once on the machine hosting the model (see Tunnel) — that registers the binding. Then any client with the app's Public Key can call it.

cb.endpoint.call(label, init): Promise<Response>

fetch()-compatible signature. Returns the raw Response — read the body as JSON, text, or stream as needed.

const cb = new ConnectBase({ publicKey: 'cb_pk_...' })

// ComfyUI prompt graph
const res = await cb.endpoint.call('comfyui-main', {
  method: 'POST',
  path: '/prompt',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ prompt: { /* ComfyUI node graph */ } }),
})
const data = await res.json()
// Streaming response (SSE / chunked) — vLLM chat completions
const res = await cb.endpoint.call('vllm-local', {
  method: 'POST',
  path: '/v1/chat/completions',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ stream: true, messages: [/* { role, content } */] }),
})
if (!res.body) throw new Error('no stream')
const reader = res.body.getReader()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  // value is a Uint8Array — decode and process chunk
}
// Cancel an in-flight request
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), 30_000)
await cb.endpoint.call('hunyuan-laptop', {
  method: 'POST',
  path: '/generate',
  signal: ctrl.signal,
  body: JSON.stringify({ /* model input */ }),
})

EndpointCallInit

| Field | Type | Required | Description | |-------|------|----------|-------------| | path | string | yes | Path on your model server, must start with / (e.g. /prompt, /v1/chat/completions) | | method | string | no (GET) | HTTP method | | headers | HeadersInit | no | Extra request headers; X-Public-Key is auto-injected unless you set it | | body | BodyInit \| null | no | Request body — string, Blob, ArrayBuffer, FormData, or ReadableStream | | signal | AbortSignal | no | Abort signal for cancellation |

The SDK assembles the URL as ${baseUrl}/v1/proxy/${label}${path} and forwards the request. Because the response is the raw fetch Response, streaming formats (SSE, chunked, NDJSON) work out of the box.

cb.endpoint.pollUntil<T>(label, init, predicate, opts?): Promise<T>

One-line "submit job → poll for result" pattern. Repeatedly calls the same endpoint until predicate returns a value. Designed for ComfyUI /history/{id}, A1111 /sdapi/v1/progress, or any custom queue API.

Behavior:

  • Calls cb.endpoint.call(label, init) and passes the parsed body to predicate
  • Returns undefined from predicate → wait intervalMs and retry
  • Returns a value from predicate → resolve immediately with that value
  • HTTP 5xx / network error → retry. HTTP 4xx → reject (job-level error)
  • timeoutMs exceeded or signal aborted → reject
type Hist = Record<
  string,
  { outputs: Record<string, { images?: { filename: string }[] }> }
>

const filename = await cb.endpoint.pollUntil<string>(
  'comfyui-main',
  { path: `/history/${promptId}` },
  (data: Hist) => {
    const entry = data[promptId]
    if (!entry) return undefined // still queued
    for (const out of Object.values(entry.outputs)) {
      const img = out.images?.[0]
      if (img) return img.filename
    }
    return undefined
  },
  { intervalMs: 1000, timeoutMs: 5 * 60_000 },
)

PollUntilOptions

| Field | Type | Default | Description | |-------|------|---------|-------------| | intervalMs | number | 1500 | Poll interval in ms | | timeoutMs | number | 300000 (5 min) | Total timeout in ms — reject if exceeded | | parse | 'json' \| 'text' \| 'none' | 'json' | Body parser. 'json' falls back to undefined on parse error | | signal | AbortSignal | — | External cancel signal — reject immediately on abort |

cb.endpoint.url(label, path): string

Returns the assembled call URL (${baseUrl}/v1/proxy/${label}${path}) for URL-passing scenarios where you control the request and can attach the X-Public-Key header yourself.

⚠️ Browser-native APIs that cannot set custom headers will fail with 401. ConnectBase's proxy requires X-Public-Key on every call (header-only — no ?api_key= fallback), so <img src>, new Image(), native WebSocket, <script src>, EventSource, etc. cannot authenticate through this URL. Use cb.endpoint.call() instead for those cases:

// ✅ Render an image: download via call(), then convert to a blob URL
const res = await cb.endpoint.call('comfyui-main', {
  path: `/view?filename=${encodeURIComponent(name)}`,
})
img.src = URL.createObjectURL(await res.blob())
// ...later: URL.revokeObjectURL(img.src)

For permanent images (works across CDN, survives tunnel restarts), upload the blob to cb.storage and use saved.url — see examples/ai-image-generator/.

When cb.endpoint.url() IS the right tool:

  • Logging / debugging the resolved tunnel URL
  • Passing the URL to a backend service or worker that will make the call with proper headers
  • Building a RequestInfo for a custom fetch() wrapper (you control headers)
console.log(cb.endpoint.url('comfyui-main', '/prompt'))
// → https://api.connectbase.world/v1/proxy/comfyui-main/prompt

// Hand the URL to a Service Worker that injects X-Public-Key
sw.postMessage({ url: cb.endpoint.url('comfyui-main', '/prompt'), key: PK })

Push Notifications

// Register a device (FCM for Android, APNS for iOS)
const device = await cb.push.registerDevice({
  device_token: 'fcm-token-or-apns-token',
  platform: 'android', // 'android' | 'ios' | 'web'
  device_name: 'Galaxy S24'
})

// Subscribe the device to a topic (deviceToken is required)
await cb.push.subscribeTopic(device.device_token, 'news')

// Unsubscribe the device from a topic
await cb.push.unsubscribeTopic(device.device_token, 'news')

// Web Push (browsers)
const vapidKey = await cb.push.getVAPIDPublicKey()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: vapidKey.public_key
})
await cb.push.registerWebPush(subscription)

WebRTC

// Public Key/JWT 유효성 사전 검증
const result = await cb.webrtc.validate()
if (result.valid) {
  console.log('인증 성공:', result.app_id)
}

// 로컬 미디어 스트림 가져오기
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })

// WebRTC 연결
await cb.webrtc.connect({
  roomId: 'live:room-1',
  isBroadcaster: true,
  localStream: stream
})

// 원격 스트림 수신
cb.webrtc.onRemoteStream((peerId, remoteStream) => {
  videoElement.srcObject = remoteStream
})

// 룸 전환 (생략한 옵션은 현재 세션 값을 이어받습니다)
await cb.webrtc.switchRoom('live:room-2')

// 연결 해제
cb.webrtc.disconnect()

기능별 독립 세션 (v6.0.0)

cb.webrtc 는 소켓 하나와 룸 하나만 듭니다. 한 앱에 WebRTC 기능이 둘 이상이면 (예: 공간 음성채팅 + 1:1 통화) 기능마다 세션을 하나씩 만드세요 — 각자 소켓, 룸, 이벤트 리스너, 피어 연결을 독립적으로 갖습니다.

const voice = cb.webrtc.createSession()
const call  = cb.webrtc.createSession()

voice.onRemoteStream((peerId, s) => attachToSpatialAudio(peerId, s))
call.onRemoteStream((peerId, s) => showCallScreen(s))

await voice.connect({ roomId: 'voice:lobby', localStream: mic })
await call.connect({ roomId: 'call:alice-bob', localStream: mic })

call.disconnect()   // voice 는 그대로 유지됩니다

Payments & Subscriptions

빌링키 API 6종(발급/확인/목록/상세/수정/삭제)은 로그인한 회원 토큰이 필요합니다. X-Public-Key 단독 호출은 401 입니다. 로그인 없이 빌링키를 만들던 게스트 체크아웃 흐름은 회원 로그인 후 호출로 바꿔야 합니다. 서버에서는 cb_sk_* 또는 management_scopes 에 payment:read / subscription:manage 를 opt-in 한 service_role 함수를 쓰세요.

// 결제수단 삭제 — PG 등록까지 지워졌는지는 프로바이더마다 다르다 (v6.1.0+)
const deleted = await cb.subscription.deleteBillingKey('billing-key-1')
if (!deleted.provider_revoked) {
  // toss 외 프로바이더: Connect Base 기록만 지워졌고 PG 등록은 남아 있다
  console.warn(deleted.provider_revoke_note)
}
// Create a subscription (정기 결제)
const subscription = await cb.subscription.create({
  billing_key_id: 'billing-key-1',
  plan_name: 'premium-monthly',
  amount: 9900,
  billing_cycle: 'monthly'
})

// Check subscription status (단건 조회)
const detail = await cb.subscription.get(subscription.id)
console.log(detail.status)

// Cancel subscription (기본: 현재 결제 주기 종료 시 해지)
await cb.subscription.cancel(subscription.id)

기간 얹어 주기 (기간권·선물 코드·CS 보상) — 요금제·금액은 그대로 두고 다음 결제일만 미룹니다. 무상 기간 지급은 머천트 결정이라 서버에서만 호출할 수 있습니다. 브라우저 퍼블릭 키 단독 호출은 403 입니다. 서버 경로는 둘입니다:

  • 내 서버: publicKey(앱 식별) + secretKey(cb_sk_*, 관리자 권한)로 초기화한 클라이언트
  • ConnectBase Functions: service_role: true + management_scopes: ["subscription:manage"] 로 만든 함수에서 ctx.cbAdmin.subscription.* — 시크릿 키를 함수 시크릿에 넣지 마세요
// 서버사이드 — 선물 코드를 검증한 뒤 지급
const cb = new ConnectBase({
  publicKey: process.env.CB_PUBLIC_KEY,
  secretKey: process.env.CB_SECRET_KEY,
})

// 31일 선물권 등록 → 다음 결제일이 31일 뒤로 (= 한 달 공짜)
const extended = await cb.subscription.postponeBilling(subscription.id, {
  days: 31,
  reason: 'gift-code:ABC123',
})
console.log(extended.next_billing_at)
// ConnectBase Functions — 시크릿 키 없이 관리 스코프로 (management_scopes: ["subscription:manage"])
export async function handler(payload, ctx) {
  if (!ctx.cbAdmin) throw new Error('service_role not enabled')
  const sub = await ctx.cbAdmin.subscription.get(payload.subscription_id)
  if (sub.customer_email !== payload.email) throw new Error('not your subscription')
  return ctx.cbAdmin.subscription.postponeBilling(sub.id, { days: 31, reason: `gift:${payload.code}` })
}

서버가 현재 결제일을 PG 에서 직접 읽어 더하므로, 갱신 직전에 호출해도 방금 갱신된 주기 위에 얹힙니다. payapp/paypal 은 PG 가 결제일 변경 API 를 주지 않아 400 next_billing_date_unsupported 입니다 (로컬만 미루면 원래 날짜에 그대로 출금되므로 조용히 처리하지 않습니다).

Organizations (Teams / Workspaces)

앱의 엔드유저가 만드는 조직. Connect Base 콘솔의 협업자 RBAC(cb.roles.*)와는 별개 시스템이다. 모든 조직 API 는 로그인한 회원 토큰을 요구한다 — 퍼블릭 키 단독 호출은 401 이다.

// 조직 만들기 (만든 사람이 owner)
const org = await cb.organizations.create({ name: '우리 팀' })

// 초대 — 평문 토큰은 이 응답에서만 볼 수 있고, 메일 발송은 앱이 직접 한다
const { token } = await cb.organizations.createInvitation(org.id, {
  email: '[email protected]',
  role: 'member',   // owner | admin | member
})

// 초대 수락 (초대받은 계정으로 로그인한 상태에서)
await cb.organizations.acceptInvitation(token)

// 내 조직 목록 (조직 전환 UI)
const mine = await cb.organizations.listMine()

조직 컨텍스트는 10분마다 갱신해야 한다

데이터베이스 보안 규칙(RLS)의 auth.org_id / auth.org_role 은 토큰에 실린 조직 컨텍스트 에서 온다. 이 컨텍스트의 수명은 액세스 토큰(1시간)과 독립적인 10분이다. 만료되어도 토큰 자체는 유효해 401 이 나지 않고, 조직 규칙만 fail-closed 로 거부되어 "멀쩡하던 조회가 갑자기 전부 막히는" 증상으로만 드러난다.

let timer: ReturnType<typeof setTimeout> | undefined

async function activate(orgId: string) {
  // 새 액세스 토큰을 받아 SDK 에 자동 적용한다
  const ctx = await cb.organizations.switchTo(orgId)
  clearTimeout(timer)
  // 만료 60초 전에 갱신
  timer = setTimeout(() => activate(orgId), Math.max(ctx.org_context_expires_in - 60, 30) * 1000)
  return ctx
}

토큰 회전(refresh)이 일어나면 조직 컨텍스트는 의도적으로 사라지므로 switchTo() 를 다시 호출해야 한다. 내가 속하지 않은 조직은 403 이 아니라 404 다 (조직 ID 열거 방지).

RLS 규칙에서 쓸 수 있는 축:

| 축 | 조직 컨텍스트가 없을 때 | 용도 | |---|---|---| | auth.org_id / auth.org_role | 에러 (fail-closed) | data.org_id == auth.org_id 같은 스칼라 비교 | | auth.has_org | false (에러 안 남) | 조직 유무로 분기 | | hasOrgRole('owner') | false (에러 안 남) | 조직 역할 판정 (앱 전역 hasRole() 과 다름) | | inOrg('<uuid>') | false (에러 안 남) | 특정 조직인지, 인자 생략 시 컨텍스트 유무 |

Support (End-user Issue Reporting)

End-user 가 앱 운영자에게 직접 버그·질문·요청을 발행하는 채널. 운영자 콘솔의 inbox 에 들어가며, AI 가 자동으로 요약·긴급도·카테고리를 분류한다 (운영자가 AI config 등록 시).

// 로그인 사용자 (AppMember JWT 자동 첨부)
await cb.support.reportIssue({
  title: "결제 화면이 멈춰요",
  body: "결제 버튼 클릭 후 로딩이 끝나지 않습니다.",
  category: "bug",  // bug | question | feature_request | incident | other
  metadata: { pageUrl: window.location.href }
})

// 익명 발행 + reCAPTCHA v3 (운영자가 RECAPTCHA_SECRET 설정한 경우 권장)
const recaptchaToken = await grecaptcha.execute(SITE_KEY, { action: 'report_issue' })
await cb.support.reportIssue({
  title: "...",
  body: "...",
  anonymousEmail: "[email protected]",  // 회신 받을 이메일 (선택)
  recaptchaToken,
})

응답: { id, status: 'open', created_at } (보안상 최소 정보만).

발행자가 결과를 조회하는 채널은 후속 plan 에서 추가될 예정 — 현재는 운영자가 외부 webhook(이메일/Slack 등)으로 회신하는 방식 권장.

Server-side (Admin)

앱 소유자 권한으로 호출하는 관리 API. 위의 클라이언트 모듈과 달리 브라우저에서 Public Key(cb_pk_) 단독으로는 호출할 수 없다 — 다른 회원의 개인정보나 키 발급 같은 표면이라, 콘솔 JWT 또는 서버리스 함수의 ctx.cbAdmin 컨텍스트가 필요하다. 잘못된 인증으로 호출하면 SDK 가 요청 전에 예외를 던진다.

함수에서 쓰려면 두 가지가 필요하다:

  1. 함수 생성 시 service_role: true — 런타임이 ctx.cbAdmin 을 주입한다 (RLS 우회, 이 앱 스코프).
  2. management_scopes 에 필요한 스코프만 opt-in — 최소권한. 스코프 없이 호출하면 403.

| 스코프 | 열리는 메서드 | |---|---| | app_member:read | appMembers.list / appMembers.get — 멤버 목록·상세 (이메일 포함) | | role:read | roles.list / roles.get | | role:manage | roles.create / roles.update / roles.assign / roles.delete | | payment:read | payment.list — 결제 내역 | | publickey:read | publicKey.getPublicKeys | | publickey:manage | publicKey.createPublicKey / updatePublicKey / deletePublicKey | | push:read | push.getStats |

시크릿 키(cb_sk_)는 이 경로에 등장하지 않는다 — 함수가 service-role 토큰으로 키 없이 호출하고, 토큰은 클러스터를 벗어나지 않는다. cb_sk_ 단독으로 관리 API 를 호출하면 401 이다.

// 함수 안에서 (service_role: true, management_scopes: ["app_member:read"])
export async function handler(payload, ctx) {
  if (!ctx.cbAdmin) throw new Error('service_role not enabled')

  // 문의로 들어온 이메일이 어느 회원인지 대조 (닉네임·이메일·로그인 identity 부분 일치)
  const found = await ctx.cbAdmin.appMembers.list(ctx.appId, { search: payload.email })
  if (found.total_count === 0) return { matched: false }

  // 회원 상세 — 로그인 수단까지
  const member = await ctx.cbAdmin.appMembers.get(ctx.appId, found.app_members[0].id)
  return {
    matched: true,
    memberId: member.id,
    email: member.email,
    providers: member.identities.map((i) => i.type),  // ['GOOGLE']
  }
}

appMembers.list 의 email 은 app_members.email 컬럼이 우선이고, 비어 있으면 EMAIL identity 의 provider_uid 로 fallback 한다. 소셜 제공자가 이메일을 주지 않은 회원은 빈 문자열이다. total_count 는 search 필터를 반영하므로 페이지네이션에 그대로 쓸 수 있다.

멤버가 자기 정보를 볼 때는 cb.auth.getMe() 를 쓴다. appMembers.* 는 운영자 방향 조회 전용이고, 멤버 쓰기(생성·삭제·정지·수정)는 콘솔 전용이라 app_member:read 로 열리지 않는다.

// 역할(RBAC) 관리 — management_scopes: ["role:read", "role:manage"]
const roles = await ctx.cbAdmin.roles.list(ctx.appId)
const { id } = await ctx.cbAdmin.roles.create(ctx.appId, {
  title: '읽기전용 운영자',
  description: '조회만',
})
// assign 은 "이 역할을 가질 사용자 전체" 로 동기화한다 (추가가 아님)
await ctx.cbAdmin.roles.assign(ctx.appId, id, ['user-uuid-1', 'user-uuid-2'])

// Public Key 관리 — management_scopes: ["publickey:read", "publickey:manage"]
// payment_mode 로 키마다 결제 자격증명 모드를 고정할 수 있다 (QA 빌드에 test 키)
const created = await ctx.cbAdmin.publicKey.createPublicKey(ctx.appId, {
  name: 'QA',
  payment_mode: 'test',
})
console.log(created.key)  // 전체 키값은 이때만 볼 수 있다

// 결제 내역 — management_scopes: ["payment:read"]
const payments = await ctx.cbAdmin.payment.list(ctx.appId, { status: 'paid', limit: 50 })

// 푸시 통계 — management_scopes: ["push:read"]
const stats = await ctx.cbAdmin.push.getStats(ctx.appId)

콘솔 JWT 로 브라우저/서버에서 직접 호출할 때는 같은 메서드를 cb.appMembers.*, cb.roles.*, cb.publicKey.* 로 쓴다 (스코프 대신 콘솔 RBAC 권한이 적용된다).

Types

GameState

interface GameState {
  roomId: string
  state: Record<string, unknown>  // Your game state
  version: number
  serverTime: number
  tickRate: number
  players: GamePlayer[]
}

GameDelta

interface GameDelta {
  fromVersion: number
  toVersion: number
  changes: Array<{
    path: string
    operation: 'set' | 'delete'
    value?: unknown
  }>
  tick: number
}

GamePlayer

interface GamePlayer {
  clientId: string
  joinedAt: number
  metadata?: Record<string, string>
}

ConnectionState

interface ConnectionState {
  status: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' | 'offline'
  reconnectAttempt: number
  lastError?: Error
  latency: number
}

Error Handling

import ConnectBase, { ApiError, AuthError } from 'connectbase-client'

try {
  await cb.auth.signInMember({ login_id, password })
} catch (error) {
  if (error instanceof ApiError) {
    // HTTP 응답 기반 에러: status/code/details 로 분기 가능
    if (error.statusCode === 429) {
      const details = error.details as { retry_after_seconds?: number } | undefined
      const retryAfter = details?.retry_after_seconds
      // ...
    }
  } else if (error instanceof AuthError) {
    // refresh 실패/토큰 만료
  }
}

// Game API 는 별도 이벤트 핸들러도 지원
gameClient.on('onError', (error) => {
  console.error('Game error:', error.message)
})

전역 에러 관찰자 (v1.9.0+)

ConnectBase 초기화 시 onError 옵션을 주면 모든 ApiError / AuthError 가 한 곳으로 모입니다. Sentry/Datadog 등 관측성 툴과 연결하기 쉽습니다.

const cb = new ConnectBase({
  publicKey: 'cb_pk_...',
  onError: (error) => {
    Sentry.captureException(error)
  },
})

요청 타임아웃 (v1.9.0+)

기본 30초 타임아웃이 모든 HTTP 호출에 적용됩니다. requestTimeoutMs 로 전역 기본값을 바꾸거나, 0 이하 값을 주면 비활성화할 수 있습니다.

const cb = new ConnectBase({
  publicKey: 'cb_pk_...',
  requestTimeoutMs: 60000, // 60s
})

Best Practices

State Synchronization

Use delta updates for efficient state synchronization:

gameClient.on('onDelta', (delta) => {
  // Apply only the changes instead of replacing entire state
  for (const change of delta.changes) {
    applyChange(localState, change.path, change.operation, change.value)
  }
})

Reconnection Handling

gameClient.on('onDisconnect', (event) => {
  if (event.code !== 1000) {
    // Show reconnecting UI
    showReconnectingMessage()
  }
})

gameClient.on('onConnect', () => {
  // Reconnected - request full state
  gameClient.requestState()
  hideReconnectingMessage()
})

Latency Compensation

// Measure latency periodically
setInterval(async () => {
  const rtt = await gameClient.ping()
  // Adjust client-side prediction based on latency
  updatePredictionOffset(rtt / 2)
}, 5000)

Examples

Simple Multiplayer Game

import ConnectBase from 'connectbase-client'

const cb = new ConnectBase({ publicKey: 'your-public-key' })
const game = cb.game.createClient({ clientId: `player-${Date.now()}` })

// Local player state
let localPlayer = { x: 0, y: 0 }

game
  .on('onConnect', () => console.log('Connected'))
  .on('onStateUpdate', (state) => {
    // Render all players
    renderPlayers(state.state.players)
  })
  .on('onDelta', (delta) => {
    // Efficient incremental updates
    for (const change of delta.changes) {
      updatePlayer(change.path, change.value)
    }
  })

// Connect and create room
await game.connect()
await game.createRoom({ maxPlayers: 8 })

// Game loop
function gameLoop() {
  // Read input
  const input = getPlayerInput()

  // Send action
  if (input.moved) {
    game.sendAction({
      type: 'move',
      data: { x: input.x, y: input.y }
    })
  }

  requestAnimationFrame(gameLoop)
}

gameLoop()

Chat Application

const game = cb.game.createClient({ clientId: userId })

game.on('onChat', (message) => {
  displayMessage(message.senderId, message.message, message.timestamp)
})

await game.connect()
await game.joinRoom('general-chat')

// Send message
chatInput.addEventListener('submit', () => {
  game.sendChat(chatInput.value)
  chatInput.value = ''
})

License

MIT