connectbase-client
v6.4.0
Published
Connect Base JavaScript/TypeScript SDK for browser and Node.js
Maintainers
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-clientKey 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 toX-Public-Key; without it they return 401SECRET_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.cbAdminwith least-privilegemanagement_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 nameconnectbase-client. This package installs two bins —connectbaseandconnectbase-client— butconnectbaseis a bin name, not a package name. npx checks the localnode_modules/.binfirst and otherwise fetches a package by that name, sonpx connectbase initonly 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 atPATH.Use the short
connectbaseafter installing: globally, insidenpm runscripts, or vianode_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 deployThe 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
.connectbasercconfig file (holds both keys) - Add
.connectbasercto.gitignore - Add a
deployscript topackage.json(includesbuildif 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 50The 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"
}⚠️
.connectbasercnow holds a Secret Key — keep it in.gitignore(initadds it) and never commit it. On a shared machine or in CI, dropsecretKeyfrom the file and passCONNECTBASE_SECRET_KEYinstead.
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 ./distIn 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.htmlmust 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
deployprints 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, setbase: '/'invite.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' | nullv5 → 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 topredicate - Returns
undefinedfrompredicate→ waitintervalMsand retry - Returns a value from
predicate→ resolve immediately with that value - HTTP
5xx/ network error → retry. HTTP4xx→ reject (job-level error) timeoutMsexceeded orsignalaborted → 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
RequestInfofor a customfetch()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 가 요청 전에 예외를 던진다.
함수에서 쓰려면 두 가지가 필요하다:
- 함수 생성 시
service_role: true— 런타임이ctx.cbAdmin을 주입한다 (RLS 우회, 이 앱 스코프). 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
