[studio] add get-build-count-for-user route (#134)

Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
This commit is contained in:
Jake Vice
2026-10-16 21:51:25 -05:00
committed by GitHub
parent 2ba2ad3a4b
commit 02ca828a69
4 changed files with 93 additions and 6 deletions
+2 -1
View File
@@ -10,8 +10,9 @@ Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomI
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. |
| `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. |
+40
View File
@@ -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<UserBuildCount> {
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<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
+13 -1
View File
@@ -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<App>()
})
)
// 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<List<int>>: `success`
// must be true and `error` null, or the editor throws; `value` is those
@@ -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