diff --git a/apps/studio/README.md b/apps/studio/README.md index 0cded81..d2d2771 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -9,11 +9,12 @@ presence list. It does not serve the bundle bytes. A stored build is loaded from Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`) and from the bucket at `/room/{filename}`. -| Method | Path | Purpose | -| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. | -| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished cloud build on the subroom's latest save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. | -| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. | +| Method | Path | Purpose | +| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. | +| `GET` | `/cloud-builds/get-build-count-for-user` | Authenticated user's Studio build totals for the current UTC-hour rate-limit window. | +| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished cloud build on the subroom's latest save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. | +| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. | ## Development diff --git a/apps/studio/src/local-builds.ts b/apps/studio/src/local-builds.ts index 247575e..0f6c74c 100644 --- a/apps/studio/src/local-builds.ts +++ b/apps/studio/src/local-builds.ts @@ -35,6 +35,16 @@ export interface StoredLocalBuild extends CloudBuildRow { subRoomDataSaveId: number } +export interface UserBuildCount { + accountId: number + startTime: string + endTime: string + subRoomTotalBuildCount: number + subRoomFailedBuildCount: number + avatarItemTotalBuildCount: number + avatarItemFailedBuildCount: number +} + interface BuildListRow { cloud_build_id: string started_at: string @@ -83,6 +93,36 @@ export async function listCloudBuilds( return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) } } +/** Build totals for the current UTC hour, matching Studio's rate-limit window DTO. */ +export async function getUserBuildCount( + db: D1Database, + accountId: number, + now = new Date() +): Promise { + const start = new Date(now) + start.setUTCMinutes(0, 0, 0) + const end = new Date(start.getTime() + 60 * 60 * 1000) + const row = await db + .prepare( + `SELECT COUNT(*) AS total, + SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed + FROM studio_cloud_build + WHERE created_by_account_id = ?1 + AND started_at >= ?2 AND started_at < ?3` + ) + .bind(accountId, start.toISOString(), end.toISOString()) + .first<{ total: number | string; failed: number | string | null }>() + return { + accountId, + startTime: start.toISOString(), + endTime: end.toISOString(), + subRoomTotalBuildCount: Number(row?.total ?? 0), + subRoomFailedBuildCount: Number(row?.failed ?? 0), + avatarItemTotalBuildCount: 0, + avatarItemFailedBuildCount: 0, + } +} + /** Base64 SHA-256 — the encoding `unity_asset.hash` (and every other bundle hash) uses. */ async function sha256Base64(bytes: ArrayBuffer): Promise { const digest = await crypto.subtle.digest('SHA-256', bytes) diff --git a/apps/studio/src/studio.app.ts b/apps/studio/src/studio.app.ts index 9ad2499..e5e6393 100644 --- a/apps/studio/src/studio.app.ts +++ b/apps/studio/src/studio.app.ts @@ -10,7 +10,7 @@ import { import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' -import { listCloudBuilds, storeLocalCloudBuild } from './local-builds' +import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' import { parseMultipart } from './multipart' import type { Context } from 'hono' @@ -195,6 +195,18 @@ const app = new Hono() }) ) + // Studio checks the authenticated user's counts before it starts a build. The + // 2026 editor treats a missing route as a fatal error and silently stops upload. + .get('/cloud-builds/get-build-count-for-user', async (c) => { + const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) + if (accountId == null) return c.body(null, 401) + return c.json({ + success: true, + error: null, + value: await getUserBuildCount(c.env.DB, accountId), + }) + }) + // Who of this room's co-owners is standing in this subroom right now. Studio // shows them as "Co-owners in Room" and reads a Result>: `success` // must be true and `error` null, or the editor throws; `value` is those diff --git a/apps/studio/src/test/integration/api.test.ts b/apps/studio/src/test/integration/api.test.ts index 3070085..e71c60d 100644 --- a/apps/studio/src/test/integration/api.test.ts +++ b/apps/studio/src/test/integration/api.test.ts @@ -123,6 +123,40 @@ it('serves a stubbed cloud build rate limit config', async () => { }) }) +it('requires authentication for the user build count', async () => { + const res = await SELF.fetch('https://example.com/cloud-builds/get-build-count-for-user') + expect(res.status).toBe(401) +}) + +it('serves the authenticated user build count expected by Studio 2026', async () => { + const res = await SELF.fetch('https://example.com/cloud-builds/get-build-count-for-user', { + headers: await bearer('11899'), + }) + expect(res.status).toBe(200) + const body = await res.json<{ + success: boolean + error: string | null + value: { + accountId: number + startTime: string + endTime: string + subRoomTotalBuildCount: number + subRoomFailedBuildCount: number + avatarItemTotalBuildCount: number + avatarItemFailedBuildCount: number + } + }>() + expect(body.success).toBe(true) + expect(body.error).toBeNull() + expect(body.value.accountId).toBe(11899) + expect(body.value.subRoomTotalBuildCount).toBe(0) + expect(body.value.subRoomFailedBuildCount).toBe(0) + expect(body.value.avatarItemTotalBuildCount).toBe(0) + expect(body.value.avatarItemFailedBuildCount).toBe(0) + expect(Date.parse(body.value.startTime)).not.toBeNaN() + expect(Date.parse(body.value.endTime)).not.toBeNaN() +}) + describe('GET /collaboration/owners-in-room', () => { const roomId = 1106 const subRoomId = 22941