Cloud Function API
Cloud Function API is a serverless function service that allows third-party applications to invoke pre-written JavaScript functions via HTTP requests. You can create and edit cloud functions in the management panel, then call them through the API.
Overview
| Item | Description |
|---|---|
| Base Path | /cf/api/ |
| Authentication | Basic (app mode) or Bearer (user mode) |
| Data Format | JSON |
| Function Quota | 50 per application by default; owners can request an increase up to 10,000 |
| Code Size Limit | 256 KB |
| Timeout Range | 5,000 ~ 30,000 ms |
| Memory Limit | 64 MB |
| Return Value Limit | 1 MB |
Authentication
The regular /cf/api/{folder}/{name} route is always authenticated, even when the target function has requireAuth=0. Anonymous callers must explicitly use one of these routes:
- Wrapped JSON:
/cf/api/public/{clientId}/{folder}/{name} - Raw HTTP, website downloads, and webhooks:
/cf/http/public/{clientId}/{folder}/{name}
requireAuth=0 allows anonymous identity at an explicit public route; it never silently turns the regular authenticated URL into a public URL.
Cloud Function API supports two authentication modes via the Authorization header:
App Mode (Basic)
Authenticate using the application's client_id and client_secret.
Authorization: Basic base64(client_id:client_secret)⚠️ Security Warning: The
client_secretgrants full administrative access to your application. Never embed it in client-side applications distributed to users. App mode should only be used in trusted server environments or internal development tools.Recommended Architecture: User-facing clients should use Bearer mode (user OIDC login) to call cloud functions. Inside cloud functions, use
caller+$permissionto verify identity and permissions, and$datato read/write data. This is the most secure integration pattern.
User Mode (Bearer)
Authenticate using the user's OAuth access_token. The cloud function can access caller information through the caller object.
Authorization: Bearer <access_token>Replay Protection
All API requests must include the following headers to prevent replay attacks:
| Header | Description |
|---|---|
X-Timestamp | Current Unix timestamp (seconds), must be within ±5 minutes of server time |
X-Nonce | Random unique string, 16-64 alphanumeric characters, different for each request |
X-Timestamp: 1719000000
X-Nonce: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6Core Concepts
Function Quota and Increase Requests
Applications can create 50 cloud functions by default. The management page and these endpoints return functionQuota with used, limit, remaining, maximumLimit, and the latest request:
GET /cf/manage/{clientId}/overview
GET /cf/manage/{clientId}/functionsThe application owner can request an increase. An administrator may approve a value lower than the request, but it must remain above the current limit:
POST /oauth/apps/{clientId}/cloud-function-limit/request
Authorization: Bearer <owner_access_token>
Content-Type: application/json
{
"requestedLimit": 100,
"reason": "Production functions are split by download, payment, and notification domains"
}Function creation and quota approval share an application-scoped lock so concurrent creates cannot cross the effective limit. The request uses the platform's existing service-review workflow and does not require developer-specific R2 or server configuration.
Cloud Functions
A cloud function is a piece of JavaScript code executed in a sandboxed environment. Each function has the following properties:
| Property | Type | Description |
|---|---|---|
name | string | Function name, unique within the application, only letters, numbers, underscores |
description | string | Function description |
folder | string | null | Folder path for organizing functions |
timeout | number | Timeout in ms, range 5,000 ~ 30,000; payment/network functions may use 30 seconds |
enabled | number | Whether enabled (1 = enabled) |
requireAuth | number | Whether authentication is required (1 = required, default; 0 = anonymous public access) |
invokeAccess | string | Invoke entry: http (default, allows HTTP/API and $invoke) or cloud_function ($invoke only) |
invokeMethod | string | HTTP invocation method: post (default), get, or both |
outputMode | string | API endpoint response shape: default (default), direct, or mixed |
requestMode | string | Input protocol: wrapped (default { params }) or explicit raw (third-party Webhook) |
rawHttpUrl | string | null | Generated public Raw URL returned by management APIs when requestMode=raw |
configVars | object | Readable non-sensitive configuration such as URLs, flags, and bundle IDs; accessible through env |
configVarsSource | string | JSONC source for ordinary configuration; preserves //, /* */ comments and trailing commas |
envVars | object | Backward-compatible write-only function Secrets; reads return __STARECHO_SECRET_SET__ placeholders only |
Use invokeAccess = cloud_function for private helper functions. In this mode, users, anonymous access, Basic/Bearer HTTP requests, and cron jobs cannot invoke the function directly; only $invoke from another cloud function can call it.
Write-only Secret Store
Application Secrets are shared independently of individual functions and are intended for Apple .p8 keys, API keys, webhook Secrets, and OAuth client Secrets. Management endpoints return only names, isSet, and timestamps:
GET /cf/manage/{clientId}/secrets
PUT /cf/manage/{clientId}/secrets/{NAME} # body: { "value": "..." }
DELETE /cf/manage/{clientId}/secrets/{NAME}The management UI accepts multiline Secret values, including pasted PEM and .p8 private keys. Input is masked by default, and line breaks are submitted and encrypted unchanged. The editor is cleared after a successful write, and management clients cannot read the plaintext back.
PUT responses are immediately redacted. Application- and function-level Secrets may use an empty string to register a Key whose value still requires manual configuration. Such entries return isSet=false; $secrets.has(NAME) is false, and $secrets.ref(NAME) reports that the Secret is not configured. The __STARECHO_SECRET_SET__ placeholder still preserves an existing function-level Secret. Delete application Secrets with the endpoint above, or pass deleteEnvVars: ['NAME'] when updating a function to explicitly delete legacy function-level Secrets. env.NAME remains available for backward compatibility, while managed signing APIs should use $secrets.ref('NAME'). Console logs, errors, and persisted execution records redact configured Secret values. HTTP and function-call results remain byte-preserving so presigned URLs, webhook signatures, ciphertext, and opaque tokens are not corrupted; function code must therefore never explicitly return a Secret value.
Key-only backup and restore
When cloud_function is selected for export, only the service selection is required:
POST /backup/manage/{clientId}/export
Content-Type: application/json
{
"services": ["cloud_function"]
}New backups write only application Secret Store and legacy function envVars names to cloud-function/secret-keys.json; actual values are never written to the archive. Restore creates empty Keys and returns requiredSecretConfiguration, grouped into application- and function-level lists, so the operator can configure each value manually. The management UI shows this list both before and after import.
StarEcho still imports legacy v2 cloud-function/secrets.enc.json archives. Only those old backups require their original multipart secretsPassphrase. A v1 backup has no Secret snapshot and preserves the target application's current application Secret Store.
Cron Rules
Cron rules periodically trigger cloud functions. Manage them through:
GET /cf/manage/{clientId}/cron
POST /cf/manage/{clientId}/cron
PUT /cf/manage/{clientId}/cron/{ruleId}
DELETE /cf/manage/{clientId}/cron/{ruleId}Common create/update fields:
| Field | Type | Description |
|---|---|---|
name | string | Optional display name for the cron rule |
functionUid | string | Primary target cloud function UID |
cronExpr | string | 5-field cron expression: minute hour day month weekday |
scheduleType | string | cron, workday, holiday, or festival |
scheduleConfig | string | Visual schedule config JSON string for multiple times, festivals, etc. |
functionTargets | string | Optional JSON array of target function UIDs; empty means only functionUid |
timeZone | string | IANA time zone, such as Asia/Shanghai or UTC |
params | object | JSON params merged into the cloud function params when the rule fires |
maxExecutions | number | Maximum execution count; 0 or omitted means unlimited |
enabled | number | For updates: 1 enabled, 0 disabled |
Cron executions run as app identity. The rule params are merged first, then system fields are overwritten:
{
...ruleParams,
__trigger: 'cron',
ruleId,
cronExpr,
scheduleType,
timeZone
}When the server is managed by PM2 in multi-instance or cluster mode, StarEcho uses a MySQL distributed lock plus a minute-level execution bucket. The same rule only executes once in the same minute, preventing duplicate triggers across instances.
Sandbox Environment
Cloud functions execute in an isolated Node.js Worker sandbox with the following characteristics:
- Security Isolation: Each execution runs in an independent VM context, 64 MB memory limit
- Timeout Control: Functions are terminated if they exceed the configured timeout
- Console Capture:
console.log/warn/error/infooutput is recorded in execution logs (up to 100 entries) - No File System Access: Cannot access the host file system
- Controlled Network Access: Built-in
fetch()for HTTP/HTTPS requests (internal network addresses are blocked) - Concurrency Limit: Up to 5 concurrent function instances per application
Function Context
Your function code can access the following global variables and built-in APIs:
| Variable | Type | Description |
|---|---|---|
params | object | Parameters passed by the caller (read-only) |
env | object | Merged configuration and Secrets (read-only; preserves existing env.KEY behavior) |
caller | object | Caller information (read-only) |
caller.mode | string | "user", "app", or "anonymous" |
caller.uid | string | undefined | User uid (only present in user mode) |
console | object | Logging output (log, warn, error, info) |
$data | object | Data collection built-in API (supports atomic operators, sorting, filtering — see below) |
$permission | object | Permission group built-in API (see below) |
$account | object | Account built-in API (fetch time-limited user avatar URL, see below) |
$storage | object | File storage built-in API (upload, download, delete, list — see below) |
$crypto | object | Digests, HMAC, AES-GCM, and ES256 JWS signing/verification |
$certificates | object | Fixed references to published, application-scoped JWS/X.509 policies |
$secrets | object | Opaque Secret references; management clients cannot read values back |
$appleIap | object | App Store Server API token creation and managed transaction queries |
$invoke | function | Invoke another cloud function in the same app; use withBasic for cross-app invocation (see below) |
fetch | function | HTTP requests (public addresses only) |
btoa / atob | function | Base64 encoding/decoding |
Standard JavaScript built-in objects are also available: JSON, Math, Date, Promise, Map, Set, RegExp, URL, Array, Object, String, Number, Boolean, Error, etc.
The following are blocked: setTimeout, setInterval, process, require, Buffer, global, globalThis, __dirname, __filename.
Writing Code
⚠️ Important: Do NOT define a
function main()or any entry function. Your code is automatically wrapped in anasyncIIFE — write your logic directly usingawaitandreturn.
// ✅ Correct: Write code directly
const name = params.name || 'World'
return { message: `Hello, ${name}!` }
// ❌ Wrong: Do NOT define a main function
// async function main(params, caller) { ... }
// The function will only be defined but never called, and its parameters will shadow the globalsReturn Values
The value of the last expression in the function is used as the return value. You can also use an explicit return statement. Return values are JSON-serialized and must not exceed 1MB.
// Example: Simple function
const greeting = params.name || 'World'
return { message: `Hello, ${greeting}!` }Built-in APIs
Cross-App Access
By default, $data, $permission, $storage, and $invoke access resources owned by the current cloud function's application. To access another application, create a target app client with withBasic(clientId, clientSecret). The platform validates the passed Client ID and Client Secret using Basic authentication and then performs the operation with the target application's app identity.
When the target app's collection, permission, storage operation, or invoked function should run as a user, call asUser(uid) on the current client or on the client returned by withBasic. The uid is the platform-wide user ID, so it can represent the same logged-in user across apps. It only affects that bridge call and does not mutate the current function's caller.
const targetClientId = env.PARTNER_APP_ID
const targetClientSecret = env.PARTNER_APP_SECRET
const partnerData = $data.withBasic(targetClientId, targetClientSecret)
const partnerPermission = $permission.withBasic(targetClientId, targetClientSecret)
const partnerStorage = $storage.withBasic(targetClientId, targetClientSecret)
const invokePartner = $invoke.withBasic(targetClientId, targetClientSecret)
const partnerUid = params.partnerUid || caller.uid
const profile = await partnerData.asUser(partnerUid).get('profiles', partnerUid)
const perm = await partnerPermission.asUser(partnerUid).checkPermission(partnerUid, 'trade.exchange')
const file = await partnerStorage.asUser(partnerUid).getDownloadUrl('public/rules.json')
const result = await invokePartner.asUser(partnerUid)('shared_exchange_rate', { from: 'gold', to: 'diamond' })
return { profile: profile.document, allowed: perm.hasPermission, fileUrl: file.url, result }Security note: Store the target app's
client_secretin the current cloud function's environment variables. Never ship it to clients or hard-code it in function source. The target app must be approved and must have the corresponding service enabled: Data, Permission, Storage, or Cloud Function.
$data — Data Collection Bridge
$data allows cloud functions to directly access Data API collections without making HTTP requests. The Data service must be enabled for the application in the management panel.
All methods return Promises and support await.
| Method | Description |
|---|---|
$data.get(collection, documentId) | Get a single document |
$data.set(collection, documentId, data, ownerUid?) | Create or overwrite a document |
$data.list(collection, options?) | List documents (supports sorting and filtering) |
$data.count(collection, options?) | Count documents matching optional filters |
$data.top(collection, indexName, options?) | Read top documents by an ordered index, useful for leaderboards |
$data.around(collection, indexName, documentId, options?) | Read the ranking window around a document by ordered index |
$data.me(collection, indexName, options?) | Read the current user's ranking window by ordered index |
$data.update(collection, documentId, data) | Partially update a document (supports operators) |
$data.delete(collection, documentId) | Delete a document |
$data.inc(collection, documentId, fields) | Atomic increment (shortcut for $data.update with $inc) |
$data.withBasic(clientId, clientSecret) | Create a client for another app's Data service; returns the same method set |
$data.asUser(uid) | Access the current app's Data service as a specific user UID; can also be chained after withBasic() for the target app |
options parameter (for list):
| Field | Type | Description |
|---|---|---|
ownerUid | string | Filter by owner |
limit | number | Items per page (default 50, max 100) |
offset | number | Offset |
sortBy | string | Sort by a field in data (e.g. 'score') |
sortOrder | string | Sort direction: 'asc' or 'desc' (default 'desc') |
where | object | Conditional filter (see below) |
ordered index options (for top / around / me):
| Field | Type | Description |
|---|---|---|
limit | number | Number of entries returned by top, default 50, max 100 |
offset | number | Pagination offset for top |
ownerUid | string | Filter top to one owner |
ownerUids | string | string[] | Filter top to multiple owners, useful for friend rankings |
partition | object | Filter by ordered-index partition, such as { groupId, variant } |
before | number | Entries before the target for around / me |
after | number | Entries after the target for around / me |
where conditional filter:
Filter documents by fields in their data JSON. Two forms are supported:
| Form | Syntax | Example |
|---|---|---|
| Equality | { field: value } | { level: 10 } — exact match |
| Operator | { field: { $op: value } } | { score: { $gt: 1000 } } |
Operators: $eq (equal), $ne (not equal), $gt, $gte, $lt, $lte
Multiple conditions are combined with AND logic:
// Single equality
await $data.list('players', { where: { level: 10 } })
// Multiple conditions (AND)
await $data.list('leaderboard', {
where: {
score: { $gt: 5000 },
tier: 'premium',
},
sortBy: 'score',
sortOrder: 'desc',
})
// Range query
await $data.list('products', {
where: {
price: { $gte: 100, $lte: 500 },
},
})
// Count with filters
const { count } = await $data.count('players', {
where: { level: { $gt: 5 } },
})The
wherefilter operates on thedataJSON column. Fields are validated as[a-zA-Z_][a-zA-Z0-9_]{0,63}to prevent SQL injection.
Return values:
| Method | Returns | Description |
|---|---|---|
$data.get() | { document: { documentId, ownerUid, data, createdAt, updatedAt } } | Full document with data content |
$data.list() | { documents[], total, limit, offset } | Metadata only — each document has documentId, ownerUid, createdAt, updatedAt (no data) |
$data.count() | { count } | Number of matching documents |
Note:
$data.list()does not return thedatafield to save bandwidth and performance. Use$data.get(collection, documentId)to retrieve the full document content.
If you need to page/sort first and then read full document content, list the document IDs first and fetch each document by ID:
const page = await $data.list('leaderboard', {
sortBy: 'score',
sortOrder: 'desc',
limit: 10,
})
const items = await Promise.all(page.documents.map(async ref => {
const full = await $data.get('leaderboard', ref.documentId).catch(() => null)
return full?.document
? { documentId: ref.documentId, ownerUid: ref.ownerUid, ...full.document.data }
: null
}))
return { items: items.filter(Boolean), total: page.total }update operators:
The data parameter of $data.update supports operator syntax for atomic updates:
| Operator | Description | Example |
|---|---|---|
$set | Set field values | { $set: { name: 'new' } } |
$unset | Remove fields | { $unset: { tempField: '' } } |
$inc | Numeric increment (negative to decrement) | { $inc: { score: 10, lives: -1 } } |
$push | Append element to array | { $push: { tags: 'new_tag' } } |
$pull | Remove matching element from array | { $pull: { tags: 'old_tag' } } |
When the data parameter contains keys starting with $, operator mode is activated automatically. Operators can be combined:
await $data.update('players', 'player_001', {
$inc: { score: 50, combo: 1 },
$push: { achievements: 'first_win' },
$set: { lastPlayedAt: new Date().toISOString() },
})Shortcut:
$data.inc(collection, documentId, fields)is equivalent to$data.update(collection, documentId, { $inc: fields })
Behavior when the document or field does not exist:
| Call | Missing document | Missing field |
|---|---|---|
$data.set(collection, documentId, data, ownerUid?) | Creates the document | Replaces the whole data object; missing business fields do not cause errors |
$data.update(collection, documentId, { field: value }) | Throws not_found | Adds the top-level field |
$data.update(..., { $set: { field: value } }) | Throws not_found | Adds the top-level field |
$data.update(..., { $inc: { field: 1 } }) | Throws not_found | Writes 0 + 1; the $inc value must be a number |
$data.update(..., { $push: { field: value } }) | Throws not_found | Creates an array and appends the value; non-array fields are treated as empty arrays |
$data.update(..., { $pull: { field: value } }) | Throws not_found | Missing or non-array fields are ignored |
$data.update updates top-level fields. { $set: { 'profile.name': 'A' } } writes a top-level field literally named "profile.name"; it does not traverse into profile.
Nested object updates:
For nested updates, read the document, modify the nested object in cloud-function code, then write back the changed top-level object:
function cloneJson(value) {
return JSON.parse(JSON.stringify(value || {}))
}
function setNested(data, path, value) {
const keys = String(path).split('.').filter(Boolean)
if (!keys.length) throw new Error('path is required')
let cursor = data
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) {
cursor[key] = {}
}
cursor = cursor[key]
}
cursor[keys[keys.length - 1]] = value
return keys[0]
}
async function updateNested(collection, documentId, path, value) {
const { document } = await $data.get(collection, documentId)
const data = cloneJson(document.data)
const rootKey = setNested(data, path, value)
await $data.update(collection, documentId, {
$set: { [rootKey]: data[rootKey] },
})
return { status: 'ok', documentId, path }
}
await updateNested('player_profile', caller.uid, 'settings.audio.bgmVolume', 80)This rewrites the changed top-level object. If multiple cloud functions concurrently modify the same top-level object, for example settings.*, the later write can overwrite the earlier one. For highly concurrent data, split fields into smaller top-level objects or separate documents.
Example: Anti-Cheat Score Upload
// Score is calculated server-side, client cannot tamper
const { level, combo, accuracy, playerUid } = params
if (!level || !combo || accuracy === undefined || !playerUid) {
return { error: 'missing_params' }
}
const score = level * 1000 + combo * 50 + Math.floor(accuracy * 500)
const existing = await $data.get('leaderboard', playerUid).catch(() => null)
const existingData = existing?.document?.data
if (existingData?.score >= score) {
return { updated: false, currentBest: existingData.score }
}
await $data.set('leaderboard', playerUid, {
score, level, combo, accuracy,
timestamp: new Date().toISOString(),
}, playerUid)
console.log(`Player ${playerUid} new high score: ${score}`)
return { updated: true, score }Example: User Settings
if (params.action === 'save') {
await $data.set('settings', caller.uid, {
theme: params.theme || 'default',
language: params.language || 'zh-CN',
notifications: params.notifications !== false,
})
return { saved: true }
} else {
const doc = await $data.get('settings', caller.uid).catch(() => null)
return doc?.document?.data || { theme: 'default', language: 'zh-CN', notifications: true }
}Example: Atomic Operations (Counter / Favorites)
const { playerUid, action, itemId } = params
if (action === 'like') {
// Atomically increment like count
await $data.update('posts', params.postId, {
$inc: { likes: 1 },
})
return { success: true }
}
if (action === 'collect') {
// Add item to user's favorites
await $data.update('favorites', playerUid, {
$push: { items: itemId },
$inc: { count: 1 },
})
return { success: true }
}
if (action === 'uncollect') {
// Remove item from favorites
await $data.update('favorites', playerUid, {
$pull: { items: itemId },
$inc: { count: -1 },
})
return { success: true }
}Example: Leaderboard Query
// Get top 10 players sorted by score descending
const topPage = await $data.list('leaderboard', {
sortBy: 'score',
sortOrder: 'desc',
limit: 10,
})
const top10 = await Promise.all(topPage.documents.map(async ref => {
const full = await $data.get('leaderboard', ref.documentId).catch(() => null)
return full?.document
? { documentId: ref.documentId, ...full.document.data }
: null
}))
// Multi-field filter: find players with high score and premium tier
const elitePage = await $data.list('leaderboard', {
where: {
score: { $gt: 5000 },
tier: 'premium',
},
sortBy: 'score',
sortOrder: 'desc',
})
const elitePlayers = await Promise.all(elitePage.documents.map(async ref => {
const full = await $data.get('leaderboard', ref.documentId).catch(() => null)
return full?.document
? { documentId: ref.documentId, ...full.document.data }
: null
}))
return {
top10: top10.filter(Boolean),
elitePlayers: elitePlayers.filter(Boolean),
}$permission — Permission Group Bridge
$permission allows cloud functions to directly access Permission API roles and permission data without making HTTP requests. The Permission service must be enabled for the application in the management panel.
All methods return Promises and support await.
| Method | Description |
|---|---|
$permission.listRoles() | List all roles |
$permission.getRole(name) | Get role details |
$permission.listRoleMembers(roleName, { page, limit }) | List members assigned to one role |
$permission.getUserRoles(uid) | Get a user's role list |
$permission.getUserPermissions(uid) | Get a user's merged permission list |
$permission.checkPermission(uid, permission) | Check if a user has a specific permission |
$permission.assignRole(uid, roleName) | Assign a role to a user |
$permission.removeRole(uid, roleName) | Remove a role from a user |
$permission.updateRolePermissions(roleName, { allow, deny }) | Update a role's allow/deny permission lists |
$permission.withBasic(clientId, clientSecret) | Create a client for another app's Permission service; returns the same method set |
$permission.asUser(uid) | Access the current app's Permission service as a specific user UID; can also be chained after withBasic() for the target app |
Cloud functions execute with app-level identity.
$dataand$permissionhave full access. Usecallerinfo in your code for access control.
Example: VIP Auto-Upgrade
const { playerUid } = params
const record = await $data.get('purchase_records', playerUid).catch(() => null)
const totalSpent = record?.document?.data?.totalSpent || 0
let targetRole = null
if (totalSpent >= 10000) targetRole = 'vip_gold'
else if (totalSpent >= 5000) targetRole = 'vip_silver'
else if (totalSpent >= 1000) targetRole = 'vip_bronze'
if (!targetRole) return { upgraded: false }
const userRoles = await $permission.getUserRoles(playerUid)
const hasRole = userRoles.roles?.some(r => r.name === targetRole)
if (hasRole) return { upgraded: false, currentRole: targetRole }
const vipRoles = ['vip_bronze', 'vip_silver', 'vip_gold']
for (const old of vipRoles) {
if (userRoles.roles?.some(r => r.name === old)) {
await $permission.removeRole(playerUid, old)
}
}
await $permission.assignRole(playerUid, targetRole)
console.log(`Player ${playerUid} upgraded to ${targetRole}`)
return { upgraded: true, newRole: targetRole }Example: Permission Check Middleware
// Check permissions before performing an action
const { action } = params
const permMap = {
'delete_post': 'post.delete',
'ban_user': 'user.ban',
'edit_config': 'config.edit',
}
const requiredPerm = permMap[action]
if (!requiredPerm) return { allowed: false, reason: 'unknown_action' }
const result = await $permission.checkPermission(caller.uid, requiredPerm)
return { allowed: result.hasPermission, permission: requiredPerm }$account — User Avatar
$account exposes read-only account queries. Currently it provides a single method, getAvatar(uid), which returns a user's avatar URL.
The returned URL is signed and time-limited (aligned to the hour boundary, expires within 1 hour) and can be handed directly to clients as an <img src="...">. Calling this API inside a cloud function does not require the storage/permission services to be enabled.
getAvatar(uid)
const result = await $account.getAvatar(uid)Parameters
| Name | Type | Required | Description |
|---|---|---|---|
uid | string | Yes | Target user's uid |
Return value
{
uid: string // echoed input
exists: boolean // whether the user exists AND has set a custom avatar; false means a default avatar was returned
url: string // signed absolute URL (http(s)://...), valid for up to 1 hour
expiresAt: number // signature expiration (Unix seconds, aligned to hour boundary)
}Example — fetch the caller's own avatar
if (!caller.uid) return { error: 'not_logged_in' }
const me = await $account.getAvatar(caller.uid)
return { avatar: me.url, expiresAt: me.expiresAt }Example — fetch any user's avatar by uid
const target = await $account.getAvatar(params.uid)
return {
avatar: target.url,
exists: target.exists,
expiresAt: target.expiresAt,
}Example — batch fetch (concurrent)
const uids = params.uids || []
const results = await Promise.all(uids.map(uid => $account.getAvatar(uid)))
return { avatars: results }Tip:
expiresAtis the signature expiration aligned to the hour boundary so clients/CDNs can cache the URL. If you need a longer-lived link, simply call the cloud function again from the client as expiration approaches.
$storage — File Storage Bridge
$storage allows cloud functions to directly access the Storage API for file operations without manually handling authentication, replay protection, or HTTP calls. The Storage service must be enabled for the application in the management panel.
All methods return Promises and support await.
| Method | Description |
|---|---|
$storage.getCapabilities() / getUsage() | Get effective limits, multipart capabilities, quota, reservations, and monthly URL-issuance diagnostics; actual transfer is explicitly null when unavailable |
$storage.getUploadUrl(path, size, contentType?, options?) | Get a single-upload URL and one-time token; old calls remain compatible |
$storage.confirmUpload(path, token) | Confirm upload with token, record metadata, update quota |
$storage.createMultipartUpload(path, options) | Create a resumable multipart session |
$storage.getMultipartUploadUrls(path, uploadId, partNumbers) | Batch issue part PUT URLs |
$storage.listMultipartUploadParts(path, uploadId) | List provider-received parts |
$storage.completeMultipartUpload(path, uploadId, options) | Validate parts and complete idempotently |
$storage.abortMultipartUpload(path, uploadId) | Abort an unfinished session |
$storage.listMultipartUploads(options?) | List multipart sessions |
$storage.getDownloadUrl(path, options?) | Get GET and HEAD URLs with response overrides |
$storage.head(path) | Read authoritative object-store metadata |
$storage.delete(path) | Delete a file |
$storage.list(options?) | List files (supports prefix and pagination) |
$storage.getMeta(path) | Get file metadata |
$storage.withBasic(clientId, clientSecret) | Create a client for another app's Storage service; returns the same method set |
$storage.asUser(uid) | Access the current app's Storage service as a specific user UID; can also be chained after withBasic() for the target app |
Method parameters:
| Method | Parameter | Type | Required | Description |
|---|---|---|---|---|
getUploadUrl | path | string | ✅ | File path, 1–512 characters |
size | number | ✅ | Final PUT body byte count; default limit 1 GiB, query getCapabilities() | |
contentType | string | ❌ | MIME type, defaults to application/octet-stream | |
options | object | ❌ | sha256, metadata, allowOverwrite, expiresIn | |
confirmUpload | path | string | ✅ | File path (same as upload) |
token | string | ✅ | Upload token returned by getUploadUrl | |
getDownloadUrl | path | string | ✅ | File path |
options | object | ❌ | expiresIn, downloadName, contentType, cacheControl | |
delete | path | string | ✅ | File path |
list | options.prefix | string | ❌ | Path prefix filter |
options.limit | number | ❌ | Items per page, default 50, max 200 | |
options.page | number | ❌ | Page number, default 1 | |
getMeta | path | string | ✅ | File path |
Return values:
getUploadUrl returns { url, token, expiresIn, fullPath, requestedSize, contentType, contentLengthBound, requiredHeaders }:
| Field | Type | Description |
|---|---|---|
url | string | Presigned PUT URL (1 hour by default; use expiresIn) |
token | string | One-time upload token (64-char hex), required for confirmUpload |
expiresIn | number | URL lifetime (seconds) |
fullPath | string | Full path inside the storage bucket |
requestedSize | number | Expected byte count submitted when requesting the URL |
contentType | string | MIME type used when requesting the URL |
contentLengthBound | boolean | Whether the signature binds the exact HTTP body length |
requiredHeaders | object | Exact length and MIME request headers required when binding is enabled |
getDownloadUrl returns { url, headUrl, expiresIn, size, contentType, etag, sha256, sha256Verification, sha256VerifyStatus }. The GET URL supports Range; use the separate headUrl for HEAD because SigV4 binds the HTTP method. Default-R2 objects are promoted from declared to platform_verified only after the background worker streams and hashes the full object.
| Field | Type | Description |
|---|---|---|
url | string | Presigned GET URL (valid for 1 hour) |
headUrl | string | Concurrently issued HEAD-only URL; it is not interchangeable with the GET URL |
expiresIn | number | URL lifetime (seconds) |
size | number | File size (bytes) |
contentType | string | MIME type |
list returns { total, files } where each item in files includes path, size, contentType, uploadedBy, createdAt, updatedAt.
getMeta returns { path, size, contentType, uploadedBy, createdAt, updatedAt }.
delete returns { status: 'ok' }.
⚠️ Note:
$storage.getUploadUrl()returns a presigned URL and one-time token; it does not upload the file. New calls may pass a fourth{ bindContentLength: true }argument to bind the exact byte count; omitting it preserves legacy compatibility. Use the URL unchanged. After PUT succeeds, call$storage.confirmUpload(); StarEcho reads the authoritative size and type withHeadObjectbefore updating metadata and quota. Since$storage.confirmUpload(path, token)does not passsize/contentType, the actual object-store values are adopted, so cloud-function and legacy callers do not need to match a declared size exactly. Multipart is recommended above 100 MiB; see the Storage API for the complete flow.
Example: Complete Upload Flow
The cloud function receives file content from the client, uploads it, and confirms:
const { fileName, fileData } = params
// fileData: base64-encoded file content
// 1. Build the final binary body; byteLength is the upload size
const binaryData = Uint8Array.from(atob(fileData), c => c.charCodeAt(0))
// 2. Get upload URL and token
const uploadRes = await $storage.getUploadUrl(
`uploads/${fileName}`,
binaryData.byteLength,
params.contentType,
{ bindContentLength: true },
)
const { url, token } = uploadRes
// 3. PUT the file to the presigned URL (cloud function fetch can reach public addresses)
await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': params.contentType || 'application/octet-stream' },
body: binaryData.buffer,
})
// 4. Confirm upload with token
const result = await $storage.confirmUpload(`uploads/${fileName}`, token)
return { uploaded: true, path: result.path, size: result.size }Example: File Download & Distribution
// Get a download link to hand to the client
const { url, expiresIn, size, contentType } = await $storage.getDownloadUrl(params.filePath)
return { downloadUrl: url, expiresIn, size, contentType }Example: List User-Related Files
// List all files under a user-specific prefix
const { total, files } = await $storage.list({
prefix: `users/${caller.uid}/`,
limit: 20,
page: 1,
})
// Each file contains: path, size, contentType, uploadedBy, createdAt, updatedAt
return {
total,
files: files.map(f => ({
path: f.path,
size: f.size,
contentType: f.contentType,
createdAt: f.createdAt,
})),
}Example: Clean Up Expired Files
const { files } = await $storage.list({ prefix: 'temp/', limit: 200 })
const now = Date.now() / 1000
const expiredFiles = files.filter(f => f.createdAt && now - Number(f.createdAt) > 86400 * 7)
for (const file of expiredFiles) {
await $storage.delete(file.path)
console.log(`Deleted expired file: ${file.path}`)
}
return { cleaned: expiredFiles.length }Example: User Avatar Storage
// Receive an avatar upload from a client and store it in the user's own directory
if (!caller.uid) return { error: 'not_logged_in' }
const { imageData, contentType } = params
const path = `avatars/${caller.uid}/${Date.now()}.png`
const uploadRes = await $storage.getUploadUrl(path, params.imageSize, contentType)
const { url, token } = uploadRes
const bytes = Uint8Array.from(atob(imageData), c => c.charCodeAt(0))
await fetch(url, { method: 'PUT', body: bytes.buffer })
const result = await $storage.confirmUpload(path, token)
return { path: result.path, size: result.size }$crypto, $certificates, $secrets, and $appleIap
Existing $crypto.hash, $crypto.hmac, and $crypto.timingSafeEqual behavior is unchanged. ES256 signing remains synchronous:
const jwt = $crypto.jwsSign({
algorithm: 'ES256',
key: $secrets.ref('PARTNER_SIGNING_PRIVATE_KEY'),
header: { kid: env.PARTNER_SIGNING_KEY_ID, typ: 'JWT' },
payload,
})For JWS values carrying an x5c chain, use a fixed version of an application-owned, published JWS/X.509 policy. The policy defines its certificate bundle, chain constraints, validation time, expected claims, and revocation behavior. The runtime does not select a hidden trust store by provider name:
const verified = await $crypto.jwsVerify({
signedPayload,
algorithm: 'ES256',
policy: $certificates.policy('partner_webhook_jws_v1', 1),
})Policy verification can perform OCSP requests and therefore always requires await. Policy names and versions must appear directly in a literal $certificates.policy('name', version) call so the runtime loads only explicitly pinned resources; do not first place them in variables or read them from environment/request values. The old trustStore, expected, and validateAt options have been removed with no compatibility fallback. A direct key: $secrets.ref('PUBLIC_KEY') remains available for fixed-public-key signature verification, but it does not validate an x5c chain, policy claims, or revocation state.
Verification failures expose code, retryable, stage, and category. Return 503 for retryable === true dependency failures so the sender can retry, and 400 for permanent signature, certificate, claim, or configuration failures. Explicit OCSP revoked and unknown statuses are permanent rejections. Do not classify failures by parsing error messages.
Application owners manage immutable, versioned certificate resources with these authenticated Cloud Function management routes:
GET/POST /cf/manage/{clientId}/certificate-bundlesGET/PUT/DELETE /cf/manage/{clientId}/certificate-bundles/{bundleId}POST /cf/manage/{clientId}/certificate-bundles/{bundleId}/publishor/deprecateGET/POST /cf/manage/{clientId}/jws-x509-policiesGET/PUT/DELETE /cf/manage/{clientId}/jws-x509-policies/{policyId}POST /cf/manage/{clientId}/jws-x509-policies/{policyId}/publishor/deprecate
Application owners and platform administrators can write. Collaborators with Cloud Function view access can only read. Every create, update, publish, deprecate, and delete is written to the application audit log; certificate bundle audits contain SHA-256 fingerprints, not complete PEMs.
Create and publish a certificate bundle first, then create a policy whose certificateBundleId references the returned bundle.id. The platform allocates versions. Published definitions cannot be edited in place; publish a new version, update the literal version in the cloud function, validate it, and only then deprecate the old version. A provider-neutral policy body can look like this:
{
"name": "partner_webhook_jws_v1",
"certificateBundleId": "<published-bundle-uuid>",
"definition": {
"allowedAlgorithms": ["ES256"],
"minChainLength": 2,
"maxChainLength": 4,
"leaf": { "ca": false, "keyTypes": ["ec"] },
"validationTime": {
"mode": "payloadClaim",
"path": "signedAt",
"unit": "milliseconds",
"maxFutureSkewMs": 300000
},
"revocation": {
"mode": "ocsp",
"targets": "leaf",
"unavailable": "retryableError"
},
"expectedClaims": [
{ "path": "audience", "equals": "my-application", "required": true }
]
}
}expectedClaims uses strict type matching and supports equals, oneOf, presence/absence checks, and conditional requiredWhen rules. Put trusted application identities, environments, and allowed business identifiers into the published policy instead of accepting them with each request.
The managed transaction API performs the documented Production-to-Sandbox fallback only for Apple error 4040010, applies a per-request timeout and limited GET retries, verifies the returned JWS, and returns an explicit environment:
const result = await $appleIap.getTransactionInfo({
transactionId: params.transactionId,
issuerIdSecret: 'APPLE_IAP_ISSUER_ID',
keyIdSecret: 'APPLE_IAP_KEY_ID',
privateKeySecret: 'APPLE_IAP_PRIVATE_KEY_P8',
bundleId: env.APPLE_IAP_BUNDLE_ID,
policy: $certificates.policy('apple_iap_transaction_v1', 1),
environment: 'auto',
requestTimeoutMs: 8000,
retries: 1,
})The template prefers the APPLE_IAP_PRIVATE_KEY_P8 Secret name and also accepts the existing APPLE_IAP_PRIVATE_KEY name. privateKeySecret may reference any valid Secret name.
App Store Server Notifications V2 does not require a provider-specific verification branch. Verify the outer notification, nested transaction, and nested renewal separately with explicit, fixed policy references:
const outer = await $crypto.jwsVerify({
signedPayload: request.body.signedPayload,
algorithm: 'ES256',
policy: $certificates.policy('apple_iap_notification_v1', 1),
})
const data = outer.payload.data || {}
const transaction = data.signedTransactionInfo
? (await $crypto.jwsVerify({
signedPayload: data.signedTransactionInfo,
algorithm: 'ES256',
policy: $certificates.policy('apple_iap_transaction_v1', 1),
})).payload
: null
const renewal = data.signedRenewalInfo
? (await $crypto.jwsVerify({
signedPayload: data.signedRenewalInfo,
algorithm: 'ES256',
policy: $certificates.policy('apple_iap_renewal_v1', 1),
})).payload
: nullProduction requires APPLE_IAP_APP_APPLE_ID to be a positive integer and to strictly equal the verified numeric data.appAppleId; Sandbox may omit it. Bundle ID, allowed products, environment, and App Apple ID should also be static expectedClaims in the corresponding published policies. Return 503 when any layer fails with error.retryable === true, or 400 for a permanent failure, preserving code, retryable, stage, and category in the response. The “Apple IAP Server Verification” cloud-function template demonstrates idempotent event storage with $data.transaction and createOnce without persisting complete JWS values.
$invoke — Inter-Function Invocation
$invoke allows a cloud function to synchronously call another cloud function within the same app and receive its return value. Use withBasic to invoke cloud functions in another app; use asUser(uid) to make the invoked function receive caller.mode === 'user' and the provided caller.uid.
const result = await $invoke(functionNameOrPath, params?, folder?)| Parameter | Type | Description |
|---|---|---|
functionNameOrPath | string | Target function name, or a folder/functionName path |
params | object | Parameters to pass (optional) |
folder | string | Target function's folder path (optional); omit it when the first argument already contains the path |
Return value: The target function's return value directly.
Cross-app invocation:
const invokePartner = $invoke.withBasic(env.PARTNER_APP_ID, env.PARTNER_APP_SECRET)
const result = await invokePartner.asUser(caller.uid)('get_exchange_rate', { from: 'gold', to: 'diamond' }, 'shared')
// Equivalent:
// const result = await invokePartner('shared/get_exchange_rate', { from: 'gold', to: 'diamond' })Limits:
- Up to 10 invocations per function execution
- Max nesting depth: 5 levels (function A → B → C → D → E)
- By default, the target function must be enabled and within the same app
- For cross-app calls, use
$invoke.withBasic(clientId, clientSecret); the target app must be approved and have Cloud Function enabled
Example: Reuse shared logic
// Function: create_order
const { items, buyerUid } = params
// Call shared validation function
const validation = await $invoke('validate_items', { items })
if (!validation.valid) return { error: validation.reason }
// Call pricing function
const pricing = await $invoke('calculate_price', { items }, 'utils')
// Or:
// const pricing = await $invoke('utils/calculate_price', { items })
return { orderId: 'xxx', total: pricing.total }Example: Cross-App Invocation
To call a cloud function in another application, use $invoke.withBasic() with the target app's Client ID and Client Secret. The platform validates the credentials using Basic authentication.
const targetClientId = env.PARTNER_APP_ID
const targetClientSecret = env.PARTNER_APP_SECRET
const invokePartner = $invoke.withBasic(targetClientId, targetClientSecret)
const result = await invokePartner('get_exchange_rate', {
from: 'gold',
to: 'diamond',
}, 'shared')
// Or invokePartner('shared/get_exchange_rate', { from: 'gold', to: 'diamond' })
const rate = result?.rate || 1
return { exchangeRate: rate }Tip: You can still call the external Cloud Function API with
fetch(), but the built-in$invoke.withBasic()path does not require manually buildingAuthorization,X-Timestamp, orX-Nonceheaders.
fetch — HTTP Requests
Cloud functions can use fetch() to make HTTP/HTTPS requests to public addresses.
Restrictions:
- Only
http://andhttps://protocols allowed - Internal network addresses blocked (
localhost,127.0.0.1,10.*,192.168.*,172.16-31.*, IPv6 loopback/link-local/ULA, etc.) timeoutMsis configurable from 1–15 seconds (default 10 seconds)- Only idempotent GET requests accept
retries: 0–2; other methods are never retried automatically - Response body limit: 1 MB
Return value:
| Field | Type | Description |
|---|---|---|
ok | boolean | Whether status code is 2xx |
status | number | HTTP status code |
text | string | Response text |
json | any | Auto-parsed JSON (undefined if parsing fails) |
headers | object | Response headers |
const resp = await fetch('https://api.example.com/data', {
timeoutMs: 8000,
retries: 1,
})
if (!resp.ok) return { error: `API returned ${resp.status}` }
return resp.jsonAPI Endpoints
Invoke Cloud Function
Supports nested folder paths:
POST /cf/api/{functionName}
POST /cf/api/{folder}/{functionName}
GET /cf/api/{functionName}
GET /cf/api/{folder}/{functionName}
Content-Type: application/jsonOnly functions with invokeAccess = http can be invoked through HTTP/API. Whether GET, POST, or both are accepted is controlled by the function's invokeMethod setting. The default is post.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
functionName | string | Function name |
folder | string | Optional, folder path |
Required Headers:
| Header | Description |
|---|---|
Authorization | Auth credentials (Basic / Bearer) |
X-Timestamp | Unix timestamp (seconds) |
X-Nonce | 16-64 character random string |
POST Request Body:
{
"params": {
"key": "value"
}
}
paramsis optional and defaults to an empty object{}.
GET Query Parameters:
?params=<JSON object>: for example?params=%7B%22platform%22%3A%22win64%22%7D- If
paramsis omitted, regular query parameters are passed asparams. For example,?platform=win64becomes{ "platform": "win64" }
Success Response:
{
"result": {
"message": "Hello, World!"
},
"logs": ["[LOG] Processing request..."],
"durationMs": 12
}Timeout Response (HTTP 400):
{
"error": "timeout",
"error_description": "Function execution timed out after 5000ms",
"logs": [],
"durationMs": 5000
}Execution Error Response (HTTP 400):
{
"error": "execution_error",
"error_description": "ReferenceError: foo is not defined",
"logs": ["[LOG] Starting..."],
"durationMs": 3
}Anonymous Invocation (Public Endpoint)
When a cloud function's requireAuth is set to 0, it can be invoked via the public endpoint with no authentication. This endpoint is intended for public-facing use cases that don't need to identify the caller (e.g. version check, public announcements, stateless calculations).
POST /cf/api/public/{clientId}/{functionName}
POST /cf/api/public/{clientId}/{folder}/{functionName}
GET /cf/api/public/{clientId}/{functionName}
GET /cf/api/public/{clientId}/{folder}/{functionName}
Content-Type: application/jsonThe public GET endpoint can be opened directly in a browser address bar, which is useful for read-only functions such as version checks and public announcements.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
clientId | string | The application's client_id (safe to expose; client_secret is NOT used) |
functionName | string | Function name |
folder | string | Optional folder path |
⚠️ Notes:
- Public endpoints do not require
Authorization,X-Timestamp, orX-Nonceheaders.- Inside the function,
caller.modeis"anonymous"andcaller.uidisundefined.- Only functions with
requireAuth=0can be invoked via this endpoint. If the function requires auth (requireAuth=1, the default), this endpoint returns403 auth_required.- Only functions with
invokeAccess=httpcan be invoked via this endpoint.invokeAccess=cloud_functionreturns403 invoke_access_denied.invokeMethodis still enforced; for example, a function configured asgetcannot be invoked withPOST.- Do NOT perform sensitive per-user operations (writing user data, deducting balance, granting rewards, etc.) inside anonymous functions.
API Endpoint Response Shape
Cloud functions can choose an outputMode in the function settings. This affects authenticated /cf/api/... and anonymous /cf/api/public/... response shapes. Raw HTTP endpoints /cf/http/public/... use custom HTTP response rules and are not affected by outputMode.
Assume the function returns:
return { state: 'Hello', version: 'Dev 0.45' }| outputMode | Response |
|---|---|
default | {"result":{"state":"Hello","version":"Dev 0.45"},"logs":[],"durationMs":28} |
direct | {"state":"Hello","version":"Dev 0.45"} |
mixed | {"logs":[],"durationMs":28,"state":"Hello","version":"Dev 0.45"} |
mixed only merges when the function returns a non-array object. If the function return value contains logs or durationMs, the function return value wins. Strings, numbers, arrays, and other non-object values fall back to the default wrapped shape.
Request parameters and response: GET uses query parameters, while POST uses the request body. The response shape is controlled by outputMode.
Example (direct browser access):
https://api.starecho.net/cf/api/public/abc123/version_checkExample (UE5 / direct client POST call):
curl -X POST https://api.starecho.net/cf/api/public/abc123/version_check \
-H "Content-Type: application/json" \
-d '{"params":{"platform":"win64"}}'This endpoint is designed for scenarios where it is inconvenient or unsafe to ship the application secret or a user token with the client, such as game client update checks and pre-login announcement queries.
Raw HTTP Public Functions (Third-Party Webhooks)
Use Raw HTTP for third-party Webhooks that do not send StarEcho's { "params": ... } envelope and need exact request bytes or real HTTP response status codes:
POST /cf/http/public/{clientId}/{functionName}
POST /cf/http/public/{clientId}/{folder}/{functionName}
GET /cf/http/public/{clientId}/{functionName}
GET /cf/http/public/{clientId}/{folder}/{functionName}The endpoint is available only when the function explicitly has requestMode="raw", requireAuth=0, and invokeAccess="http"; invokeMethod and the normal enabled/service/rate/ timeout checks still apply. Management API responses and VS Code extension/MCP tools can create, read, and update requestMode, and return the ready-to-use rawHttpUrl. Wrapped functions receive 403 raw_http_disabled on the Raw endpoint.
The function receives this request context:
| Field | Description |
|---|---|
method | Actual GET or POST method |
headers | Headers with lowercase keys and preserved values |
query | Parsed query parameters |
rawBody | UTF-8 view of the exact body captured before parsing |
rawBodyBase64 | Base64 of the exact bytes; use this for binary protocols |
rawBodyLength | Exact byte length |
body | Parsed JSON, text, or null |
ip | Source IP, preferring trusted proxy/Cloudflare headers |
path | Request path without query |
url | Actual URL including query |
The platform never reconstructs rawBody with JSON.stringify(request.body). Raw bodies have a 1 MiB limit; the Fastify route returns a real HTTP 413 when exceeded.
const authorization = String(request.headers?.authorization || '')
const signature = authorization.match(/^Signature\s+([a-f0-9]+)$/i)?.[1] || ''
const expected = $crypto.hash('sha1', request.rawBody + env.WEBHOOK_SECRET, 'hex')
if (!$crypto.timingSafeEqual(signature, expected)) {
return { statusCode: 400, body: { error: 'invalid_signature' } }
}
return { statusCode: 204, body: null }The built-in “Generic JWS/X.509 Policy Verification”, “Apple IAP Server Verification”, and “Xsolla Raw Webhook” templates select compatible Raw, anonymous HTTP, POST-only defaults. The Apple template separately awaits fixed published policies for the outer notification, transaction, and renewal JWS, returns 503 for retryable dependency failures or 400 for permanent failures, and only then stores notificationUUID idempotently. The Xsolla template verifies Authorization: Signature <hex> against SHA-1 of the exact body plus its Secret and returns real 204/400 statuses.
Deployment: A database administrator should separately run
scripts/database/20260725-add-cloud-function-secrets.sqlfrom the source repository. Database migrations are excluded fromdist/and production deployment archives. SetSITE_API_URL=https://api.starecho.netin production so management APIs return a publicrawHttpUrl. Cloudflare rules must cover/cf/http/; the provided nginx configuration already proxies it throughlocation ^~ /cf/.
A Raw function can return { statusCode, headers, body }; the gateway applies all three to the actual HTTP response. Other return values use status 200. outputMode does not affect Raw responses. Runtime failures return real 500 responses and timeouts return real 504 responses, while existing /cf/api/... protocols and response shapes remain unchanged.
Error Responses
All error responses follow a unified format:
{
"error": "error_code",
"error_description": "Human-readable error message"
}Common Error Codes:
| Error Code | HTTP Status | Description |
|---|---|---|
unauthorized | 401 | Missing Authorization header |
invalid_client | 401 | Invalid Basic credentials |
invalid_token | 401 | Invalid Bearer token |
replay_detected | 403 | Request replay or expired timestamp |
service_not_enabled | 403 | Cloud Function service not enabled for this application |
invoke_access_denied | 403 | Function is configured for internal $invoke calls only |
raw_http_disabled | 403 | Function has not explicitly enabled requestMode=raw |
auth_required | 403 | Function does not allow anonymous invocation (requireAuth=1) |
method_not_allowed | 405 | The HTTP method is not allowed by the function's invokeMethod setting |
disabled | 403 | Function is disabled |
not_found | 404 | Function not found |
payload_too_large | 413 | Raw HTTP body exceeds 1 MiB |
timeout | 400 / 504 | 400 on legacy endpoints; real 504 on Raw HTTP |
execution_error | 400 / 500 | 400 on legacy endpoints; real 500 on Raw HTTP |
Complete Examples
Using Basic Authentication
const res = await fetch(`${API_BASE}/cf/api/calculate_score`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
'X-Timestamp': Math.floor(Date.now() / 1000).toString(),
'X-Nonce': crypto.randomUUID().replace(/-/g, ''),
},
body: JSON.stringify({
params: { level: 5, combo: 120, accuracy: 0.95 }
}),
})
const data = await res.json()
console.log(data.result.score)Using Environment Variables
Configure ordinary values such as URLs, environment names, and feature flags in the management panel. Put API keys in application- or function-level Secrets.
The configuration editor expects a JSONC object, not .env-style KEY=value lines. // and /* */ comments and trailing commas are supported and preserved across saves and backup restores. Object keys become properties on the function's env object:
Before deploying this capability, run database_migrations/20260731-expand-cloud-function-config-vars.sql to expand encrypted JSONC source storage to MEDIUMTEXT. The migration does not modify existing configuration values.
{
// Disable the test payment entry before production release
"ENABLE_TEST_PAYMENT": false,
"WEBHOOK_URL": "https://example.com/hook",
}Read values with env.KEY:
const message = `Player ${caller.uid} completed level ${params.level}`
console.log(`Sending notification: ${message}`)
return {
notified: true,
webhook: env.WEBHOOK_URL,
message
}Caller Identity Logic
if (caller.mode === 'user') {
return { greeting: `Welcome, player ${caller.uid}!` }
} else {
return { greeting: 'Server-to-server call' }
}Recommended Pattern: User Auth + Permission Check + Data Operations
This is the recommended cloud function calling pattern for game clients distributed to users. The client uses Bearer mode (user access_token) to call cloud functions — no need to embed client_secret in the client.
Developers do not need to verify the
access_tokenthemselves. The platform automatically verifies the JWT signature and expiration before the cloud function executes. If the token is invalid or expired, the request is intercepted and returns401 invalid_token— the cloud function code never runs. Developers only need to handle business-level permission checks.
Client-side call:
// Client code (game/frontend) — uses the user's access_token
const res = await fetch(`${API_BASE}/cf/api/submit_score`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, // token obtained after user login
'X-Timestamp': Math.floor(Date.now() / 1000).toString(),
'X-Nonce': crypto.randomUUID().replace(/-/g, ''),
},
body: JSON.stringify({
params: { level: 5, score: 9800 }
}),
})Cloud function code:
// The caller object is automatically injected by the platform, token already verified
// caller.mode = "user"
// caller.uid = "user uid"
// caller.clientId = "app ID"
// ✅ Only check business permissions — no need to verify the token
const { allowed } = await $permission.checkPermission(caller.uid, 'leaderboard.submit')
if (!allowed) {
return { error: 'no_permission', message: 'No leaderboard submit permission' }
}
// Business logic: store the score
await $data.set('leaderboard', caller.uid, {
level: params.level,
score: params.score,
submittedAt: new Date().toISOString(),
}, caller.uid)
return { success: true, message: 'Score submitted' }Combined $data + $permission Example
// Example: Inventory grant system with permission checking
const { adminUid, targetUid, itemId, quantity } = params
const check = await $permission.checkPermission(adminUid, 'inventory.grant')
if (!check.hasPermission) {
return { error: 'Insufficient permissions', required: 'inventory.grant' }
}
const bag = await $data.get('inventory', targetUid).catch(() => null)
const items = bag?.document?.data?.items || {}
items[itemId] = (items[itemId] || 0) + quantity
await $data.set('inventory', targetUid, {
items, lastModified: new Date().toISOString()
}, targetUid)
console.log(`Admin ${adminUid} granted ${quantity}x ${itemId} to ${targetUid}`)
return { success: true, itemId, quantity, newTotal: items[itemId] }