open up room save endpoints to users with presence

This commit is contained in:
Devin Zuczek
2026-08-07 10:48:52 -04:00
parent 368ca252c1
commit 3dd6d6420b
3 changed files with 114 additions and 24 deletions
+10 -5
View File
@@ -99,12 +99,17 @@ inconsistency here without checking the client first.
publish: no publish step exists in the client for them. Saves live in the publish: no publish step exists in the client for them. Saves live in the
`subroom_save` table with globally-unique ids (a bare id has to resolve — `subroom_save` table with globally-unique ids (a bare id has to resolve —
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so `StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is `…/saves` is real history and `publish_save` doubles as restore-a-save. There
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path. is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
`GET …/saves/:saveId` is the detail behind a list row, under the same creator-only gate, `GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
but in the CAMELCASE projection the room save's response uses — not the PascalCase rows CAMELCASE projection the room save's response uses — not the PascalCase rows the list
the list serves. Three shapes of one save; keep them straight. serves. Three shapes of one save; keep them straight.
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
co-owners as such (a co-owner passes only by standing there). They list unpublished
staged saves, so they aren't public; but a visitor resolves which version an instance is
running from this list, so creator-only locks them out of loading the room. The grant
expires with the presence row.
- A room save writes ONLY to the subroom and its save row — never to the room. Everything - A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision: `Description` is the save comment shown in the body carries describes that one revision: `Description` is the save comment shown in
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the `…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
+40 -11
View File
@@ -159,6 +159,7 @@ const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
*/ */
interface PresenceView { interface PresenceView {
roomInstanceId?: number roomInstanceId?: number
roomId?: number
subRoomId?: number subRoomId?: number
} }
@@ -243,6 +244,31 @@ async function handlePhotonAccessToken(c: Context<App>) {
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides)) return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
} }
/**
* May this caller read the room's saves? The room's creator always may. So may anyone
* whose live presence puts them IN the room: they are already loading its scene, and the
* client resolves which version to load — the published one or the creator's latest — from
* the save list, so refusing everyone but the creator leaves a visitor unable to load what
* the instance is actually running.
*
* Presence is the shared `presence` table the `match` heartbeat maintains, so this grant
* lasts only as long as the player is actually there (rows carry an absolute expiry and
* expired ones don't read back). Co-owners get nothing extra from being co-owners — a
* co-owner standing in the room passes because of where they are, not what they hold.
*
* The presence read only happens for a non-creator, so the owner's own path stays one query.
*/
async function canReadSaves(
c: Context<App>,
room: Record<string, unknown>,
roomId: number,
accountId: number
): Promise<boolean> {
if (room.CreatorAccountId === accountId) return true
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
return instance?.roomId === roomId
}
/** 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. */
async function authedAccountId(c: Context<App>): Promise<number | null> { async function authedAccountId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
@@ -1874,8 +1900,8 @@ const app = new Hono<App>()
// A subroom's saved-data versions — the room-history / "restore a save" list. Every // A subroom's saved-data versions — the room-history / "restore a save" list. Every
// save is its own `subroom_save` row (nothing is overwritten), so this is real // save is its own `subroom_save` row (nothing is overwritten), so this is real
// history, newest first, paged by skip/take. Auth-gated (401) and creator-only (403): // history, newest first, paged by skip/take. Auth-gated (401), and readable by the
// the list exposes unpublished saves, which only the owner is entitled to see. // room's creator or anyone whose presence puts them in the room (see `canReadSaves`).
.get( .get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
describeRoute({ describeRoute({
@@ -1887,9 +1913,11 @@ const app = new Hono<App>()
'only when the subroom has never been saved.', 'only when the subroom has never been saved.',
'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.', '`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
'', '',
'Owner-only (403 otherwise) — the list includes STAGED saves that were never', 'The list includes STAGED saves that were never published, so it is not public:',
'published, so it is not public. It is what the client reads to offer the owner', 'the rooms creator may read it, and so may anyone standing IN the room (their live',
'“load the latest or the published version?” when they enter a private instance.', 'presence says so). Anyone else is a 403. It is what the client reads to resolve',
'“load the latest or the published version?” on entering a private instance — a',
'visitor who cannot read it cannot load what the instance is running.',
'', '',
'`TotalResults` and `TotalCount` carry the same number: the clients paged DTO and', '`TotalResults` and `TotalCount` carry the same number: the clients paged DTO and',
'the reference disagree on the name, so both are emitted.', 'the reference disagree on the name, so both are emitted.',
@@ -1920,7 +1948,7 @@ const app = new Hono<App>()
if (!room || !findSubRoom(room, subRoomId)) { if (!room || !findSubRoom(room, subRoomId)) {
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 }) return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
} }
if (room.CreatorAccountId !== accountId) return c.body(null, 403) if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
const saves = await getSubRoomSaves(c.env.DB, subRoomId) const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
@@ -1933,8 +1961,8 @@ const app = new Hono<App>()
) )
// One of a subroom's saves by id — the detail behind a row of the `…/saves` list. // One of a subroom's saves by id — the detail behind a row of the `…/saves` list.
// Same gate as that list (auth-gated, creator-only): a save id resolves whether or not // Same gate as that list: a save id resolves whether or not it was ever published, so
// it was ever published, so this exposes the same unpublished work the list does. // this exposes the same unpublished work, to the same readers.
.get( .get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}', '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}',
describeRoute({ describeRoute({
@@ -1947,8 +1975,9 @@ const app = new Hono<App>()
'save by guessing an id: a save that belongs elsewhere is a 404, same as an unknown', 'save by guessing an id: a save that belongs elsewhere is a 404, same as an unknown',
'one.', 'one.',
'', '',
'Creator-only, like the list it details — a save id resolves whether or not it was', 'Gated like the list it details — the rooms creator, or anyone whose presence puts',
'ever published, so this reads unpublished work.', 'them in the room. A save id resolves whether or not it was ever published, so this',
'reads unpublished work.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
parameters: [roomIdParam, subRoomIdParam, saveIdParam], parameters: [roomIdParam, subRoomIdParam, saveIdParam],
@@ -1971,7 +2000,7 @@ const app = new Hono<App>()
// be used to read its saves. // be used to read its saves.
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound() if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
if (room.CreatorAccountId !== accountId) return c.body(null, 403) if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId) const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
return save ? c.json(toSaveResponse(save)) : c.notFound() return save ? c.json(toSaveResponse(save)) : c.notFound()
+64 -8
View File
@@ -50,6 +50,33 @@ async function bearer(sub: string, roles?: string[]): Promise<Record<string, str
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
} }
/**
* Put a player in a room, the way the `match` heartbeat would — the save routes read this
* to decide whether a non-creator may see the room's history. `expired` writes a row that
* has already lapsed, which reads back as no presence at all.
*/
async function putInRoom(
accountId: number,
roomId: number,
{ expired = false }: { expired?: boolean } = {}
): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 1000000 + roomId, roomId, subRoomId: roomId },
expiresAt: expired ? now - 1 : now + 900,
})
)
.run()
}
/** Take a player back out of whatever room they were in. */
async function clearPresence(accountId: number): Promise<void> {
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(accountId).run()
}
// Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations). // Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations).
beforeAll(async () => { beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves. // Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
@@ -2579,18 +2606,41 @@ describe('rooms endpoints', () => {
}) })
expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 }) expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 })
// The list exposes unpublished saves, so it is owner-only: no token → 401, and a // The list exposes unpublished saves, so it isn't public: no token → 401, and a
// valid token that isn't the room's creator → 403. // valid token from someone who is neither the creator nor in the room → 403. Account
// 2 is a co-owner (Role 30 on the seeded rooms) and is refused too — holding a role
// grants nothing here; being in the room does (see below).
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401) expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401)
expect( expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') })) (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status .status
).toBe(403) ).toBe(403)
// Even a co-owner (account 2 holds Role 30 on the seeded rooms) is refused.
expect( expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') })) (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') }))
.status .status
).toBe(403) ).toBe(403)
// …but a player standing IN the room reads it: the client resolves which version to
// load from this list, so a visitor who can't read it can't load the instance.
await putInRoom(999, 2)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(200)
// Presence in a DIFFERENT room is not presence in this one.
await putInRoom(999, 5)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
// And the grant lasts only as long as the presence does — an expired row reads as
// absent, so the visitor is refused again the moment they leave.
await putInRoom(999, 2, { expired: true })
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
await clearPresence(999)
}) })
it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => { it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => {
@@ -2637,11 +2687,17 @@ describe('rooms endpoints', () => {
expect((await get('/rooms/99999/subrooms/2/saves/1', '1')).status).toBe(404) expect((await get('/rooms/99999/subrooms/2/saves/1', '1')).status).toBe(404)
expect((await get('/rooms/2/subrooms/99999/saves/1', '1')).status).toBe(404) expect((await get('/rooms/2/subrooms/99999/saves/1', '1')).status).toBe(404)
// Same gate as the list it details: 401 unauthed, 403 for a non-creator, and 403 // Same gate as the list it details: 401 unauthed, 403 for someone who is neither the
// even for a co-owner — it reads unpublished saves. // creator nor in the room (a co-owner included) — it reads unpublished saves.
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`)).status).toBe(401) const detail = `/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '999')).status).toBe(403) expect((await get(detail)).status).toBe(401)
expect((await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '2')).status).toBe(403) expect((await get(detail, '999')).status).toBe(403)
expect((await get(detail, '2')).status).toBe(403)
// A player standing in the room reads it, for as long as they're there.
await putInRoom(999, 2)
expect((await get(detail, '999')).status).toBe(200)
await clearPresence(999)
expect((await get(detail, '999')).status).toBe(403)
}) })
it('GET /openapi.json documents every route', async () => { it('GET /openapi.json documents every route', async () => {