From f2f3c7badc912e219498dd3e258a4a5f42aa5d42 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 19 Aug 2026 17:36:01 -0400 Subject: [PATCH] [events] prevent events longer than 24h --- apps/api/src/events-db.ts | 97 ++++++-- apps/api/src/openapi.ts | 48 +++- apps/api/src/routes/events.ts | 285 +++++++++++++++++++++- apps/api/src/test/integration/api.test.ts | 267 +++++++++++++++++++- packages/domain/src/validation.ts | 12 + 5 files changed, 681 insertions(+), 28 deletions(-) diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index ad0ae26..4c59329 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -17,7 +17,12 @@ * relational table rather than a JSON blob. */ -import { glyphLength, MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_NAME_LENGTH } from '@repo/domain' +import { + glyphLength, + MAX_EVENT_DESCRIPTION_LENGTH, + MAX_EVENT_DURATION_MS, + MAX_EVENT_NAME_LENGTH, +} from '@repo/domain' /** * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any @@ -315,6 +320,21 @@ function eventTime(ms: number): string { return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z') } +/** + * Normalize one posted timestamp into the stored form, or undefined when it isn't a + * usable date. + * + * Exported for the single-field time edit (`PUT …/v2/{id}/time`), which has to tell an + * ABSENT bound — leave the stored one alone — from an unusable one, which it refuses. + * {@link parseEventBody} collapses the two, since a create/update posting rubbish for a + * time is better off defaulting than failing. + */ +export function parseEventTime(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const parsed = Date.parse(raw) + return Number.isNaN(parsed) ? undefined : eventTime(parsed) +} + /** An event's tags, alphabetical so a list read is stable. */ export async function getEventTags(db: D1Database, eventId: number): Promise { const { results } = await db @@ -384,26 +404,46 @@ function asInt(value: unknown): number | undefined { } /** - * Parse a posted event body into an {@link EventInput}. + * The window a write would end up storing, resolved the way {@link createEvent} and + * {@link updateEvent} resolve it: a bound the body carries wins, otherwise the stored one + * (an edit), otherwise the create defaults — now, and an hour later. * - * Accepts the event's fields either at the top level or nested under `PlayerEvent`: - * the client posts the same envelope it reads back, and both forms are in circulation. - * A field the body doesn't carry stays undefined (create defaults it, update keeps the - * stored value); an explicit `null` on one of the nullable ids is preserved so it can - * clear the value. Timestamps are normalized here, so an unparseable one is dropped - * rather than stored. + * Exists so the duration rule below and the writes themselves can't drift apart on what + * "the event's window" means for a body that moves only one bound. */ +function resolvedWindow( + input: EventInput, + existing?: PlayerEvent, + now = Date.now() +): { start: number; end: number } { + const start = Date.parse(input.startTime ?? existing?.StartTime ?? eventTime(now)) + const stored = input.endTime ?? existing?.EndTime + return { start, end: stored === undefined ? start + DEFAULT_DURATION_MS : Date.parse(stored) } +} + /** * Why a parsed event body can't be stored, or `null` when it's fine. * - * Length only. An event name is a title, not an identifier — "Building a Better Room - * Using Trigonometry" is a real one — so the alphanumeric rule the account and room - * names carry would be wrong here. Absent fields are skipped: an update posts only what - * it changes, and create defaults a missing name rather than refusing it. + * Two rules: the stored lengths, and the window. * - * The name is measured AFTER trimming, matching what create/update actually store. + * Lengths are a cap, not a charset — an event name is a title, not an identifier + * ("Building a Better Room Using Trigonometry" is a real one), so the alphanumeric rule + * the account and room names carry would be wrong here. Absent fields are skipped: an + * update posts only what it changes, and create defaults a missing name rather than + * refusing it. The name is measured AFTER trimming, matching what the writes store. + * + * The window is checked on what the write RESOLVES to rather than on the fields the body + * carries, which is why `existing` is passed for an edit: moving the start alone still + * has to leave a window that ends after it and runs no longer than + * {@link MAX_EVENT_DURATION_MS}. A create resolves against the same defaults + * {@link createEvent} applies, so a body naming neither bound — or only a start — can + * never fail this. + * + * A backwards window is refused here too. It isn't a duration rule as such, but it's the + * hole in one: `end - start` on a window running a month backwards is negative, which + * would sail past a "no longer than a day" check. */ -export function eventInputRejection(input: EventInput): string | null { +export function eventInputRejection(input: EventInput, existing?: PlayerEvent): string | null { const name = input.name?.trim() if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) { return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.` @@ -414,6 +454,16 @@ export function eventInputRejection(input: EventInput): string | null { ) { return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.` } + + const { start, end } = resolvedWindow(input, existing) + // Unparseable can't happen from `parseEventBody` (it drops what it can't read) but can + // from a stored blob edited by hand; skip the rule rather than refusing an edit that + // says nothing about the times. + if (Number.isNaN(start) || Number.isNaN(end)) return null + if (end < start) return 'An event cannot end before it starts.' + if (end - start > MAX_EVENT_DURATION_MS) { + return `An event can run for at most ${MAX_EVENT_DURATION_MS / (60 * 60 * 1000)} hours.` + } return null } @@ -427,7 +477,7 @@ export function eventInputRejection(input: EventInput): string | null { * are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops` * are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped. */ -function parseEventTags(raw: unknown): EventTag[] | undefined { +export function parseEventTags(raw: unknown): EventTag[] | undefined { if (!Array.isArray(raw)) return undefined const byTag = new Map() for (const entry of raw) { @@ -444,6 +494,16 @@ function parseEventTags(raw: unknown): EventTag[] | undefined { return [...byTag.values()] } +/** + * Parse a posted event body into an {@link EventInput}. + * + * Accepts the event's fields either at the top level or nested under `PlayerEvent`: + * the client posts the same envelope it reads back, and both forms are in circulation. + * A field the body doesn't carry stays undefined (create defaults it, update keeps the + * stored value); an explicit `null` on one of the nullable ids is preserved so it can + * clear the value. Timestamps are normalized here, so an unparseable one is dropped + * rather than stored. + */ export function parseEventBody(body: unknown): EventInput { const outer = (typeof body === 'object' && body !== null ? body : {}) as Record const nested = outer.PlayerEvent @@ -458,12 +518,7 @@ export function parseEventBody(body: unknown): EventInput { if (!has(key)) return undefined return obj[key] === null ? null : asInt(obj[key]) } - const time = (key: string): string | undefined => { - const raw = obj[key] - if (typeof raw !== 'string') return undefined - const parsed = Date.parse(raw) - return Number.isNaN(parsed) ? undefined : eventTime(parsed) - } + const time = (key: string): string | undefined => parseEventTime(obj[key]) const bool = (key: string): boolean | undefined => { const raw = obj[key] if (typeof raw === 'boolean') return raw diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index bb0e83e..fd32b16 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -610,7 +610,9 @@ export const PlayerEventDto = z.object({ Name: z.string(), Description: z.string(), StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'), - EndTime: z.string().describe('ISO 8601 UTC, seconds precision'), + EndTime: z + .string() + .describe('ISO 8601 UTC, seconds precision; at most 24 hours after `StartTime`'), AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'), State: z.int().describe('0 = scheduled'), Accessibility: z.int(), @@ -691,6 +693,50 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({ .describe('The event’s fields, if nested rather than posted at the top level'), }) +/** + * `PUT /api/playerevents/v2/{eventId}/time` form body — the event's window, moved. Both + * bounds are optional; an absent one keeps its stored value, so the start can be nudged + * without restating the end. The RESOLVED window must end after it starts and run no + * longer than 24 hours. + */ +export const PlayerEventTimeRequest = z.object({ + startTime: z + .string() + .optional() + .describe('New start, any parseable ISO 8601 — the client sends .NET tick precision'), + endTime: z.string().optional().describe('New end, same form'), +}) + +/** + * `PUT /api/playerevents/v2/{eventId}/accessibility` form body. The client sends the + * `RoomAccessibility` NAME, as it does on the subroom route in `rooms`; the ordinal is + * accepted too. + */ +export const PlayerEventAccessibilityRequest = z.object({ + accessibility: z + .string() + .describe( + '`Private`, `Public`, `Unlisted`, `Dev_only` or `Dev_Unlisted` (case-insensitive) — ' + + 'or its ordinal 0–4' + ), +}) + +/** `PUT /api/playerevents/v2/{eventId}/name` form body. */ +export const PlayerEventNameRequest = z.object({ + name: z.string().describe('The new title; blank is refused — an event always has a name'), +}) + +/** `PUT /api/playerevents/v2/{eventId}/description` form body. */ +export const PlayerEventDescriptionRequest = z.object({ + description: z.string().optional().describe('The new blurb; absent clears it'), +}) + +/** + * `PUT /api/playerevents/v2/{eventId}/tags` body — a BARE JSON ARRAY of tag names + * (`["tag1","class"]`), not an object. The whole set the event should carry. + */ +export const PlayerEventTagsRequest = z.array(z.string()).describe('The event’s whole tag set') + /** * `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as * the guest list serves it. diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 3cfe322..30f02dd 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { Accessibility } from '@repo/domain' import { logger } from '@repo/hono-helpers' // The notification-type ids the hub carries (owned by the `notify` worker). Imported @@ -21,6 +22,8 @@ import { inviteToEvent, isEventResponseType, parseEventBody, + parseEventTags, + parseEventTime, searchEvents, setEventResponse, toEventBase, @@ -32,15 +35,19 @@ import { import { authedId, queryIds, unauthorized } from '../http' import { AUTHED, + form, idParam, intQuery, json, jsonBody, pageParams, + PlayerEventAccessibilityRequest, PlayerEventBaseDto, PlayerEventBulkInviteRequest, + PlayerEventDescriptionRequest, PlayerEventDetailsDto, PlayerEventDto, + PlayerEventNameRequest, PlayerEventReportRequest, PlayerEventRequest, PlayerEventRespondRequest, @@ -48,6 +55,8 @@ import { PlayerEventResultDto, PlayerEventsAll, PlayerEventsPage, + PlayerEventTagsRequest, + PlayerEventTimeRequest, stringQuery, SuccessErrorEnvelope, TagFilters, @@ -58,7 +67,7 @@ import { createReport } from '../reports-db' import type { Context } from 'hono' import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads' import type { App } from '../context' -import type { EventAttendeeRow, EventTag, PlayerEvent } from '../events-db' +import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db' /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -133,6 +142,66 @@ async function notifyInvited( } } +/** + * The shared front half of the single-field event edits (`PUT …/v2/{id}/{field}`): + * authenticate, load the event, check the caller created it, then apply whatever patch + * `parse` reads out of the body and answer the same `{ Result, TagModifyResult, + * PlayerEvent }` envelope the other v2 writes do — the client re-renders the event from + * the response rather than refetching it. + * + * `parse` answers `null` to refuse the body, which becomes the empty-bodied 400 the rest + * of this file uses. It gets the stored event so a rule can depend on it (the time edit + * checks the new window against the bound it isn't changing). + * + * These edits are creator-only like the whole-event update, and they go through the same + * {@link updateEvent}, so a patch touching one field leaves the rest of the event alone. + */ +function editEventField( + parse: (c: Context, event: PlayerEvent) => Promise +) { + return async (c: Context) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + // `?? ''` only to satisfy the untyped-path signature — the route pattern already + // constrains the segment to digits, so it is always there. + const eventId = Number.parseInt(c.req.param('eventId') ?? '', 10) + const existing = await getEventById(c.env.DB, eventId) + if (existing === null) return c.body(null, 404) + if (existing.CreatorPlayerId !== id) return c.body(null, 403) + + const input = await parse(c, existing) + if (input === null) return c.body(null, 400) + const updated = await updateEvent(c.env.DB, eventId, input) + // updateEvent only returns null when the row vanished, which the read above rules out. + return c.json(toEventResult(updated!, await getEventTags(c.env.DB, eventId))) + } +} + +/** The form body of a single-field edit; an unparseable one reads as empty. */ +async function formBody(c: Context): Promise> { + return (await c.req.parseBody().catch(() => ({}))) as Record +} + +/** + * Parse an `accessibility` field into an {@link Accessibility} value. The client sends + * the enum NAME here (`accessibility=Unlisted`), as it does on the subroom route in + * `rooms`; the ordinal is accepted alongside it. Undefined when the field names nothing + * in the enum — which the route refuses rather than defaulting, since guessing a + * visibility wrong is the kind of mistake that shows a private event to everyone. + */ +function parseEventAccessibility(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined + const raw = value.trim() + const named = Object.entries(Accessibility).find( + ([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase() + ) + if (named) return named[1] as number + if (!/^\d+$/.test(raw)) return undefined + const ordinal = Number.parseInt(raw, 10) + return ordinal in Accessibility ? ordinal : undefined +} + /** * Player events — scheduled events players and clubs host in a room. * @@ -554,6 +623,10 @@ export const eventRoutes = new Hono({ strict: false }) 'which is what makes `AttendeeCount` start at 1, since that count is derived from ' + 'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' + 'the bare event the read endpoints serve.\n\n' + + 'The window is capped at 24 hours and must end after it starts — an event is a ' + + 'scheduled get-together, not a season. Since a missing end defaults to an hour ' + + 'after the start, only a body naming both bounds (or an end alone, which is ' + + 'measured from now) can fail this.\n\n' + 'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' + 'the event in its camelCase notification projection. A hub failure is logged and ' + 'swallowed — the event is already stored by then.', @@ -561,7 +634,11 @@ export const eventRoutes = new Hono({ strict: false }) requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'), responses: { 200: json(PlayerEventResultDto, 'The created event'), - 400: { description: 'Name over 64 or description over 512 characters (empty body)' }, + 400: { + description: + 'Name over 64 or description over 512 characters, or a window that is ' + + 'backwards or longer than 24 hours (empty body)', + }, 401: UNAUTHORIZED_RESPONSE, }, }), @@ -678,13 +755,20 @@ export const eventRoutes = new Hono({ strict: false }) 'clear it.\n\n' + 'The id, the creator and the attendee count are not editable: ownership doesn’t ' + 'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' + - 'an unknown event is 404. Answers the same envelope as create.', + 'an unknown event is 404. Answers the same envelope as create.\n\n' + + 'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' + + 'carries: moving the start alone still has to leave a window that ends after it ' + + 'and runs no longer than a day against the STORED end.', security: AUTHED, parameters: [idParam('eventId', 'Event id')], requestBody: jsonBody(PlayerEventRequest, 'The fields to change'), responses: { 200: json(PlayerEventResultDto, 'The updated event'), - 400: { description: 'Name over 64 or description over 512 characters (empty body)' }, + 400: { + description: + 'Name over 64 or description over 512 characters, or a resolved window that ' + + 'is backwards or longer than 24 hours (empty body)', + }, 401: UNAUTHORIZED_RESPONSE, 403: { description: 'Not the event’s creator (empty body)' }, 404: { description: 'No such event (empty body)' }, @@ -700,13 +784,204 @@ export const eventRoutes = new Hono({ strict: false }) const body = await c.req.json().catch(() => ({})) const input = parseEventBody(body) - if (eventInputRejection(input) !== null) return c.body(null, 400) + // The stored event is passed so the window rule resolves against the bound this + // post isn't moving: an edit that only shifts the start still has to land inside + // a day of the end already stored. + if (eventInputRejection(input, existing) !== null) return c.body(null, 400) const updated = await updateEvent(c.env.DB, eventId, input) // updateEvent only returns null when the row vanished, which the read above rules out. return c.json(toEventResult(updated!, await getEventTags(c.env.DB, eventId))) } ) + // ---- Single-field edits ------------------------------------------------- + // The event-settings screen edits one field at a time rather than posting the whole + // event back, so each of these is a PUT alongside the whole-event update above. They + // share its rules — creator-only, 404/403/401 the same way — and answer the same v2 + // envelope, which is what the client re-renders the event from. + // + // Note the bodies are FORM-encoded (the tags one excepted), where the whole-event + // writes next to them are JSON. That is what the client sends; don't unify them. + + // Move an event's window. Either bound alone is enough — an absent one keeps its + // stored value, so the start can be nudged without restating the end. + .put( + '/api/playerevents/v2/:eventId{[0-9]+}/time', + describeRoute({ + tags: ['Events'], + summary: 'Reschedule a player event', + description: + 'Moves an event’s window. `startTime` and `endTime` are both optional and both ' + + 'independent: an absent bound keeps the stored one, so the start can be nudged ' + + 'without restating the end. Any parseable ISO 8601 is accepted — the client sends ' + + '.NET tick precision (`2026-08-31T17:30:00.0000000Z`) — and stored trimmed to ' + + 'seconds, the form every read serves.\n\n' + + 'A bound that is present but unparseable is a 400 rather than being dropped: a ' + + 'reschedule that silently did nothing is worse than a refusal. So is a window that ' + + 'ends before it starts, or one running longer than 24 HOURS — an event lasts at ' + + 'most a day. Both are checked against the RESOLVED window, so sending one bound ' + + 'is measured against the stored other one.\n\n' + + 'Creator only, like the whole-event update; answers the same v2 envelope.', + security: AUTHED, + parameters: [idParam('eventId', 'Event id')], + requestBody: form(PlayerEventTimeRequest, 'The new window'), + responses: { + 200: json(PlayerEventResultDto, 'The rescheduled event'), + 400: { + description: + 'An unparseable time, an end before the start, or a window over 24 hours ' + + '(empty body)', + }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the event’s creator (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + editEventField(async (c, event) => { + const body = await formBody(c) + // Absent leaves the stored bound alone; present-but-unusable is refused, which is + // the distinction `parseEventBody` deliberately collapses for the JSON writes. + const startTime = parseEventTime(body.startTime) + const endTime = parseEventTime(body.endTime) + if (body.startTime !== undefined && startTime === undefined) return null + if (body.endTime !== undefined && endTime === undefined) return null + // The window rules — ends after it starts, runs no longer than a day — live with + // the other write validation, resolved against the bound this edit isn't moving. + const input = { startTime, endTime } + return eventInputRejection(input, event) === null ? input : null + }) + ) + + // Change an event's visibility. The NAME of the enum, as the subroom route in `rooms` + // takes it — not the ordinal the event's JSON writes carry. + .put( + '/api/playerevents/v2/:eventId{[0-9]+}/accessibility', + describeRoute({ + tags: ['Events'], + summary: 'Set a player event’s accessibility', + description: + 'Sets an event’s visibility. The client sends the `RoomAccessibility` NAME here ' + + '(`accessibility=Unlisted`), the way it does on the subroom route in `rooms` — not ' + + 'the ordinal the event’s JSON writes carry, though the ordinal is accepted too.\n\n' + + 'A value naming nothing in the enum is a 400 rather than being defaulted or stored ' + + 'verbatim: guessing a visibility wrong is what shows a private event to everyone. ' + + 'Creator only; answers the same v2 envelope.', + security: AUTHED, + parameters: [idParam('eventId', 'Event id')], + requestBody: form(PlayerEventAccessibilityRequest, 'The new visibility'), + responses: { + 200: json(PlayerEventResultDto, 'The updated event'), + 400: { description: 'Missing or unrecognized `accessibility` (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the event’s creator (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + editEventField(async (c) => { + const accessibility = parseEventAccessibility((await formBody(c)).accessibility) + return accessibility === undefined ? null : { accessibility } + }) + ) + + // Replace an event's tags. A BARE JSON ARRAY of names, unlike the other edits here. + .put( + '/api/playerevents/v2/:eventId{[0-9]+}/tags', + describeRoute({ + tags: ['Events'], + summary: 'Set a player event’s tags', + description: + 'Replaces an event’s whole tag set. The body is a BARE JSON ARRAY of names — ' + + '`["tag1","class"]` — not the form encoding the other single-field edits use, and ' + + 'not an object; the `{ tag, type }` pairs the create/update bodies accept work too. ' + + 'A replace, not a merge: untagging is a PUT with the tag left out, and `[]` clears ' + + 'them all.\n\n' + + 'Names are lowercased and a leading `#` stripped, matching what the `#tag` search ' + + 'looks for. A body that is not an array is a 400. Creator only; answers the same v2 ' + + 'envelope, whose `TagModifyResult` carries the set the event now has.', + security: AUTHED, + parameters: [idParam('eventId', 'Event id')], + requestBody: jsonBody(PlayerEventTagsRequest, 'The whole tag set'), + responses: { + 200: json(PlayerEventResultDto, 'The updated event, with its new tags'), + 400: { description: 'The body is not a JSON array (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the event’s creator (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + editEventField(async (c) => { + const body = await c.req.json().catch(() => undefined) + const tags = parseEventTags(body) + return tags === undefined ? null : { tags } + }) + ) + + // Rewrite an event's blurb. An absent field clears it — the client sends no field for + // an emptied box, like the room description route in `rooms`. + .put( + '/api/playerevents/v2/:eventId{[0-9]+}/description', + describeRoute({ + tags: ['Events'], + summary: 'Set a player event’s description', + description: + 'Rewrites an event’s blurb. An absent `description` CLEARS it — an emptied text box ' + + 'sends no field, the same way the room description route in `rooms` behaves — so ' + + 'this is the one edit here that can’t be a no-op.\n\n' + + 'Capped at 512 characters, the stored length, and refused rather than truncated: ' + + 'silently cutting a player’s text off is worse than telling them. Creator only; ' + + 'answers the same v2 envelope.', + security: AUTHED, + parameters: [idParam('eventId', 'Event id')], + requestBody: form(PlayerEventDescriptionRequest, 'The new description'), + responses: { + 200: json(PlayerEventResultDto, 'The updated event'), + 400: { description: 'Description over 512 characters (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the event’s creator (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + editEventField(async (c) => { + const raw = (await formBody(c)).description + const description = typeof raw === 'string' ? raw : '' + return eventInputRejection({ description }) === null ? { description } : null + }) + ) + + // Rename an event. Unlike the description, a blank one is refused: `updateEvent` reads + // an empty name as "leave it alone", so storing one is impossible anyway — and an event + // with no title renders as a blank row. + .put( + '/api/playerevents/v2/:eventId{[0-9]+}/name', + describeRoute({ + tags: ['Events'], + summary: 'Rename a player event', + description: + 'Retitles an event. Capped at 64 characters, the stored length, and refused rather ' + + 'than truncated. A blank name is refused too — an event with no title renders as a ' + + 'blank row, and the whole-event update reads an empty name as “leave it alone”, so ' + + 'there is no way to store one regardless. The name is stored trimmed.\n\n' + + 'No uniqueness rule: two events may share a title, unlike a room name. Creator ' + + 'only; answers the same v2 envelope.', + security: AUTHED, + parameters: [idParam('eventId', 'Event id')], + requestBody: form(PlayerEventNameRequest, 'The new title'), + responses: { + 200: json(PlayerEventResultDto, 'The renamed event'), + 400: { description: 'A blank name, or one over 64 characters (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the event’s creator (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + editEventField(async (c) => { + const raw = (await formBody(c)).name + const name = typeof raw === 'string' ? raw.trim() : '' + if (name === '' || eventInputRejection({ name }) !== null) return null + return { name } + }) + ) + // An event's guest list — every RSVP row, whatever the answer. .get( '/api/playerevents/v1/:eventId{[0-9]+}/responses', diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index f11b262..c8f3a76 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -48,7 +48,7 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war import type { SavedImage } from '@repo/domain' import type { Env } from '../../context' -import type { PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db' +import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db' import type { InventionSaveResult, SavedInvention } from '../../inventions-db' declare module 'cloudflare:test' { @@ -3486,6 +3486,8 @@ describe('mutual friends', () => { describe('player events', () => { const HOUR = 60 * 60 * 1000 + /** The longest window a write may store — an event lasts at most a day. */ + const DAY = 24 * HOUR /** * Seconds precision, no milliseconds — the form the client sends and reads back. * @@ -3685,6 +3687,66 @@ describe('player events', () => { expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe') }) + test('the event writes cap the window at 24 hours', async () => { + const window = (StartTime: string, EndTime: string) => + post('/api/playerevents/v2', { Name: 'Windowed', RoomId: 3, StartTime, EndTime }) + + // A day exactly is allowed — "at most one day", not "under one day". + expect((await window(at(0), at(DAY))).status).toBe(200) + // A second past it is not. + expect((await window(at(0), at(DAY + 1000))).status).toBe(400) + expect((await window(at(0), at(30 * DAY))).status).toBe(400) + + // A missing end defaults to an hour after the start, so it can never fail… + expect( + (await post('/api/playerevents/v2', { Name: 'Open ended', RoomId: 3, StartTime: at(DAY) })) + .status + ).toBe(200) + // …but an end alone is measured from now, which can. + expect( + (await post('/api/playerevents/v2', { Name: 'Far end', RoomId: 3, EndTime: at(2 * DAY) })) + .status + ).toBe(400) + expect( + (await post('/api/playerevents/v2', { Name: 'Near end', RoomId: 3, EndTime: at(HOUR) })) + .status + ).toBe(200) + // A body naming neither is defaulted, as before. + expect((await post('/api/playerevents/v2', { Name: 'Untimed', RoomId: 3 })).status).toBe(200) + + // A backwards window is refused too — `end - start` on one running a month + // backwards is negative, which would sail past a "no longer than a day" check. + expect((await window(at(3 * HOUR), at(HOUR))).status).toBe(400) + }) + + test('the 24-hour cap is checked on the window a write RESOLVES to', async () => { + const event = await create({ + RoomId: 3, + Name: 'Movable', + StartTime: at(5 * HOUR), + EndTime: at(6 * HOUR), + }) + const path = `/api/playerevents/v2/${event.PlayerEventId}` + + // Moving one bound is measured against the STORED other one, not against a default: + // a start dragged two days back leaves a window far longer than a day. + expect((await post(path, { StartTime: at(-2 * DAY) })).status).toBe(400) + expect((await post(path, { EndTime: at(2 * DAY) })).status).toBe(400) + // Both bounds moved together stay inside the cap, so this is fine. + expect((await post(path, { StartTime: at(2 * DAY), EndTime: at(2 * DAY + HOUR) })).status).toBe( + 200 + ) + // An edit that says nothing about the times is unaffected. + expect((await post(path, { Name: 'Still Movable' })).status).toBe(200) + + // …and a refusal left the event where it was. + const stored = (await ( + await get(`/api/playerevents/v1/${event.PlayerEventId}`) + ).json()) as PlayerEvent + expect(stored.StartTime).toBe(at(2 * DAY)) + expect(stored.EndTime).toBe(at(2 * DAY + HOUR)) + }) + test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => { const res = await post('/api/playerevents/v2', { Name: 'Enveloped', @@ -4416,6 +4478,204 @@ describe('player events', () => { expect(updated.CreatorPlayerId).toBe(42) expect(updated.AttendeeCount).toBe(1) }) + + // ---- Single-field edits (PUT …/v2/:eventId/:field) ----------------------- + + /** A form-encoded single-field edit — the encoding the client sends on these. */ + const putForm = async ( + path: string, + fields: Record, + sub: string | null = '42' + ): Promise => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'PUT', + headers: { + ...(sub === null ? {} : await bearer(sub)), + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) + + const putJson = async (path: string, body: unknown, sub = '42'): Promise => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + /** The envelope's event out of a 200 from one of the edits. */ + const edited = async (res: Response): Promise => { + expect(res.status).toBe(200) + const body = (await res.json()) as PlayerEventResult + expect(body.Result).toBe(0) + return body.PlayerEvent + } + + test('PUT /api/playerevents/v2/:eventId/time moves either bound independently', async () => { + const event = await create({ + RoomId: 5, + Name: 'Reschedulable', + StartTime: at(5 * HOUR), + EndTime: at(6 * HOUR), + }) + const path = `/api/playerevents/v2/${event.PlayerEventId}/time` + + // The client sends .NET tick precision; it is stored trimmed to seconds. + const moved = await edited( + await putForm(path, { + startTime: at(7 * HOUR).replace('Z', '.0000000Z'), + endTime: at(9 * HOUR).replace('Z', '.0000000Z'), + }) + ) + expect(moved).toEqual({ ...event, StartTime: at(7 * HOUR), EndTime: at(9 * HOUR) }) + + // One bound alone keeps the other. + const nudged = await edited(await putForm(path, { endTime: at(10 * HOUR) })) + expect(nudged.StartTime).toBe(at(7 * HOUR)) + expect(nudged.EndTime).toBe(at(10 * HOUR)) + + // A day exactly is allowed — the cap is "at most a day", not "under a day". + const full = await edited(await putForm(path, { endTime: at(7 * HOUR + DAY) })) + expect(full.EndTime).toBe(at(7 * HOUR + DAY)) + }) + + test('PUT /api/playerevents/v2/:eventId/time refuses rubbish and a backwards window', async () => { + const event = await create({ + RoomId: 5, + Name: 'Fixed Window', + StartTime: at(5 * HOUR), + EndTime: at(6 * HOUR), + }) + const path = `/api/playerevents/v2/${event.PlayerEventId}/time` + + // Present but unparseable is refused rather than dropped: a reschedule that + // silently did nothing is worse than a refusal. + expect((await putForm(path, { startTime: 'tomorrowish' })).status).toBe(400) + // An end before the start, checked against the STORED bound when only one is sent. + expect((await putForm(path, { endTime: at(4 * HOUR) })).status).toBe(400) + expect((await putForm(path, { startTime: at(9 * HOUR), endTime: at(8 * HOUR) })).status).toBe( + 400 + ) + // And a window longer than a day, resolved the same way. + expect((await putForm(path, { endTime: at(5 * HOUR + DAY + 1000) })).status).toBe(400) + expect((await putForm(path, { startTime: at(-DAY) })).status).toBe(400) + expect( + (await putForm(path, { startTime: at(2 * DAY), endTime: at(2 * DAY + DAY + 1000) })).status + ).toBe(400) + // An empty body changes nothing, and is not an error. + expect((await edited(await putForm(path, {}))).StartTime).toBe(at(5 * HOUR)) + // …and nothing stuck. + const stored = (await ( + await get(`/api/playerevents/v1/${event.PlayerEventId}`) + ).json()) as PlayerEvent + expect(stored.EndTime).toBe(at(6 * HOUR)) + }) + + test('PUT /api/playerevents/v2/:eventId/accessibility takes the enum name', async () => { + const event = await create({ RoomId: 5, Name: 'Visible', Accessibility: 1 }) + const path = `/api/playerevents/v2/${event.PlayerEventId}/accessibility` + + // The NAME is what the client sends here. + expect((await edited(await putForm(path, { accessibility: 'Unlisted' }))).Accessibility).toBe(2) + // Case-insensitively… + expect((await edited(await putForm(path, { accessibility: 'private' }))).Accessibility).toBe(0) + // …and the ordinal works too. + expect((await edited(await putForm(path, { accessibility: '4' }))).Accessibility).toBe(4) + + // Anything else is refused rather than stored verbatim — guessing a visibility + // wrong is what shows a private event to everyone. + expect((await putForm(path, { accessibility: 'Secret' })).status).toBe(400) + expect((await putForm(path, { accessibility: '9' })).status).toBe(400) + expect((await putForm(path, {})).status).toBe(400) + expect( + ((await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()) as PlayerEvent) + .Accessibility + ).toBe(4) + }) + + test('PUT /api/playerevents/v2/:eventId/tags replaces the whole set from a bare array', async () => { + const event = await create({ RoomId: 5, Name: 'Taggable', Tags: ['meetup'] }) + const path = `/api/playerevents/v2/${event.PlayerEventId}/tags` + + // A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes. + const tagged = await edited(await putJson(path, ['tag1', '#Class'])) + expect(tagged.Tags).toEqual(['class', 'tag1']) + // The envelope's TagModifyResult reports the same set the client redraws chips from. + const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult + expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] }) + + // `[]` clears them; a non-array body is refused. + expect((await edited(await putJson(path, []))).Tags).toEqual([]) + expect((await putJson(path, { Tags: ['nope'] })).status).toBe(400) + expect( + ( + (await ( + await get(`/api/playerevents/v1/${event.PlayerEventId}?includeDetails=True`) + ).json()) as PlayerEvent & { tags: EventTag[] } + ).tags + ).toEqual([]) + }) + + test('PUT /api/playerevents/v2/:eventId/description rewrites the blurb; absent clears it', async () => { + const event = await create({ RoomId: 5, Name: 'Described', Description: 'The old blurb' }) + const path = `/api/playerevents/v2/${event.PlayerEventId}/description` + + const written = await edited( + await putForm(path, { description: 'fthe description of said event' }) + ) + expect(written).toEqual({ ...event, Description: 'fthe description of said event' }) + + // An emptied text box sends no field at all, which clears it. + expect((await edited(await putForm(path, {}))).Description).toBe('') + + // Capped at the stored length, and refused rather than truncated. + expect((await putForm(path, { description: 'd'.repeat(513) })).status).toBe(400) + expect((await putForm(path, { description: 'd'.repeat(512) })).status).toBe(200) + }) + + test('PUT /api/playerevents/v2/:eventId/name retitles, refusing a blank or overlong one', async () => { + const event = await create({ RoomId: 5, Name: 'Before' }) + const path = `/api/playerevents/v2/${event.PlayerEventId}/name` + + const renamed = await edited( + await putForm(path, { name: 'an event in the future I should be able to editx' }) + ) + expect(renamed).toEqual({ + ...event, + Name: 'an event in the future I should be able to editx', + }) + // Stored trimmed. + expect((await edited(await putForm(path, { name: ' Padded ' }))).Name).toBe('Padded') + + // A blank name renders as a blank row, and the whole-event update reads one as + // "leave it alone" — so it is refused outright here. + expect((await putForm(path, { name: ' ' })).status).toBe(400) + expect((await putForm(path, {})).status).toBe(400) + expect((await putForm(path, { name: 'n'.repeat(65) })).status).toBe(400) + expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200) + }) + + test('the single-field edits are creator-only, like the whole-event update', async () => { + const event = await create({ RoomId: 5, Name: 'Guarded' }) + const id = event.PlayerEventId + for (const [field, fields] of [ + ['time', { startTime: at(8 * HOUR) }], + ['accessibility', { accessibility: 'Public' }], + ['description', { description: 'nope' }], + ['name', { name: 'Hijacked' }], + ] as Array<[string, Record]>) { + expect((await putForm(`/api/playerevents/v2/${id}/${field}`, fields, null)).status).toBe(401) + // 43 didn't create it. + expect((await putForm(`/api/playerevents/v2/${id}/${field}`, fields, '43')).status).toBe(403) + expect((await putForm(`/api/playerevents/v2/999999/${field}`, fields)).status).toBe(404) + } + // The tags edit takes JSON rather than a form, but is gated the same way. + expect((await putJson(`/api/playerevents/v2/${id}/tags`, ['nope'], '43')).status).toBe(403) + expect((await putJson('/api/playerevents/v2/999999/tags', ['nope'])).status).toBe(404) + + // Nothing moved. + expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null)) + }) }) describe('openapi', () => { @@ -4577,6 +4837,11 @@ describe('openapi', () => { 'POST /api/sanitize/v1/isPure', 'POST /api/v1/progression/bulk', 'POST /statsigUserProperties', + 'PUT /api/playerevents/v2/{eventId}/accessibility', + 'PUT /api/playerevents/v2/{eventId}/description', + 'PUT /api/playerevents/v2/{eventId}/name', + 'PUT /api/playerevents/v2/{eventId}/tags', + 'PUT /api/playerevents/v2/{eventId}/time', 'PUT /api/players/v1/playerPhotoTaggingSetting', 'PUT /outfits/me', ]) diff --git a/packages/domain/src/validation.ts b/packages/domain/src/validation.ts index 3415899..bce705a 100644 --- a/packages/domain/src/validation.ts +++ b/packages/domain/src/validation.ts @@ -38,6 +38,18 @@ export const MAX_CLUB_DESCRIPTION_LENGTH = 512 export const MAX_EVENT_NAME_LENGTH = 64 export const MAX_EVENT_DESCRIPTION_LENGTH = 512 +/** + * The longest a player event may run — a day, inclusive, so a full 24-hour event is + * allowed and anything past it is refused. An event is a scheduled get-together in one + * room, not a season: a window of weeks would sit in the browse feed's "upcoming" and + * "happening now" rows indefinitely, crowding out everything real. + * + * Applied to the window a write RESOLVES to, not to the fields it carries — an edit that + * moves one bound is checked against the stored other one. See `eventInputRejection` in + * the api worker. + */ +export const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1000 + /** * Invention limits. A name is a title a player types into the invention-save box and * reads back in a browse tile, so it allows the punctuation a title needs — but