// SERVER ONLY. Generated from the accompanying public OpenAPI contract. import { randomUUID } from 'node:crypto'; const policy = { "publicPrefix": "ggb_pk_sandbox_", "secretPrefix": "ggb_sk_sandbox_", "randomSuffixLength": 43, "linkTokenPrefix": "ggb_link_sandbox_", "linkTokenSuffixLength": 43, "configMaxPayoutMaximum": 1000000000000, "configDailyCapMaximum": 1000000000000, "configRuleRewardMaximum": 1000000000000, "errorCodes": [ "AUTH_UNAVAILABLE", "DEVELOPER_ACCESS_UNAVAILABLE", "DEVELOPER_INVITATION_REQUIRED", "EMAIL_VERIFICATION_REQUIRED", "INVALID_CONTENT_TYPE", "INVALID_CURSOR", "INVALID_PLAYER", "INVALID_PUBLIC_KEY", "INVALID_RECORD_QUERY", "INVALID_REQUEST", "INVALID_SESSION_ORIGIN", "PAYLOAD_TOO_LARGE", "PLATFORM_DISABLED", "PLATFORM_UNAVAILABLE", "UNAUTHORIZED", "already_linked", "conflict", "event_conflict", "forbidden", "game_limit", "game_paused", "insufficient_funds", "invalid_action", "invalid_event", "invalid_key", "invalid_link", "invalid_metadata", "invalid_player", "limit_exceeded", "link_disabled", "not_found", "owner_immutable", "project_limit", "retry_later", "secret_key_required", "unauthorized", "unlimited_funding", "user_unavailable" ], "eventsPath": "/api/game-sdk/v2/events", "linksPath": "/api/game-sdk/v2/links", "playerPath": "/api/game-sdk/v2/players/{playerId}", "configPath": "/api/game-sdk/v2/config", "eventFields": [ "eventId", "eventType", "metadata", "occurredAt", "playerId" ], "requiredEventFields": [ "eventId", "eventType", "occurredAt", "playerId" ], "metadata": { "maxUtf8Bytes": 4096, "maxDepth": 6, "maxArrayItems": 100, "maxObjectEntries": 100 } }; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function validLinkToken(token) { return typeof token === 'string' && token.startsWith(policy.linkTokenPrefix) && token.length === policy.linkTokenPrefix.length + policy.linkTokenSuffixLength && /^[A-Za-z0-9_-]+$/.test(token.slice(policy.linkTokenPrefix.length)); } function jsonValue(value, depth = 0) { if (depth > policy.metadata.maxDepth) throw new Error('Metadata is too deeply nested.'); if (value === null || typeof value === 'boolean') return; if (typeof value === 'string' && !/\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? jsonValue(item, depth + 1)); return; } if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { const entries = Object.entries(value); if (entries.length > policy.metadata.maxObjectEntries) throw new Error('Metadata contains too many entries.'); entries.forEach(([key, item]) => { jsonValue(key, depth); jsonValue(item, depth + 1); }); return; } throw new Error('Metadata must contain valid JSON values.'); } function validateEvent(event) { if (!event || typeof event !== 'object' || Array.isArray(event) || Object.keys(event).some(key => !policy.eventFields.includes(key)) || policy.requiredEventFields.some(key => !(key in event)) || typeof event.eventId !== 'string' || !uuid.test(event.eventId) || typeof event.playerId !== 'string' || !uuid.test(event.playerId) || typeof event.eventType !== 'string' || !/^[a-z][a-z0-9_.-]{0,63}$/.test(event.eventType) || /[\r\n]/.test(event.eventType) || typeof event.occurredAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(event.occurredAt) || !Number.isFinite(Date.parse(event.occurredAt))) throw new Error('Invalid event contract.'); if (event.metadata !== undefined) { if (!event.metadata || typeof event.metadata !== 'object' || Array.isArray(event.metadata)) throw new Error('Metadata must be an object.'); jsonValue(event.metadata); if (Buffer.byteLength(JSON.stringify(event.metadata), 'utf8') > policy.metadata.maxUtf8Bytes) throw new Error('Metadata exceeds the byte limit.'); } } // Invoke only after YOUR game server verifies the action. Persist this exact // string transactionally with that action, before attempting delivery. export function recordVerifiedEvent(playerId, eventType, metadata) { const event = { eventId: randomUUID(), playerId, eventType, occurredAt: new Date().toISOString(), ...(metadata === undefined ? {} : { metadata }) }; validateEvent(event); return JSON.stringify(event); } export class GameServerError extends Error { constructor(code, status, retryable, retryAfterSeconds = null) { super('ggbucks server request did not complete.'); this.code = code; this.status = status; this.retryable = retryable; this.retryAfterSeconds = retryAfterSeconds; } } export function createGameServerClient({ origin, secretKey, fetcher = fetch }) { const url = new URL(origin); if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('A fixed HTTPS origin is required.'); if (typeof secretKey !== 'string' || !secretKey.startsWith(policy.secretPrefix) || secretKey.length !== policy.secretPrefix.length + policy.randomSuffixLength || !/^[A-Za-z0-9_-]+$/.test(secretKey.slice(policy.secretPrefix.length))) throw new Error('A server secret key is required.'); const fixedOrigin = url.origin, fixedKey = secretKey; async function request(method, path, body) { let response; try { response = await fetcher(fixedOrigin + path, { method, body, cache: 'no-store', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(20000), headers: { Authorization: 'Bearer ' + fixedKey, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) } }); } catch { throw new GameServerError('NETWORK_UNAVAILABLE', 0, true); } let result; try { result = await response.json(); } catch { throw new GameServerError('INVALID_RESPONSE', response.status, response.status >= 500); } if (!response.ok) { const header = response.headers.get('retry-after'); const delay = header && /^\d+$/.test(header) ? Number(header) : header ? (Date.parse(header) - Date.now()) / 1000 : NaN; const retryAfterSeconds = Number.isFinite(delay) ? Math.min(300, Math.max(0, Math.ceil(delay))) : null; const code = typeof result?.error?.code === 'string' && policy.errorCodes.includes(result.error.code) ? result.error.code : 'REQUEST_FAILED'; throw new GameServerError(code, response.status, response.status === 429 || response.status >= 500, retryAfterSeconds); } if (!result?.data || result.error || result.data.mode !== 'sandbox') throw new GameServerError('INVALID_RESPONSE', response.status, false); return result.data; } return { async sendRecordedEvent(body) { const event = JSON.parse(body); validateEvent(event); const result = await request('POST', policy.eventsPath, body); if (result.eventId !== event.eventId || result.playerId !== event.playerId || result.eventType !== event.eventType || typeof result.occurredAt !== 'string' || Date.parse(result.occurredAt) !== Date.parse(event.occurredAt) || !['accepted', 'rejected'].includes(result.status) || typeof result.replayed !== 'boolean' || !Number.isSafeInteger(result.rewardGgbucks) || result.rewardGgbucks < 0 || !(result.reason === null || typeof result.reason === 'string')) throw new GameServerError('INVALID_RESPONSE', 502, false); return result; // Persist rejected decisions too. A replay is never a second grant. }, async createLinkIntent(externalPlayerId) { if (typeof externalPlayerId !== 'string' || !externalPlayerId.trim() || externalPlayerId.length > 200 || /[\u0000-\u001f\u007f]/.test(externalPlayerId)) throw new Error('Use the authenticated game account identifier.'); const result = await request('POST', policy.linksPath, JSON.stringify({ externalPlayerId })); if (typeof result.playerId !== 'string' || !uuid.test(result.playerId) || !validLinkToken(result.linkToken) || typeof result.expiresAt !== 'string' || !Number.isFinite(Date.parse(result.expiresAt))) throw new GameServerError('INVALID_RESPONSE', 502, false); return result; }, async getPlayerStatus(playerId) { if (typeof playerId !== 'string' || !uuid.test(playerId)) throw new Error('Use a game player UUID.'); const result = await request('GET', policy.playerPath.replace('{playerId}', encodeURIComponent(playerId))); if (result.playerId !== playerId || typeof result.linked !== 'boolean') throw new GameServerError('INVALID_RESPONSE', 502, false); return result; }, }; } // Only this fixed ggbucks-app route is exposed. The player approves in that // app; no method here accepts a player Clerk token or calls native approval. export function nativeConsentUrl(linkToken) { if (!validLinkToken(linkToken)) throw new Error('Invalid native consent link.'); return 'ggbucks://developer-game-link?linkToken=' + encodeURIComponent(linkToken); } // Construct on your server from its secret manager. Keep the stored event body // unchanged across a bounded queue retry; never regenerate its UUID or time.