openvibers/openvibe.games Commits

Games: creator worlds announce their first publication (games.world.published) and staff actions; saves accept Cloudflare's weak ETag (#89)

OP OpenVibers committed 29 days ago · 011b37a359 · 5 files, +169 −12

- A world's first publication writes games.world.published (contracts 0.194.0) in the same
  transaction: OpenVibe.Quest's "Publish a creator world". Unpublishing and publishing again does not
  repeat it.
- Staff hiding, restoring or deleting someone else's world is a games.moderation.action (world.hidden,
  world.restored, world.deleted) for Network's moderation log; a deletion's event commits with the
  delete.
- If-Match: Cloudflare hands the browser the map's ETag as W/"…", so the first save from the editor
  was refused as stale (seen in the production probe). The revision is read without the W/ prefix.

Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex c9cf613..7225bf8 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -767,10 +767,13 @@ async function main(): Promise<void> {
void router.serialize(async () => worldHost.applyMap(id, map))
},
changed: (world, id) => router.serialize(() => worldHost.changed(world, id)),
-- remove: (id) =>
++ remove: (id, within) =>
router.serialize(async () => {
await worldHost.changed(null, id)
-- await creatorWorlds.remove(id)
++ await db.tx(async (t) => {
++ await creatorWorlds.remove(id)
++ await within?.(t)
++ })
}),
},
resolveUser: (token) =>
@@@ -781,6 +784,7 @@ async function main(): Promise<void> {
address: clientAddress,
log: log.child({ system: 'worlds' }),
perPerson: config.creatorWorldsPerPerson,
++ events: { db, sink, siteUrl: config.oauth?.selfUrl ?? 'https://openvibe.games' },
})
// The casual games' JSON API (plan T22).
const serveCasual = createCasualRoutes({
ddiff --git a/apps/server/src/platform/gameEvents.ts b/apps/server/src/platform/gameEvents.ts
iindex f8554f4..30197f9 100644
---- a/apps/server/src/platform/gameEvents.ts
++++ b/apps/server/src/platform/gameEvents.ts
@@@ -7,6 +7,7 @@
* games.skill.leveled a persisted skill level went up (one event per skill per save)
* games.blueprint.unlocked a persisted blueprint recipe unlock
* games.world.saved the authoritative world was written (checkpoint / shutdown)
++ * games.world.published a player published a creator world for the first time (worlds/worldRoutes.ts)
* games.mod.* mod install, enable, disable, grant changes and revoke
* games.moderation.action staff took down or put back a mod someone else published
* (common.moderation-action@1, ADR-022), for Network's moderation
@@@ -157,6 +158,31 @@ export function worldSavedEvent(
}
}
++/**
++ * games.world.published (contracts 0.194.0): a player published one of their creator worlds for the first time.
++ * Public: the world is listed at /worlds. OpenVibe.Quest credits the owner's "Publish a creator world" quest.
++ */
++export function worldPublishedEvent(
++ world: { id: string; ownerSubject: string; title: string; publishedAt: number | null },
++ siteUrl: string,
++): EventInput {
++ const owner = actorFor(world.ownerSubject)
++ return {
++ event_type: 'games.world.published',
++ actor: owner,
++ subject: { type: 'world', id: world.id },
++ visibility: 'public',
++ priority: 'low',
++ payload: {
++ world: world.id,
++ owner,
++ title: world.title,
++ url: `${siteUrl.replace(/\/+$/, '')}/play?world=${world.id}`,
++ published_at: new Date(world.publishedAt ?? Date.now()).toISOString(),
++ },
++ }
++}
++
export type ModLifecycle = 'installed' | 'enabled' | 'disabled' | 'revoked' | 'grants_changed'
export function modEvent(
ddiff --git a/apps/server/src/worlds/worldRoutes.test.ts b/apps/server/src/worlds/worldRoutes.test.ts
iindex e4f1fa7..5eb21c3 100644
---- a/apps/server/src/worlds/worldRoutes.test.ts
++++ b/apps/server/src/worlds/worldRoutes.test.ts
@@@ -4,10 +4,16 @@ import { emptyMapV2, type MapFileV2 } from '@openvibe/content'
import { createCreatorWorldStore, type CreatorWorld } from '@openvibe/persistence'
import { openTestDb } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
++import { createRequire } from 'node:module'
++import type { EventInput } from 'openvibe-sdk/events'
import { afterEach, describe, expect, it } from 'vitest'
import { createGamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.js'
--import { cleanText, createWorldRoutes } from './worldRoutes.js'
++import { cleanText, createWorldRoutes, parseIfMatch } from './worldRoutes.js'
++
++const { validate } = createRequire(import.meta.url)('openvibe-contracts') as {
++ validate(id: string, v: unknown): { valid: boolean; errors?: unknown }
++}
const KIM = 'usr_01J0000000000000000000000K'
const LEE = 'usr_01J0000000000000000000000L'
@@@ -32,6 +38,7 @@ async function setup(perPerson = 2) {
applied: [] as string[],
changed: [] as (string | null)[],
removed: [] as string[],
++ events: [] as EventInput[],
}
const handler = createWorldRoutes({
store,
@@@ -41,9 +48,12 @@ async function setup(perPerson = 2) {
changed: async (world: CreatorWorld | null, id: string) => {
calls.changed.push(world ? `${id}:${world.status}` : null)
},
-- remove: async (id) => {
++ remove: async (id, within) => {
calls.removed.push(id)
-- await store.remove(id)
++ await db.tx(async (t) => {
++ await store.remove(id)
++ await within?.(t)
++ })
},
},
resolveUser: async (token) => USERS[token] ?? null,
@@@ -51,6 +61,17 @@ async function setup(perPerson = 2) {
address: () => '127.0.0.1',
log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),
perPerson,
++ events: {
++ db,
++ sink: {
++ enabled: true,
++ enqueue: async (_t, event) => {
++ expect(db.inTransaction()).toBe(true)
++ calls.events.push(event)
++ },
++ },
++ siteUrl: 'https://openvibe.games',
++ },
})
server = createServer((req, res) => {
if (!handler(req, res)) {
@@@ -96,6 +117,13 @@ describe('/api/worlds', () => {
expect(cleanText(42, 60)).toBeNull()
})
++ it('reads If-Match the way it arrives through Cloudflare (a weak ETag) or straight', () => {
++ expect(parseIfMatch('W/"abc123"')).toBe('abc123')
++ expect(parseIfMatch('"abc123"')).toBe('abc123')
++ expect(parseIfMatch(undefined)).toBeNull()
++ expect(parseIfMatch('')).toBeNull()
++ })
++
it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {
const { call, calls } = await setup()
expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)
@@@ -133,7 +161,7 @@ describe('/api/worlds', () => {
expect(refused.body.issues?.some((i) => i.startsWith('textures:'))).toBe(true)
const next: MapFileV2 = { ...emptyMapV2(), spawn: [2, 1, 2] }
const saved = await call('PUT', `/api/worlds/${id}/map`, 'kim', next, {
-- 'if-match': map.etag ?? '',
++ 'if-match': `W/${map.etag ?? ''}`,
})
expect(saved.status).toBe(200)
expect(calls.applied).toEqual([id])
@@@ -150,6 +178,21 @@ describe('/api/worlds', () => {
200,
)
expect(calls.changed).toEqual([`${id}:published`])
++ // The first publication is an event (Quest's "Publish a creator world"); publishing again is not.
++ expect(calls.events.map((e) => e.event_type)).toEqual(['games.world.published'])
++ const published = calls.events[0] as EventInput & { payload: Record<string, unknown> }
++ expect(published.payload).toMatchObject({
++ world: id,
++ owner: { type: 'user', id: KIM },
++ title: 'Moon base',
++ url: `https://openvibe.games/play?world=${id}`,
++ })
++ expect(validate('games.world.published@1', published.payload)).toMatchObject({ valid: true })
++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'draft' })).status).toBe(200)
++ expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(
++ 200,
++ )
++ expect(calls.events).toHaveLength(1)
const listed = (await call('GET', '/api/worlds')).body.worlds ?? []
expect(listed.map((w) => w.id)).toEqual([id])
expect(listed[0]).toMatchObject({
@@@ -167,6 +210,10 @@ describe('/api/worlds', () => {
expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { status: 'hidden' })).status).toBe(
200,
)
++ expect(calls.events.at(-1)).toMatchObject({
++ event_type: 'games.moderation.action',
++ payload: { action: 'world.hidden', target: { type: 'world', id, owner_subject: KIM } },
++ })
expect((await call('GET', '/api/worlds')).body.worlds).toEqual([])
expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(
403,
@@@ -181,6 +228,18 @@ describe('/api/worlds', () => {
expect((await call('DELETE', `/api/worlds/${id}`, 'lee')).status).toBe(404)
expect((await call('DELETE', `/api/worlds/${id}`, 'kim')).status).toBe(204)
expect(calls.removed).toEqual([id])
++ // The owner deleting is not moderation; games staff deleting someone else's world is.
++ expect(calls.events.filter((e) => e.event_type === 'games.moderation.action')).toHaveLength(1)
++ const other = String((await call('POST', '/api/worlds', 'lee', { title: 'Lee world' })).body.id)
++ expect((await call('DELETE', `/api/worlds/${other}`, 'staff')).status).toBe(204)
++ expect(calls.events.at(-1)).toMatchObject({
++ event_type: 'games.moderation.action',
++ actor: { type: 'user', id: STAFF },
++ payload: {
++ action: 'world.deleted',
++ target: { type: 'world', id: other, owner_subject: LEE },
++ },
++ })
expect((await call('GET', `/api/worlds/${id}`, 'kim')).status).toBe(404)
expect((await call('GET', '/api/worlds/not-an-id')).status).toBe(404)
})
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 8fe3604..99df418 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -9,6 +9,14 @@ import {
} from '@openvibe/content'
import type { CreatorWorld, CreatorWorldStatus, CreatorWorldStore } from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
++import type { Db, Tx } from 'openvibe-sdk/db'
++import type { EventInput } from 'openvibe-sdk/events'
++import {
++ actorFor,
++ moderationEvent,
++ worldPublishedEvent,
++ type EventSink,
++} from '../platform/gameEvents.js'
import type { GamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.js'
@@@ -32,8 +40,11 @@ export interface WorldHostView {
online(): Map<string, number>
applyMap(id: string, map: MapFileV2): void
changed(world: CreatorWorld | null, id: string): Promise<void>
-- /** Stops the world (its last save lands) and removes it with its place's rows. */
-- remove(id: string): Promise<void>
++ /**
++ * Stops the world (its last save lands) and removes it with its place's rows; `within` runs in the transaction
++ * that removes it (a staff deletion's moderation event).
++ */
++ remove(id: string, within?: (t: Tx) => Promise<void>): Promise<void>
}
export interface WorldRouteOptions {
@@@ -45,6 +56,11 @@ export interface WorldRouteOptions {
address: (req: IncomingMessage) => string
log: Logger
perPerson: number
++ /**
++ * Events through Games' outbox, each in the transaction of the change it describes: games.world.published on a
++ * world's first publication, games.moderation.action when staff hide, restore or delete someone else's world.
++ */
++ events?: { db: Db; sink: EventSink; siteUrl: string }
now?: () => number
}
@@@ -118,6 +134,16 @@ function ownerView(world: CreatorWorld, online: number) {
}
}
++/** An If-Match header as a bare revision: Cloudflare hands the browser a weak `W/"…"` form of the ETag. */
++export function parseIfMatch(header: string | string[] | undefined): string | null {
++ const raw = Array.isArray(header) ? header[0] : header
++ const value = String(raw ?? '')
++ .trim()
++ .replace(/^W\//, '')
++ .replace(/"/g, '')
++ return value || null
++}
++
function readBody(req: IncomingMessage, max: number): Promise<{ text: string; bytes: number }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
@@@ -149,6 +175,15 @@ function parseJson(text: string): unknown {
export function createWorldRoutes(opts: WorldRouteOptions) {
const now = opts.now ?? Date.now
const online = (id: string): number => opts.host.online().get(id) ?? 0
++ /** Runs a change with its events in one transaction (or without events when the outbox is off). */
++ const withEvents = <T>(
++ fn: (enqueue: (event: EventInput) => Promise<void>) => Promise<T>,
++ ): Promise<T> => {
++ const events = opts.events?.sink.enabled ? opts.events : null
++ return events
++ ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event)))
++ : fn(async () => {})
++ }
const send = (
res: ServerResponse,
@@@ -291,7 +326,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const issues = creatorMapIssues(map, bytes)
if (issues.length) fail(422, 'map_invalid', { issues })
const { revision: _client, ...doc } = map
-- const ifMatch = (req.headers['if-match'] as string | undefined)?.replace(/"/g, '') || null
++ const ifMatch = parseIfMatch(req.headers['if-match'])
const saved = await opts.store.saveMap(id, doc, ifMatch, now())
if (saved === null) fail(404, 'world_not_found')
if (saved === 'conflict') {
@@@ -364,7 +399,22 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
}
patch.status = status as CreatorWorldStatus
}
-- const updated = await opts.store.update(id, patch, now())
++ const updated = await withEvents(async (enqueue) => {
++ const next = await opts.store.update(id, patch, now())
++ if (!next) return null
++ if (mine && patch.status === 'published' && world.publishedAt === null)
++ await enqueue(worldPublishedEvent(next, opts.events?.siteUrl ?? ''))
++ if (!mine && patch.status && patch.status !== world.status)
++ await enqueue(
++ moderationEvent(
++ patch.status === 'hidden' ? 'world.hidden' : 'world.restored',
++ { type: 'world', id, ownerSubject: world.ownerSubject },
++ actorFor(user.subjectId),
++ { details: { from: world.status, to: patch.status } },
++ ),
++ )
++ return next
++ })
if (!updated) fail(404, 'world_not_found')
if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
if (patch.status && patch.status !== world.status) {
@@@ -386,7 +436,23 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
if (!world || (world.ownerSubject !== user.subjectId && !isGamesStaff(user)))
return fail(404, 'world_not_found')
-- await opts.host.remove(id)
++ const byStaff = world.ownerSubject !== user.subjectId
++ const events = opts.events?.sink.enabled ? opts.events : null
++ await opts.host.remove(
++ id,
++ byStaff && events
++ ? (t) =>
++ events.sink.enqueue(
++ t,
++ moderationEvent(
++ 'world.deleted',
++ { type: 'world', id, ownerSubject: world.ownerSubject },
++ actorFor(user.subjectId),
++ { details: { status: world.status } },
++ ),
++ )
++ : undefined,
++ )
opts.log.info('creator world deleted', {
world: id,
staff: world.ownerSubject !== user.subjectId,
ddiff --git a/docs/games.md b/docs/games.md
iindex ae84126..4768926 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -60,7 +60,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
reaches Scraplandia, the leaderboards or the Inventory. The world's own rows (props, constraints, clock) persist.
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
-- cannot publish it again. Deleting a world removes its place's rows. Worlds are in the account export, follow an
++ cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
++ `games.world.published` (OpenVibe.Quest's "Publish a creator world"); staff hiding, restoring or deleting someone
++ else's world is a `games.moderation.action` (`world.hidden`, `world.restored`, `world.deleted`). Worlds are in the account export, follow an
account merge and go with an account deletion.
## Adding another game