import { Buffer } from 'node:buffer'; import type { CapabilityBillingReceiptV1 } from '../../shared/data-service'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; const MAX_JSON_BYTES = 1_048_576; const MAX_REQUEST_BYTES = 24 * 1024 * 1024; const MAX_CONTENT_BYTES = 32 * 1024 * 1024; const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u; const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'pending_review']); type FetchImplementation = typeof fetch; type AccessTokenGetter = typeof getValidWorksSquareAccessToken; type JsonRecord = Record; export interface GameResourceGeneration { readonly executionId: string; readonly releaseId: string; readonly projectId: string; readonly logicalOperationId: string; readonly kind: 'pixel' | 'hd'; readonly templateName: string; readonly status: 'reserved' | 'accepted' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'submission_unknown' | 'pending_review'; readonly outputCount: number; readonly pollIntervalSeconds?: number; readonly errorCode?: string; readonly billing: Extract; } export interface GameResourceContent { readonly bytes: Uint8Array; readonly fileName: string | null; readonly contentType: string; } export interface GameResourceGenerateInput { readonly releaseAdmissionId: string; readonly releaseId: string; readonly projectId: string; readonly logicalOperationId: string; readonly kind: 'pixel' | 'hd'; readonly templateName: string; readonly templateConfig: Readonly>; readonly requirement: string; readonly aspectRatio?: string; readonly temperature?: number; readonly jobName?: string; readonly modelName?: string; readonly resolution?: string; readonly hdRemoveBgMode?: string; readonly threadId?: string; readonly referenceFiles?: readonly { name: string; mimeType: string; dataBase64: string; }[]; } export class GameResourceClientError extends Error { constructor( readonly code: string, readonly status: number, readonly retryable: boolean, message: string, ) { super(message); this.name = 'GameResourceClientError'; } } export interface GameResourceClientOptions { readonly fetchImpl?: FetchImplementation; readonly getAccessToken?: AccessTokenGetter; readonly apiBaseUrl?: string; } function isRecord(value: unknown): value is JsonRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } function text(value: unknown, maximum: number): string | null { return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null; } function integer(value: unknown, minimum: number, maximum: number): number | null { return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum ? value as number : null; } function billing(value: unknown): Extract { if (!isRecord(value) || value.mode !== 'platform_metered') throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid'); const status = text(value.status, 32); const reservedPoints = text(value.reserved_points, 32); const actualPoints = value.actual_points === null || value.actual_points === undefined ? undefined : text(value.actual_points, 32) ?? undefined; const usageAmount = value.usage_amount === null || value.usage_amount === undefined ? undefined : integer(value.usage_amount, 0, Number.MAX_SAFE_INTEGER) ?? undefined; if (!status || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status) || !reservedPoints || !DECIMAL.test(reservedPoints) || (actualPoints !== undefined && !DECIMAL.test(actualPoints)) || (['settled', 'refunded'].includes(status) && actualPoints === undefined) || value.unit !== 'generation') { throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid'); } return { mode: 'platform_metered', status: status as Extract['status'], reserved_points: reservedPoints, ...(actualPoints === undefined ? {} : { actual_points: actualPoints }), ...(usageAmount === undefined ? {} : { usage_amount: usageAmount }), unit: 'generation', } as Extract; } function generation(value: unknown): GameResourceGeneration { if (!isRecord(value) || value.schema_version !== 1 || value.plugin_id !== 'makelore.game-resource') { throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid'); } const executionId = text(value.execution_id, 36); const releaseId = text(value.release_id, 36); const projectId = text(value.project_id, 36); const logicalOperationId = text(value.logical_operation_id, 128); const templateName = text(value.template_name, 200); const status = text(value.status, 32); const outputCount = integer(value.output_count, 0, 100); const poll = value.poll_interval_seconds === null || value.poll_interval_seconds === undefined ? undefined : integer(value.poll_interval_seconds, 1, 300) ?? undefined; const errorCode = value.error_code === null || value.error_code === undefined ? undefined : text(value.error_code, 128) ?? undefined; if (!executionId || !EXECUTION_ID.test(executionId) || !releaseId || !projectId || !logicalOperationId || (value.kind !== 'pixel' && value.kind !== 'hd') || !templateName || !status || !['reserved', 'accepted', 'running', 'succeeded', 'failed', 'cancelled', 'submission_unknown', 'pending_review'].includes(status) || outputCount === null) { throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid'); } return { executionId, releaseId, projectId, logicalOperationId, kind: value.kind, templateName, status: status as GameResourceGeneration['status'], outputCount, ...(poll === undefined ? {} : { pollIntervalSeconds: poll }), ...(errorCode === undefined ? {} : { errorCode }), billing: billing(value.billing), }; } async function readBounded(response: Response, maximum: number): Promise { const declared = response.headers.get('content-length'); if (declared && /^\d+$/u.test(declared) && Number(declared) > maximum) { await response.body?.cancel().catch(() => undefined); throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound'); } const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > maximum) throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound'); return bytes; } function errorFrom(status: number, payload: unknown): GameResourceClientError { const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; const code = text(detail?.error_code, 128) ?? 'plugin_backend_unavailable'; const retryable = status >= 500 || status === 429; const message = status === 401 ? 'Works Square sign-in is required' : status === 402 ? 'Token Point balance is insufficient' : status === 409 ? 'Hosted game-resource operation conflicts with current state' : status === 422 ? 'Hosted game-resource request is invalid' : 'Hosted game-resource service is unavailable'; return new GameResourceClientError(code, status, retryable, message); } function encodedBody(value: unknown): string { const result = JSON.stringify(value); if (Buffer.byteLength(result, 'utf8') > MAX_REQUEST_BYTES) { throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Hosted game-resource request is too large'); } return result; } export class GameResourceClient { private readonly fetchImpl: FetchImplementation; private readonly getAccessToken: AccessTokenGetter; private readonly apiBaseUrl: string; constructor(options: GameResourceClientOptions = {}) { this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken; this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); } async templates(input: { releaseId: string; releaseAdmissionId: string; kind: 'pixel' | 'hd' }): Promise { const query = new URLSearchParams({ release_id: input.releaseId, release_admission_id: input.releaseAdmissionId, kind: input.kind, }); const payload = await this.json('GET', `/api/plugins/v1/hosted/game-resource/templates?${query}`); if (!isRecord(payload) || payload.schema_version !== 1 || payload.kind !== input.kind || !Array.isArray(payload.templates) || payload.templates.length > 100 || payload.templates.some((item) => !text(item, 200))) { throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted template response is invalid'); } return payload.templates as string[]; } async generate(input: GameResourceGenerateInput): Promise { return generation(await this.json('POST', '/api/plugins/v1/hosted/game-resource/generations', { release_admission_id: input.releaseAdmissionId, release_id: input.releaseId, project_id: input.projectId, logical_operation_id: input.logicalOperationId, kind: input.kind, template_name: input.templateName, template_config: input.templateConfig, requirement: input.requirement, aspect_ratio: input.aspectRatio ?? '1:1', temperature: input.temperature ?? 0, ...(input.jobName ? { job_name: input.jobName } : {}), ...(input.modelName ? { model_name: input.modelName } : {}), ...(input.resolution ? { resolution: input.resolution } : {}), ...(input.hdRemoveBgMode ? { hd_remove_bg_mode: input.hdRemoveBgMode } : {}), ...(input.threadId ? { thread_id: input.threadId } : {}), reference_files: input.referenceFiles?.map((item) => ({ name: item.name, mime_type: item.mimeType, data_base64: item.dataBase64, })) ?? [], })); } async get(executionId: string): Promise { return generation(await this.json('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}`)); } async cancel(executionId: string): Promise { return generation(await this.json('POST', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/cancel`)); } async download(executionId: string, outputIndex?: number): Promise { const suffix = outputIndex === undefined ? '' : `?output_index=${outputIndex}`; const response = await this.send('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/content${suffix}`); if (!response.ok) { const bytes = await readBounded(response, MAX_JSON_BYTES).catch(() => new Uint8Array()); let payload: unknown = null; try { payload = JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; } catch { /* bounded generic error */ } throw errorFrom(response.status, payload); } const bytes = await readBounded(response, MAX_CONTENT_BYTES); const disposition = response.headers.get('content-disposition') ?? ''; const encoded = /filename\*=UTF-8''([^;]+)/iu.exec(disposition)?.[1]; return { bytes, fileName: encoded ? decodeURIComponent(encoded).slice(0, 200) : null, contentType: (response.headers.get('content-type') ?? 'application/octet-stream').slice(0, 128), }; } private async json(method: 'GET' | 'POST', path: string, body?: unknown): Promise { const response = await this.send(method, path, body); const bytes = await readBounded(response, MAX_JSON_BYTES); let payload: unknown; try { payload = bytes.byteLength ? JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown : null; } catch { throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted response is invalid'); } if (!response.ok) throw errorFrom(response.status, payload); return payload; } private async send(method: 'GET' | 'POST', path: string, body?: unknown): Promise { let token: string | null; try { token = await this.getAccessToken({ fetchImpl: this.fetchImpl }); } catch { token = null; } if (!token) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required'); const encoded = body === undefined ? undefined : encodedBody(body); const request = async (accessToken: string) => await this.fetchImpl(`${this.apiBaseUrl}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, ...(encoded === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(encoded === undefined ? {} : { body: encoded }), redirect: 'manual', signal: AbortSignal.timeout(35_000), }); let response: Response; try { response = await request(token); } catch { throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable'); } if (response.status !== 401) return response; await response.body?.cancel().catch(() => undefined); let refreshed: string | null; try { refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true }); } catch { refreshed = null; } if (!refreshed) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required'); try { response = await request(refreshed); } catch { throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable'); } if (response.status === 401) { await response.body?.cancel().catch(() => undefined); throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required'); } return response; } } export function isTerminalGameResourceStatus(status: string): boolean { return TERMINAL_STATUSES.has(status); }