mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api] player photo tagging setting
GET/PUT /api/players/v1/playerPhotoTaggingSetting, ported from the reference's PlayerDB.Get/SetPlayerPhotoTaggingSetting. Backed by the shared player-settings KV bag (owned by the `playersettings` worker) under a `PlayerPhotoTaggingSetting` key rather than its own table. The setting is served as the enum ORDINAL (0 Anyone / 1 Friends / 2 NoOne) — the reference registers no JsonStringEnumConverter, so the client decodes a number. Unset reads back 0; the PUT answers a bare true, or false when the body carries no recognizable setting, as the reference's bool does. The write merges into the player's settings map and seeds the `playersettings` defaults when there is none yet, so writing this key can't cost a player the seeding that worker's first read would have done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { gameplayRoutes } from './routes/gameplay'
|
||||
import { imageRoutes } from './routes/images'
|
||||
import { inventoryRoutes } from './routes/inventory'
|
||||
import { moderationRoutes } from './routes/moderation'
|
||||
import { playerRoutes } from './routes/players'
|
||||
import { progressionRoutes } from './routes/progression'
|
||||
import { roomRoutes } from './routes/rooms'
|
||||
import { socialRoutes } from './routes/social'
|
||||
@@ -64,6 +65,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', roomRoutes)
|
||||
.route('/', imageRoutes)
|
||||
.route('/', accountRoutes)
|
||||
.route('/', playerRoutes)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
|
||||
@@ -24,6 +24,10 @@ export type Env = SharedHonoEnv & {
|
||||
// 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
|
||||
// Per-player settings bag (KV owned by the `playersettings` worker, which serves
|
||||
// the same map at `/playersettings`). Key `player:<id>` → JSON `{ key: value }`;
|
||||
// read/written here for the player preferences the client calls by name.
|
||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push RelationshipChanged notifications when a player's relationship changes.
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
|
||||
@@ -254,6 +254,20 @@ export const BulkIdsRequest = z.object({
|
||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||
})
|
||||
|
||||
// ---- Players ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `PlayerPhotoTaggingSetting` — who may tag the player in a photo, as the enum ORDINAL.
|
||||
* The reference serves the number (it registers no `JsonStringEnumConverter`), so this
|
||||
* is a bare integer body, not a name and not an envelope.
|
||||
*/
|
||||
export const PhotoTaggingSetting = z.int().describe('0 = Anyone, 1 = Friends, 2 = NoOne')
|
||||
|
||||
/** The `{ Setting }` JSON body `PUT /api/players/v1/playerPhotoTaggingSetting` takes. */
|
||||
export const SetPhotoTaggingSettingRequest = z.object({
|
||||
Setting: PhotoTaggingSetting,
|
||||
})
|
||||
|
||||
// ---- Inventions ------------------------------------------------------------
|
||||
|
||||
/** One version of an invention — carries the blob name the client downloads. */
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
// The `playersettings` worker owns this KV map; its defaults are imported as a value
|
||||
// (a plain array, no runtime dependencies) so a first write from here seeds the same
|
||||
// settings that worker's first read would have.
|
||||
import { DEFAULT_SETTINGS } from '../../../playersettings/src/default-settings'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
json,
|
||||
jsonBody,
|
||||
PhotoTaggingSetting,
|
||||
SetPhotoTaggingSettingRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* Player-account preferences. These live in the same per-player KV bag the
|
||||
* `playersettings` worker serves (`player:{id}` → `{ key: value }`), just under a
|
||||
* dedicated route the client calls by name — the reference keeps the photo-tagging
|
||||
* setting on the player record, but a settings key is the same thing without a table.
|
||||
*/
|
||||
|
||||
/** The settings key the photo-tagging preference is stored under. */
|
||||
const PHOTO_TAGGING_KEY = 'PlayerPhotoTaggingSetting'
|
||||
|
||||
/**
|
||||
* `PlayerPhotoTaggingSetting` — who may tag this player in a photo. Serialized as the
|
||||
* ordinal, not the name: the reference server leaves `JsonStringEnumConverter` off, so
|
||||
* the client's decoder is reading a number.
|
||||
*/
|
||||
const PHOTO_TAGGING_VALUES = ['Anyone', 'Friends', 'NoOne'] as const
|
||||
|
||||
/** `Anyone` — what a player who has never set one reads back as. */
|
||||
const PHOTO_TAGGING_DEFAULT = 0
|
||||
|
||||
/** The caller's KV key in the shared player-settings bag. */
|
||||
function settingsKey(id: number): string {
|
||||
return `player:${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a posted setting to its ordinal. Accepts the number the client sends and the
|
||||
* enum NAME as well, so a client that spells it out still lands on the right value.
|
||||
* `null` when the body carries nothing recognizable — the caller answers `false`.
|
||||
*/
|
||||
function parsePhotoTaggingSetting(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isInteger(raw)) {
|
||||
return raw >= 0 && raw < PHOTO_TAGGING_VALUES.length ? raw : null
|
||||
}
|
||||
if (typeof raw !== 'string' || raw === '') return null
|
||||
|
||||
const asNumber = Number.parseInt(raw, 10)
|
||||
if (!Number.isNaN(asNumber)) {
|
||||
return asNumber >= 0 && asNumber < PHOTO_TAGGING_VALUES.length ? asNumber : null
|
||||
}
|
||||
|
||||
const named = PHOTO_TAGGING_VALUES.findIndex((v) => v.toLowerCase() === raw.toLowerCase())
|
||||
return named === -1 ? null : named
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Setting` field out of a PUT body: JSON (what the client posts, `{ "Setting": 1 }`),
|
||||
* or a form-urlencoded `Setting` for hand-rolled callers. Either casing is accepted.
|
||||
*/
|
||||
async function readSetting(c: Context<App>): Promise<number | null> {
|
||||
const contentType = c.req.header('content-type') ?? ''
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await c.req.json<unknown>().catch(() => null)
|
||||
if (body === null || typeof body !== 'object') return null
|
||||
const rec = body as Record<string, unknown>
|
||||
return parsePhotoTaggingSetting(rec.Setting ?? rec.setting)
|
||||
}
|
||||
|
||||
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
return parsePhotoTaggingSetting(form.Setting ?? form.setting)
|
||||
}
|
||||
|
||||
// ---- Players ---------------------------------------------------------------
|
||||
export const playerRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
'/api/players/v1/playerPhotoTaggingSetting',
|
||||
describeRoute({
|
||||
tags: ['Players'],
|
||||
summary: 'Who may tag the caller in photos',
|
||||
description:
|
||||
'The caller’s `PlayerPhotoTaggingSetting` as the enum ORDINAL — `0` Anyone, `1` ' +
|
||||
'Friends, `2` NoOne — read from the `PlayerPhotoTaggingSetting` key of the shared ' +
|
||||
'player-settings bag the `playersettings` worker serves. A player who has never set ' +
|
||||
'one reads back `0` (Anyone), which is the reference’s default; nothing is written ' +
|
||||
'on a read.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PhotoTaggingSetting, 'The setting, as its ordinal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
settingsKey(id),
|
||||
'json'
|
||||
)
|
||||
const parsed = parsePhotoTaggingSetting(stored?.[PHOTO_TAGGING_KEY])
|
||||
return c.json(parsed ?? PHOTO_TAGGING_DEFAULT)
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/api/players/v1/playerPhotoTaggingSetting',
|
||||
describeRoute({
|
||||
tags: ['Players'],
|
||||
summary: 'Set who may tag the caller in photos',
|
||||
description:
|
||||
'Writes `{ "Setting": 0 | 1 | 2 }` to the caller’s `PlayerPhotoTaggingSetting` key ' +
|
||||
'and answers a bare `true`, as the reference does (it answers `false` when there was ' +
|
||||
'nothing to update — here, when the body carries no recognizable setting). The enum ' +
|
||||
'NAME is accepted alongside the ordinal. The write MERGES into the player’s settings ' +
|
||||
'bag, so it leaves every other key alone; a player with no bag yet is seeded with the ' +
|
||||
'`playersettings` defaults first, so this write can’t cost them that seeding.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SetPhotoTaggingSettingRequest, 'The setting to store'),
|
||||
responses: {
|
||||
200: json(BareBoolean, 'True when the setting was written'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const setting = await readSetting(c)
|
||||
if (setting === null) return c.json(false)
|
||||
|
||||
const kvKey = settingsKey(id)
|
||||
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
kvKey,
|
||||
'json'
|
||||
)
|
||||
const base =
|
||||
existing && Object.keys(existing).length > 0
|
||||
? existing
|
||||
: Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
|
||||
|
||||
await c.env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
kvKey,
|
||||
JSON.stringify({ ...base, [PHOTO_TAGGING_KEY]: String(setting) })
|
||||
)
|
||||
return c.json(true)
|
||||
}
|
||||
)
|
||||
@@ -1647,6 +1647,84 @@ describe('account', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('photo tagging setting', () => {
|
||||
const PATH = `${ORIGIN}/api/players/v1/playerPhotoTaggingSetting`
|
||||
|
||||
const read = async (sub: string) => exports.default.fetch(PATH, { headers: await bearer(sub) })
|
||||
|
||||
const write = async (sub: string, body: unknown) =>
|
||||
exports.default.fetch(PATH, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer(sub)) },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
test('both verbs 401 without a bearer token', async () => {
|
||||
expect((await exports.default.fetch(PATH)).status).toBe(401)
|
||||
const put = await exports.default.fetch(PATH, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Setting: 1 }),
|
||||
})
|
||||
expect(put.status).toBe(401)
|
||||
})
|
||||
|
||||
test('a player who has never set one reads Anyone (0)', async () => {
|
||||
const res = await read('9001')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
})
|
||||
|
||||
test('PUT stores the setting and answers true', async () => {
|
||||
const put = await write('9002', { Setting: 2 })
|
||||
expect(put.status).toBe(200)
|
||||
expect(await put.json()).toBe(true)
|
||||
expect(await (await read('9002')).json()).toBe(2)
|
||||
})
|
||||
|
||||
test('the enum name is accepted alongside the ordinal', async () => {
|
||||
expect(await (await write('9003', { Setting: 'Friends' })).json()).toBe(true)
|
||||
expect(await (await read('9003')).json()).toBe(1)
|
||||
})
|
||||
|
||||
test('an unrecognized setting is a false, and changes nothing', async () => {
|
||||
await write('9004', { Setting: 2 })
|
||||
expect(await (await write('9004', { Setting: 7 })).json()).toBe(false)
|
||||
expect(await (await write('9004', {})).json()).toBe(false)
|
||||
expect(await (await read('9004')).json()).toBe(2)
|
||||
})
|
||||
|
||||
test('the write merges — the player’s other settings survive', async () => {
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
'player:9005',
|
||||
JSON.stringify({ TUTORIAL_COMPLETE_MASK: '11' })
|
||||
)
|
||||
await write('9005', { Setting: 1 })
|
||||
expect(
|
||||
await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>('player:9005', 'json')
|
||||
).toEqual({ TUTORIAL_COMPLETE_MASK: '11', PlayerPhotoTaggingSetting: '1' })
|
||||
})
|
||||
|
||||
test('a first write seeds the playersettings defaults alongside it', async () => {
|
||||
await write('9006', { Setting: 2 })
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:9006',
|
||||
'json'
|
||||
)
|
||||
expect(stored).toMatchObject({ 'Recroom.OOBE': '77', PlayerPhotoTaggingSetting: '2' })
|
||||
})
|
||||
|
||||
test('a form-urlencoded PUT is accepted too', async () => {
|
||||
const res = await exports.default.fetch(PATH, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('9007')) },
|
||||
body: new URLSearchParams({ Setting: '1' }),
|
||||
})
|
||||
expect(await res.json()).toBe(true)
|
||||
expect(await (await read('9007')).json()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth-gated endpoints', () => {
|
||||
test('401 without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
|
||||
@@ -3850,6 +3928,7 @@ describe('openapi', () => {
|
||||
'GET /api/playerevents/v1/tagfilters',
|
||||
'GET /api/playerevents/v1/{eventId}',
|
||||
'GET /api/playerevents/v1/{eventId}/responses',
|
||||
'GET /api/players/v1/playerPhotoTaggingSetting',
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
@@ -3917,6 +3996,7 @@ describe('openapi', () => {
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/v1/progression/bulk',
|
||||
'PUT /api/players/v1/playerPhotoTaggingSetting',
|
||||
'PUT /outfits/me',
|
||||
])
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
"bucket_name": "recflare-cdn"
|
||||
}
|
||||
],
|
||||
// Per-player settings bag, shared with the `playersettings` worker (which owns it
|
||||
// and serves the same map at `/playersettings`). Same binding name and same "local"
|
||||
// id placeholder as that worker's config, so both point at one namespace.
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "RECFLARE_PLAYER_SETTINGS",
|
||||
"id": "local"
|
||||
}
|
||||
],
|
||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||
"durable_objects": {
|
||||
|
||||
Reference in New Issue
Block a user