3 Commits

Author SHA1 Message Date
Devin Zuczek 5010f62371 add subroom permissions 2026-08-03 15:16:10 -04:00
Devin Zuczek b3f1d04823 add invention blob hash which does not seem to help 2026-08-01 12:15:56 -04:00
Devin Zuczek 55cb769de9 add github workflow to test 2026-07-31 14:03:35 -04:00
12 changed files with 682 additions and 69 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Test
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
name: Regression tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Install just
uses: extractions/setup-just@v3
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: just install
- name: Test
run: just test
+3
View File
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
// Image bucket (shared with the `img` worker, which serves objects back by // Image bucket (shared with the `img` worker, which serves objects back by
// key). Uploaded saved images are written here. // key). Uploaded saved images are written here.
IMAGES: R2Bucket IMAGES: R2Bucket
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
// here only to hash an invention's uploaded data blob under `invention/`.
CDN_ASSETS: R2Bucket
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to // SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes. // push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub> RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+56 -5
View File
@@ -130,6 +130,37 @@ function inventionBlobName(filename: string): string {
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv` return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
} }
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
function toBase64(bytes: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
}
/**
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
* upload time, so this is normally a HEAD with no body transfer; a blob stored
* before that (or by anything else) is downloaded and digested instead.
*
* Null when the blob isn't in the bucket — a metadata-only save names a file that
* was never uploaded, and a hash of nothing would be worse than the absent hash the
* field already allows for.
*/
export async function inventionBlobHash(
bucket: R2Bucket,
blobName: string
): Promise<string | null> {
const key = `invention/${inventionBlobName(blobName)}`
const head = await bucket.head(key)
if (head === null) return null
const recorded = head.checksums.sha256
if (recorded !== undefined) return toBase64(recorded)
const object = await bucket.get(key)
return object === null
? null
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
}
/** /**
* Fields the client supplies on save (camelCase); everything else is defaulted here. * Fields the client supplies on save (camelCase); everything else is defaulted here.
* `inventionDataFilename` is the one the caller must supply — an invention with no * `inventionDataFilename` is the one the caller must supply — an invention with no
@@ -163,6 +194,7 @@ export interface NewInvention {
*/ */
export async function createInvention( export async function createInvention(
db: D1Database, db: D1Database,
bucket: R2Bucket,
input: NewInvention input: NewInvention
): Promise<SavedInvention> { ): Promise<SavedInvention> {
// Sequential id: one past the current max (the table starts empty). // Sequential id: one past the current max (the table starts empty).
@@ -171,6 +203,7 @@ export async function createInvention(
.first<{ next: number }>() .first<{ next: number }>()
const inventionId = row?.next ?? 1 const inventionId = row?.next ?? 1
const now = new Date().toISOString() const now = new Date().toISOString()
const blobName = inventionBlobName(input.inventionDataFilename)
const invention: SavedInvention = { const invention: SavedInvention = {
InventionId: inventionId, InventionId: inventionId,
ReplicationId: crypto.randomUUID(), ReplicationId: crypto.randomUUID(),
@@ -183,8 +216,8 @@ export async function createInvention(
InventionId: inventionId, InventionId: inventionId,
ReplicationId: crypto.randomUUID(), ReplicationId: crypto.randomUUID(),
VersionNumber: 1, VersionNumber: 1,
BlobName: inventionBlobName(input.inventionDataFilename), BlobName: blobName,
BlobHash: null, BlobHash: await inventionBlobHash(bucket, blobName),
InstantiationCost: input.instantiationCost ?? 0, InstantiationCost: input.instantiationCost ?? 0,
LightsCost: input.lightsCost ?? 0, LightsCost: input.lightsCost ?? 0,
ChipsCost: input.chipsCost ?? 0, ChipsCost: input.chipsCost ?? 0,
@@ -568,20 +601,38 @@ export async function getInventionsByRoom(
*/ */
export async function getInventionVersion( export async function getInventionVersion(
db: D1Database, db: D1Database,
bucket: R2Bucket,
inventionId: number, inventionId: number,
versionNumber: number versionNumber: number
): Promise<InventionVersion | null> { ): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId) const invention = await getInventionById(db, inventionId)
if (invention === null) return null if (invention === null) return null
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null if (invention.CurrentVersionNumber !== versionNumber) return null
// A version saved before its blob finished uploading (or before we hashed on
// save at all) carries no hash. Hash it now and keep the result, so the other
// invention endpoints serve it too and this stays a one-time cost per blob.
// ModifiedAt is deliberately left alone: reading a version is not an edit.
if (invention.CurrentVersion.BlobHash === null) {
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
if (hash !== null) {
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
await storeInvention(db, invention)
}
}
return invention.CurrentVersion
} }
/** Persist an edited invention record, bumping ModifiedAt. */ /** Persist an edited invention record, bumping ModifiedAt. */
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> { async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() } await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
}
/** Write a record back as it stands — for changes that aren't edits (see above). */
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
await db await db
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2') .prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(updated), invention.InventionId) .bind(JSON.stringify(invention), invention.InventionId)
.run() .run()
} }
+4 -1
View File
@@ -206,7 +206,10 @@ export const InventionVersionDto = z.object({
ReplicationId: z.string(), ReplicationId: z.string(),
VersionNumber: z.int(), VersionNumber: z.int(),
BlobName: z.string().describe('The `.inv` key in the storage workers bucket'), BlobName: z.string().describe('The `.inv` key in the storage workers bucket'),
BlobHash: z.string().nullable(), BlobHash: z
.string()
.nullable()
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
InstantiationCost: z.int(), InstantiationCost: z.int(),
LightsCost: z.int(), LightsCost: z.int(),
ChipsCost: z.int(), ChipsCost: z.int(),
+16 -8
View File
@@ -330,18 +330,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
) )
// A single version of an invention (`?inventionId=…&version=…`) — the bare // A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public. // RRInventionVersion, which carries the blob name the client downloads and the
// Only the current version exists (nothing writes version history yet), so any // SHA-256 of that blob. Public. Only the current version exists (nothing writes
// other version number 404s rather than naming a blob that isn't there. // version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
.get( .get(
'/api/inventions/v1/version', '/api/inventions/v1/version',
describeRoute({ describeRoute({
tags: ['Inventions'], tags: ['Inventions'],
summary: 'One version of an invention', summary: 'One version of an invention',
description: description:
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' + 'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
'Only the current version exists — nothing writes version history yet — so any ' + 'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'other version number 404s rather than naming a blob that is not there.', 'never uploaded). Only the current version exists — nothing writes version ' +
'history yet — so any other version number 404s rather than naming a blob that ' +
'is not there.',
parameters: [ parameters: [
intQuery('inventionId', 'Invention id; required'), intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'), intQuery('version', 'Version number; required'),
@@ -358,7 +361,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10) const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400) if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber) const version = await getInventionVersion(
c.env.DB,
c.env.CDN_ASSETS,
inventionId,
versionNumber
)
return version === null ? c.notFound() : c.json(version) return version === null ? c.notFound() : c.json(version)
} }
) )
@@ -704,7 +712,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ error: 'inventionDataFilename is required' }, 400) return c.json({ error: 'inventionDataFilename is required' }, 400)
} }
const invention = await createInvention(c.env.DB, { const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id, creatorPlayerId: id,
inventionDataFilename, inventionDataFilename,
name: str(body.name), name: str(body.name),
+53 -1
View File
@@ -110,6 +110,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
} }
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => { describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => { test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`) const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
@@ -766,6 +772,12 @@ describe('public endpoints', () => {
}) })
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => { test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
// The data file is uploaded (via the storage worker) before the metadata save,
// so the version carries its hash from the start. No sha256 recorded on this
// object — the api worker digests the blob itself in that case.
const data = new Uint8Array([1, 2, 3, 4])
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST', method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' }, headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
@@ -777,7 +789,8 @@ describe('public endpoints', () => {
}) })
const { Invention } = (await save.json()) as InventionSaveResult const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads. // The bare RRInventionVersion — the blob name is what the client downloads,
// BlobHash the base64 SHA-256 of what it will download.
const res = await exports.default.fetch( const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1` `${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
) )
@@ -786,6 +799,7 @@ describe('public endpoints', () => {
InventionId: Invention.InventionId, InventionId: Invention.InventionId,
VersionNumber: 1, VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv', BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42, InstantiationCost: 42,
}) })
@@ -808,6 +822,44 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400) expect(noId.status).toBe(400)
}) })
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
// Saved before the upload landed: nothing to hash, so the field stays null
// rather than carrying a hash of something the client can't download.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
})
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
expect(InventionVersion.BlobHash).toBeNull()
const version = async (): Promise<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
expect((await version()).BlobHash).toBeNull()
// Once the blob is there the hash resolves — here from the checksum recorded at
// upload time (what the storage worker puts), not by digesting the body.
const data = new Uint8Array([9, 8, 7])
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
sha256: await crypto.subtle.digest('SHA-256', data),
})
const hash = await base64Sha256(data)
expect((await version()).BlobHash).toBe(hash)
// And it's kept, so the other invention endpoints serve it too — without the
// read counting as an edit (ModifiedAt is untouched).
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
)
const stored = (await details.json()) as SavedInvention
expect(stored.CurrentVersion.BlobHash).toBe(hash)
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => { test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST', method: 'POST',
+7 -1
View File
@@ -19,11 +19,17 @@
} }
], ],
// Image bucket shared with the `img` worker (which serves objects back by key). // Image bucket shared with the `img` worker (which serves objects back by key).
// Saved-image uploads are written here. // Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
// invention's uploaded data blob for its `BlobHash`.
"r2_buckets": [ "r2_buckets": [
{ {
"binding": "IMAGES", "binding": "IMAGES",
"bucket_name": "recflare-img" "bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
} }
], ],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by // Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
@@ -0,0 +1,29 @@
-- Per-subroom permission overrides. `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`
-- is how a room's creator changes what a role may do in one subroom (spawn inventions,
-- invite, use the delete-all button, …). The client addresses an entry by the
-- (`Permission`, `Role`) pair and re-PUTs that pair to change it, so the pair is the
-- primary key: sending it again overwrites the stored row rather than appending a second.
--
-- A row IS an override, so the client's `Override` flag is not a column. It's the checkbox
-- the client draws next to each permission — `Override: true` stores the value, and
-- `Override: false` means "fall back to the default", which deletes the row. Reads always
-- serve `Override: true`.
--
-- Read on one path only — `GET /photon_access_token`, where a stored entry overwrites the
-- matching default in the permission table the client applies when it spawns. That's why
-- this is its own table rather than a field on the subroom's `data` blob: that blob is
-- served to the client verbatim inside the room, and nothing client-facing reads these.
--
-- `value` is the client's string kept verbatim: usually `True`/`False`, but a permission
-- whose UI isn't a True/False picker carries something else, and we don't interpret it.
--
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS subroom_permission (
sub_room_id INTEGER NOT NULL,
permission TEXT NOT NULL,
role INTEGER NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
value TEXT NOT NULL,
PRIMARY KEY (sub_room_id, permission, role)
);
+38 -2
View File
@@ -479,6 +479,35 @@ export const SubRoomAccessibilityRequest = z.object({
), ),
}) })
/**
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` — the entries to change, keyed by
* (`Permission`, `Role`). Only the pairs sent are touched. `Override` is the client's
* checkbox: true stores the entry, false clears it back to the default.
*/
export const SubRoomPermissionsRequest = z
.array(
z.object({
Permission: z
.string()
.describe('e.g. `CAN_SAVE_INVENTIONS`, `CAN_INVITE`, `CAN_USE_DELETE_ALL_BUTTON`'),
Role: z.int().describe('The role tier the entry applies to (0 = everyone, 30 = co-owner)'),
Override: z
.boolean()
.describe(
'The override checkbox, and a JSON boolean unlike `Value`: true stores this entry, ' +
'false DELETES any stored one so the pair falls back to its default'
),
Type: z.int().describe('Always 0 in what the client sends; stored verbatim'),
Value: z
.string()
.describe(
'A STRING, not a boolean — usually `True` / `False`, but kept verbatim: not every ' +
'permissions UI is a True/False picker. Ignored when `Override` is false'
),
})
)
.describe('An array — the client sends one even when changing a single permission')
/** /**
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live. * `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live.
* Any id from the subroom's history works, so this is both publish and restore. * Any id from the subroom's history works, so this is both publish and restore.
@@ -543,11 +572,13 @@ export const SubRoomSavesPage = z.object({
/** One entry of the permission table the client applies when it spawns into a room. */ /** One entry of the permission table the client applies when it spawns into a room. */
export const RoomPermissionDto = z.object({ export const RoomPermissionDto = z.object({
Override: z.boolean(), Override: z.boolean().describe('Always true on an entry that came from a subrooms overrides'),
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'), Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'), Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
Type: z.int(), Type: z.int(),
Value: z.string().describe('Always `True` — a permission is present or absent'), Value: z
.string()
.describe('A STRING, not a boolean — `True` on the defaults, anything on an override'),
}) })
/** /**
@@ -556,6 +587,11 @@ export const RoomPermissionDto = z.object({
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a * `PhotonAccessToken` is deliberately empty: the reference server signs it with a
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The * secret/algorithm we don't have, and our Photon setup accepts an empty token. The
* global (Role 0) maker pen is granted only to the hardcoded dev accounts. * global (Role 0) maker pen is granted only to the hardcoded dev accounts.
*
* `Permissions` is the default table with the overrides stored on the subroom the caller
* is standing in merged over it (see
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`): an override replaces the
* default with the same (`Permission`, `Role`), and one naming a new pair is appended.
*/ */
export const PhotonAccessTokenDto = z.object({ export const PhotonAccessTokenDto = z.object({
Permissions: z.array(RoomPermissionDto), Permissions: z.array(RoomPermissionDto),
+163 -29
View File
@@ -25,6 +25,7 @@ import {
getRoomsByCreator, getRoomsByCreator,
getRoomsByIds, getRoomsByIds,
getSimilarRooms, getSimilarRooms,
getSubRoomPermissions,
getSubRoomSaves, getSubRoomSaves,
getVisitedRooms, getVisitedRooms,
modifySubRoom, modifySubRoom,
@@ -37,6 +38,7 @@ import {
setRoomImage, setRoomImage,
setRoomName, setRoomName,
setRoomRole, setRoomRole,
setSubRoomPermissions,
toggleCheer, toggleCheer,
toggleFavorite, toggleFavorite,
toggleRoomTag, toggleRoomTag,
@@ -81,6 +83,7 @@ import {
stringQuery, stringQuery,
SubRoomAccessibilityRequest, SubRoomAccessibilityRequest,
subRoomIdParam, subRoomIdParam,
SubRoomPermissionsRequest,
SubRoomSavesPage, SubRoomSavesPage,
TagRequest, TagRequest,
UNAUTHORIZED_EMPTY, UNAUTHORIZED_EMPTY,
@@ -90,6 +93,7 @@ import {
} from './openapi' } from './openapi'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { RoomPermission } from '@repo/domain'
import type { App } from './context' import type { App } from './context'
/** /**
@@ -131,9 +135,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
* hardcoded moderator/dev accounts. */ * hardcoded moderator/dev accounts. */
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3]) const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
/** The slice of the shared presence row we read — the caller's current room instance. */ /**
* The slice of the shared presence row we read — the caller's current room instance.
* `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
* the player is standing in, not to the room.
*/
interface PresenceView { interface PresenceView {
roomInstanceId?: number roomInstanceId?: number
subRoomId?: number
} }
/** /**
@@ -143,16 +152,26 @@ interface PresenceView {
* they aren't in one). `PhotonAccessToken` stays empty — the reference server * they aren't in one). `PhotonAccessToken` stays empty — the reference server
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our * signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
* Photon setup accepts an empty token. * Photon setup accepts an empty token.
*
* `overrides` are the permissions the room's creator saved on the subroom the caller is
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
* defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by —
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
* standing in it as well.
*/ */
function photonAccessToken(accountId: number, roomInstanceId: number | null) { function photonAccessToken(
const perm = (Permission: string, Role: number, Override: boolean) => ({ accountId: number,
roomInstanceId: number | null,
overrides: RoomPermission[] = []
) {
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
Override, Override,
Permission, Permission,
Role, Role,
Type: 0, Type: 0,
Value: 'True', Value: 'True',
}) })
const permissions = [ const permissions: RoomPermission[] = [
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true), perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true), perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
perm('CAN_SAVE_INVENTIONS', 0, true), perm('CAN_SAVE_INVENTIONS', 0, true),
@@ -165,9 +184,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
perm('CAN_SPAWN_INVENTIONS', 30, true), perm('CAN_SPAWN_INVENTIONS', 30, true),
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true), perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
] ]
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) { if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true)) permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
} }
// The subroom's stored table wins, applied LAST and over the dev grant too: a
// (Permission, Role) the table already carries is replaced in place — so the order
// doesn't shift under the client, and no pair is ever listed twice with two values —
// and one it doesn't (e.g. CAN_INVITE) is appended.
for (const override of overrides) {
const i = permissions.findIndex(
(p) => p.Permission === override.Permission && p.Role === override.Role
)
if (i === -1) permissions.push(override)
else permissions[i] = override
}
return { return {
Permissions: permissions, Permissions: permissions,
PhotonAccessToken: '', PhotonAccessToken: '',
@@ -176,16 +208,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
} }
/** /**
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated: * Photon access-token handler. Auth-gated: resolves the caller, reads their current
* resolves the caller, reads their current room instance from the shared * room instance from the shared `presence` table (see @repo/domain), and returns the
* `presence` table (see @repo/domain), and returns the permissions + token. * permissions + token.
*/ */
async function handlePhotonAccessToken(c: Context<App>) { async function handlePhotonAccessToken(c: Context<App>) {
const accountId = await authedAccountId(c) const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
const presence = await getPresence<PresenceView>(c.env.DB, accountId) const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null // The permission overrides are the ones saved on the subroom the caller is standing in.
return c.json(photonAccessToken(accountId, roomInstanceId)) // A player in no instance — sitting in the lobby, or an instance predating subroom
// tracking — gets the default table untouched.
const overrides =
typeof instance?.subRoomId === 'number'
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
: []
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
} }
/** The Bearer token's account id (`sub`), or null when there's no valid token. */ /** The Bearer token's account id (`sub`), or null when there's no valid token. */
@@ -214,6 +252,59 @@ function parseAccessibility(value: unknown): number | undefined {
return named ? (named[1] as number) : undefined return named ? (named[1] as number) : undefined
} }
/** Parse an integer from the number or numeric string a JSON body may carry. */
function parseInt10(value: unknown): number | undefined {
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
if (typeof value !== 'string') return undefined
const n = Number.parseInt(value.trim(), 10)
return Number.isNaN(n) ? undefined : n
}
/**
* The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the
* True/False picker beside the override checkbox — but a permission whose UI is something
* else carries a different value, so nothing here interprets it. A JSON boolean or number
* is rendered the way the client would have written it.
*/
function permissionValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'boolean') return value ? 'True' : 'False'
if (typeof value === 'number') return String(value)
return ''
}
/**
* Parse the subroom-permissions PUT body: a JSON ARRAY of
* `{ Permission, Role, Override, Type, Value }` entries.
*
* `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true
* stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
* pair falls back to the default. It is carried through as sent.
*
* Entries without a permission name or a usable role are dropped rather than rejected —
* the client ignores the response either way, so half a table applied beats none.
*/
function parseRoomPermissions(body: unknown): RoomPermission[] {
if (!Array.isArray(body)) return []
const permissions: RoomPermission[] = []
for (const entry of body) {
if (typeof entry !== 'object' || entry === null) continue
const e = entry as Record<string, unknown>
const permission = typeof e.Permission === 'string' ? e.Permission.trim() : ''
const role = parseInt10(e.Role)
if (permission === '' || role === undefined) continue
permissions.push({
Permission: permission,
Role: role,
// Sent as a JSON boolean, unlike `Value` — accept the string form regardless.
Override: e.Override === true || String(e.Override).toLowerCase() === 'true',
Type: parseInt10(e.Type) ?? 0,
Value: permissionValue(e.Value),
})
}
return permissions
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */ /** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
@@ -1820,6 +1911,67 @@ const app = new Hono<App>()
} }
) )
// Set a subroom's permission overrides — what each role may do in that subroom. The
// body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override`
// is the client's checkbox, so true stores the entry for that pair and false clears it
// back to the default. The stored table then overwrites the matching defaults in
// `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other
// subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing,
// so there is no envelope to match.
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
describeRoute({
tags: ['Subrooms'],
summary: 'Set a subrooms permissions',
description: [
'Stores the permission entries a rooms creator changed for one subroom — who may',
'save inventions, invite players, use the delete-all button, and so on. The body is a',
'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending',
'a pair overwrites the stored entry rather than adding a second, and pairs that were',
'never sent are left alone.',
'',
'`Override` is the checkbox the client draws beside each permission, not data:',
'`true` stores `Value` for that pair, and `false` means “fall back to the default”, so',
'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads',
'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept',
'verbatim, since not every permissions UI is a True/False picker.',
'',
'What this feeds is `GET /photon_access_token`: a stored entry replaces the default',
'with the same (`Permission`, `Role`) in the table the client applies when it spawns,',
'and one naming a pair the defaults dont carry (e.g. `CAN_INVITE`) is added to it.',
'The overrides apply to the subroom the caller is standing in, resolved from presence.',
'',
'Creator-only — co-owners may build in a room but not decide what a role may do.',
'The response body is EMPTY: the client doesnt read one.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
responses: {
200: { description: 'Stored (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room or subroom' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't be written.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
return c.body(null, 200)
}
)
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same // Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and // scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new // returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
@@ -2041,8 +2193,7 @@ const app = new Hono<App>()
} }
) )
// Photon access token + room permissions the client needs to spawn into a // Photon access token + room permissions the client needs to spawn into a room.
// room. The client calls it on the rooms host both bare and under `/roomserver`.
.get( .get(
'/photon_access_token', '/photon_access_token',
describeRoute({ describeRoute({
@@ -2065,23 +2216,6 @@ const app = new Hono<App>()
}), }),
handlePhotonAccessToken handlePhotonAccessToken
) )
.get(
'/roomserver/photon_access_token',
describeRoute({
tags: ['Session'],
summary: 'Photon token + room permissions (legacy path)',
description: [
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
'`/roomserver` prefix, so both forms are registered.',
].join(' '),
security: AUTHED,
responses: {
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
401: UNAUTHORIZED_RESPONSE,
},
}),
handlePhotonAccessToken
)
// The generated spec. Documentation only — no request is validated against it (see // The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output. // openapi.ts). `hide: true` keeps this route out of its own output.
+263 -21
View File
@@ -1433,13 +1433,10 @@ describe('rooms endpoints', () => {
}) })
it('GET /photon_access_token 401s without a token', async () => { it('GET /photon_access_token 401s without a token', async () => {
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401)
const res = await SELF.fetch(`${ORIGIN}${path}`)
expect(res.status).toBe(401)
}
}) })
it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => { it('GET /photon_access_token returns permissions + presence instance', async () => {
// Seed the caller's presence so RoomInstanceId reflects their current instance. // Seed the caller's presence so RoomInstanceId reflects their current instance.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind( .bind(
@@ -1450,22 +1447,19 @@ describe('rooms endpoints', () => {
}) })
) )
.run() .run()
const headers = await bearer('777') const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { expect(res.status).toBe(200)
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers }) const body = (await res.json()) as {
expect(res.status).toBe(200) Permissions: Array<{ Permission: string; Role: number }>
const body = (await res.json()) as { PhotonAccessToken: string
Permissions: Array<{ Permission: string; Role: number }> RoomInstanceId: number | null
PhotonAccessToken: string
RoomInstanceId: number | null
}
expect(body.Permissions.length).toBe(11)
expect(body.RoomInstanceId).toBe(1000042)
// A non-dev account does NOT get the global (Role 0) maker pen.
expect(
body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)
).toBe(false)
} }
expect(body.Permissions.length).toBe(11)
expect(body.RoomInstanceId).toBe(1000042)
// A non-dev account does NOT get the global (Role 0) maker pen.
expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe(
false
)
}) })
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => { it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
@@ -1676,6 +1670,254 @@ describe('rooms endpoints', () => {
expect(await accessibilityOf()).toBe(1) expect(await accessibilityOf()).toBe(1)
}) })
// The permission table a room's creator saves on a subroom, and how it reaches the
// client: `PUT …/permissions` stores entries keyed by (Permission, Role), and
// `GET /photon_access_token` merges them over its defaults for whoever is standing in
// that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor
// whose presence points at it.
describe('subroom permissions', () => {
type Permission = { Permission: string; Role: number; Override: boolean; Value: string }
const putPermissions = async (path: string, body: unknown, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
// Put a player in an instance of the given subroom, then read the permission table
// the client would apply when it spawns there.
const permissionsIn = async (accountId: number, subRoomId: number): Promise<Permission[]> => {
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId },
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer(String(accountId)),
})
expect(res.status).toBe(200)
return ((await res.json()) as { Permissions: Permission[] }).Permissions
}
const entry = (list: Permission[], permission: string, role: number) =>
list.find((p) => p.Permission === permission && p.Role === role)
it('is auth-gated and creator-only', async () => {
const body = [
{ Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' },
]
// No token → 401.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401)
// A valid token that isn't the room's creator → 403.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe(
403
)
// Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may
// build in a room but don't decide what a role may do.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403)
// Unknown room / unknown subroom → 404.
expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe(
404
)
// A subroom id belonging to another room doesn't resolve either.
expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe(
404
)
})
it('answers an empty 200 — the client reads no body', async () => {
const res = await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1'
)
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
})
it('a checked Override replaces the matching default in place', async () => {
const before = await permissionsIn(743, 2)
expect(before.length).toBe(11)
const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30)
// The default for this pair is an un-overridden grant.
expect(before[at]).toMatchObject({ Override: false, Value: 'True' })
expect(
(
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: true,
Type: 0,
Value: 'False',
},
],
'1'
)
).status
).toBe(200)
const after = await permissionsIn(743, 2)
// Replaced, not appended — and at the same index, so the table doesn't reshuffle.
expect(after.length).toBe(11)
expect(after[at]).toMatchObject({
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: true,
Value: 'False',
})
// Re-sending the same (Permission, Role) updates that entry rather than adding one.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1'
)
const changed = await permissionsIn(743, 2)
expect(changed.length).toBe(11)
expect(changed[at]).toMatchObject({ Override: true, Value: 'True' })
})
it('an unchecked Override erases the entry, back to the default', async () => {
const stored = async () =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM subroom_permission
WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30`
).first<{ n: number }>())!.n
// The previous test left this pair overridden.
expect(await stored()).toBe(1)
// `Override: false` means "fall back to the default" — the `Value` riding along is
// not stored, it's whatever the picker happened to show.
expect(
(
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{
Permission: 'CAN_USE_MAKER_PEN',
Role: 30,
Override: false,
Type: 0,
Value: 'True',
},
],
'1'
)
).status
).toBe(200)
// The row is gone, and the token serves the default for the pair again.
expect(await stored()).toBe(0)
const table = await permissionsIn(743, 2)
expect(table.length).toBe(11)
expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({
Override: false,
Value: 'True',
})
// Clearing a pair that was never overridden is a no-op, not an insert.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }],
'1'
)
expect((await permissionsIn(743, 2)).length).toBe(11)
})
it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => {
// CAN_INVITE is in none of the defaults, so it lands as a new entry.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }],
'1'
)
const inSubRoom2 = await permissionsIn(744, 2)
expect(inSubRoom2.length).toBe(12)
expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({
Override: true,
Value: 'False',
})
// A different subroom is untouched — the table is per-subroom, not per-room.
expect((await permissionsIn(744, 3)).length).toBe(11)
// And so is a player in no instance at all.
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run()
const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer('744'),
})
expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11)
})
it('keeps a Value that isnt True/False verbatim', async () => {
// Not every permission's UI is the True/False picker, so nothing interprets the
// string — it goes to the client exactly as the creator set it.
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }],
'1'
)
expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({
Override: true,
Value: '25',
})
})
it('applies over the dev accounts global maker pen, without listing a pair twice', async () => {
await putPermissions(
'/rooms/2/subrooms/2/permissions',
[
{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' },
// The third sample body — a Role 0 grant the defaults already carry.
{
Permission: 'CAN_USE_DELETE_ALL_BUTTON',
Role: 0,
Override: true,
Type: 0,
Value: 'True',
},
],
'1'
)
// Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0)
// maker pen prepended — which this subroom then revokes. The merge runs last and
// replaces it in place, so the pair appears exactly ONCE: a table listing it twice
// with two values would leave which one applies up to the client.
const devTable = await permissionsIn(3, 2)
expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([
{ Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' },
])
expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' })
// A normal player in the same subroom sees the same revocation.
expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({
Value: 'False',
})
})
it('a cloned subroom inherits the sources permission table', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } }
const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId))
const inClone = await permissionsIn(746, cloneId)
expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' })
expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' })
})
})
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => { it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
const clone = async (roomId: number, subRoomId: number, sub?: string) => const clone = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, { SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
@@ -1944,7 +2186,6 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /roomserver/photon_access_token',
'GET /roomserver/rooms/createdby/me', 'GET /roomserver/rooms/createdby/me',
'POST /rooms/{roomId}/clone', 'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/subrooms', 'POST /rooms/{roomId}/subrooms',
@@ -1963,6 +2204,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
'PUT /rooms/{roomId}/tags', 'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/warning', 'PUT /rooms/{roomId}/warning',
]) ])
+7 -1
View File
@@ -150,8 +150,14 @@ const app = new Hono<App>()
// does the extension, which is why it goes on the key, not just the name. // does the extension, which is why it goes on the key, not just the name.
const datePrefix = new Date().toISOString().slice(0, 10) const datePrefix = new Date().toISOString().slice(0, 10)
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}` const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), { const bytes = await file.arrayBuffer()
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
httpMetadata: { contentType: file.type || 'application/octet-stream' }, httpMetadata: { contentType: file.type || 'application/octet-stream' },
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
// only a checksum given at put time is readable later — this lets the `api`
// worker answer one from a HEAD instead of downloading the blob to digest it.
sha256: await crypto.subtle.digest('SHA-256', bytes),
}) })
return c.json({ filename }) return c.json({ filename })
} }