openvibers/openvibe.games Commits
Games: creator worlds announce their first publication (games.world.published) and staff actions; saves accept Cloudflare's weak ETag (#89)
Changes
@@@ -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({@@@ -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(@@@ -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)})@@@ -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): voidchanged(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) => stringlog: LoggerperPerson: 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.nowconst 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) : nullif (!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,@@@ -60,7 +60,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built inreaches 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 anaccount merge and go with an account deletion.## Adding another game
@@ -767,10 +767,13 @@ async function main(): Promise<void> {
@@ -767,10 +767,13 @@ async function main(): Promise<void> {
void router.serialize(async () => worldHost.applyMap(id, map))
void router.serialize(async () => worldHost.applyMap(id, map))
},
},
changed: (world, id) => router.serialize(() => worldHost.changed(world, id)),
changed: (world, id) => router.serialize(() => worldHost.changed(world, id)),
- remove: (id) =>
+ remove: (id, within) =>
router.serialize(async () => {
router.serialize(async () => {
await worldHost.changed(null, id)
await worldHost.changed(null, id)
- await creatorWorlds.remove(id)
+ await db.tx(async (t) => {
+ await creatorWorlds.remove(id)
+ await within?.(t)
+ })
}),
}),
},
},
resolveUser: (token) =>
resolveUser: (token) =>
@@ -781,6 +784,7 @@ async function main(): Promise<void> {
@@ -781,6 +784,7 @@ async function main(): Promise<void> {
address: clientAddress,
address: clientAddress,
log: log.child({ system: 'worlds' }),
log: log.child({ system: 'worlds' }),
perPerson: config.creatorWorldsPerPerson,
perPerson: config.creatorWorldsPerPerson,
+ events: { db, sink, siteUrl: config.oauth?.selfUrl ?? 'https://openvibe.games' },
})
})
// The casual games' JSON API (plan T22).
// The casual games' JSON API (plan T22).
const serveCasual = createCasualRoutes({
const serveCasual = createCasualRoutes({
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* games.skill.leveled a persisted skill level went up (one event per skill per save)
* games.skill.leveled a persisted skill level went up (one event per skill per save)
* games.blueprint.unlocked a persisted blueprint recipe unlock
* games.blueprint.unlocked a persisted blueprint recipe unlock
* games.world.saved the authoritative world was written (checkpoint / shutdown)
* 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.mod.* mod install, enable, disable, grant changes and revoke
* games.moderation.action staff took down or put back a mod someone else published
* games.moderation.action staff took down or put back a mod someone else published
* (common.moderation-action@1, ADR-022), for Network's moderation
* (common.moderation-action@1, ADR-022), for Network's moderation
@@ -157,6 +158,31 @@ export function worldSavedEvent(
@@ -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 type ModLifecycle = 'installed' | 'enabled' | 'disabled' | 'revoked' | 'grants_changed'
export function modEvent(
export function modEvent(
@@ -4,10 +4,16 @@ import { emptyMapV2, type MapFileV2 } from '@openvibe/content'
@@ -4,10 +4,16 @@ import { emptyMapV2, type MapFileV2 } from '@openvibe/content'
import { createCreatorWorldStore, type CreatorWorld } from '@openvibe/persistence'
import { createCreatorWorldStore, type CreatorWorld } from '@openvibe/persistence'
import { openTestDb } from '@openvibe/persistence/testing'
import { openTestDb } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
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 { afterEach, describe, expect, it } from 'vitest'
import { createGamesActorLimits } from '../net/actorLimits.js'
import { createGamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.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 KIM = 'usr_01J0000000000000000000000K'
const LEE = 'usr_01J0000000000000000000000L'
const LEE = 'usr_01J0000000000000000000000L'
@@ -32,6 +38,7 @@ async function setup(perPerson = 2) {
@@ -32,6 +38,7 @@ async function setup(perPerson = 2) {
applied: [] as string[],
applied: [] as string[],
changed: [] as (string | null)[],
changed: [] as (string | null)[],
removed: [] as string[],
removed: [] as string[],
+ events: [] as EventInput[],
}
}
const handler = createWorldRoutes({
const handler = createWorldRoutes({
store,
store,
@@ -41,9 +48,12 @@ async function setup(perPerson = 2) {
@@ -41,9 +48,12 @@ async function setup(perPerson = 2) {
changed: async (world: CreatorWorld | null, id: string) => {
changed: async (world: CreatorWorld | null, id: string) => {
calls.changed.push(world ? `${id}:${world.status}` : null)
calls.changed.push(world ? `${id}:${world.status}` : null)
},
},
- remove: async (id) => {
+ remove: async (id, within) => {
calls.removed.push(id)
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,
resolveUser: async (token) => USERS[token] ?? null,
@@ -51,6 +61,17 @@ async function setup(perPerson = 2) {
@@ -51,6 +61,17 @@ async function setup(perPerson = 2) {
address: () => '127.0.0.1',
address: () => '127.0.0.1',
log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),
log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),
perPerson,
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) => {
server = createServer((req, res) => {
if (!handler(req, res)) {
if (!handler(req, res)) {
@@ -96,6 +117,13 @@ describe('/api/worlds', () => {
@@ -96,6 +117,13 @@ describe('/api/worlds', () => {
expect(cleanText(42, 60)).toBeNull()
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 () => {
it('lets a signed-in person make, save, publish and delete a world; staff take one down', async () => {
const { call, calls } = await setup()
const { call, calls } = await setup()
expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)
expect((await call('POST', '/api/worlds', undefined, { title: 'x' })).status).toBe(401)
@@ -133,7 +161,7 @@ describe('/api/worlds', () => {
@@ -133,7 +161,7 @@ describe('/api/worlds', () => {
expect(refused.body.issues?.some((i) => i.startsWith('textures:'))).toBe(true)
expect(refused.body.issues?.some((i) => i.startsWith('textures:'))).toBe(true)
const next: MapFileV2 = { ...emptyMapV2(), spawn: [2, 1, 2] }
const next: MapFileV2 = { ...emptyMapV2(), spawn: [2, 1, 2] }
const saved = await call('PUT', `/api/worlds/${id}/map`, 'kim', next, {
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(saved.status).toBe(200)
expect(calls.applied).toEqual([id])
expect(calls.applied).toEqual([id])
@@ -150,6 +178,21 @@ describe('/api/worlds', () => {
@@ -150,6 +178,21 @@ describe('/api/worlds', () => {
200,
200,
)
)
expect(calls.changed).toEqual([`${id}:published`])
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 ?? []
const listed = (await call('GET', '/api/worlds')).body.worlds ?? []
expect(listed.map((w) => w.id)).toEqual([id])
expect(listed.map((w) => w.id)).toEqual([id])
expect(listed[0]).toMatchObject({
expect(listed[0]).toMatchObject({
@@ -167,6 +210,10 @@ describe('/api/worlds', () => {
@@ -167,6 +210,10 @@ describe('/api/worlds', () => {
expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { status: 'hidden' })).status).toBe(
expect((await call('PATCH', `/api/worlds/${id}`, 'staff', { status: 'hidden' })).status).toBe(
200,
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('GET', '/api/worlds')).body.worlds).toEqual([])
expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(
expect((await call('PATCH', `/api/worlds/${id}`, 'kim', { status: 'published' })).status).toBe(
403,
403,
@@ -181,6 +228,18 @@ describe('/api/worlds', () => {
@@ -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}`, 'lee')).status).toBe(404)
expect((await call('DELETE', `/api/worlds/${id}`, 'kim')).status).toBe(204)
expect((await call('DELETE', `/api/worlds/${id}`, 'kim')).status).toBe(204)
expect(calls.removed).toEqual([id])
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/${id}`, 'kim')).status).toBe(404)
expect((await call('GET', '/api/worlds/not-an-id')).status).toBe(404)
expect((await call('GET', '/api/worlds/not-an-id')).status).toBe(404)
})
})
@@ -9,6 +9,14 @@ import {
@@ -9,6 +9,14 @@ import {
} from '@openvibe/content'
} from '@openvibe/content'
import type { CreatorWorld, CreatorWorldStatus, CreatorWorldStore } from '@openvibe/persistence'
import type { CreatorWorld, CreatorWorldStatus, CreatorWorldStore } from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
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 { GamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.js'
import type { NetworkUser } from '../net/networkAuth.js'
@@ -32,8 +40,11 @@ export interface WorldHostView {
@@ -32,8 +40,11 @@ export interface WorldHostView {
online(): Map<string, number>
online(): Map<string, number>
applyMap(id: string, map: MapFileV2): void
applyMap(id: string, map: MapFileV2): void
changed(world: CreatorWorld | null, id: string): Promise<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 {
export interface WorldRouteOptions {
@@ -45,6 +56,11 @@ export interface WorldRouteOptions {
@@ -45,6 +56,11 @@ export interface WorldRouteOptions {
address: (req: IncomingMessage) => string
address: (req: IncomingMessage) => string
log: Logger
log: Logger
perPerson: number
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
now?: () => number
}
}
@@ -118,6 +134,16 @@ function ownerView(world: CreatorWorld, online: 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 }> {
function readBody(req: IncomingMessage, max: number): Promise<{ text: string; bytes: number }> {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
const chunks: Buffer[] = []
@@ -149,6 +175,15 @@ function parseJson(text: string): unknown {
@@ -149,6 +175,15 @@ function parseJson(text: string): unknown {
export function createWorldRoutes(opts: WorldRouteOptions) {
export function createWorldRoutes(opts: WorldRouteOptions) {
const now = opts.now ?? Date.now
const now = opts.now ?? Date.now
const online = (id: string): number => opts.host.online().get(id) ?? 0
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 = (
const send = (
res: ServerResponse,
res: ServerResponse,
@@ -291,7 +326,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -291,7 +326,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const issues = creatorMapIssues(map, bytes)
const issues = creatorMapIssues(map, bytes)
if (issues.length) fail(422, 'map_invalid', { issues })
if (issues.length) fail(422, 'map_invalid', { issues })
const { revision: _client, ...doc } = map
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())
const saved = await opts.store.saveMap(id, doc, ifMatch, now())
if (saved === null) fail(404, 'world_not_found')
if (saved === null) fail(404, 'world_not_found')
if (saved === 'conflict') {
if (saved === 'conflict') {
@@ -364,7 +399,22 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -364,7 +399,22 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
}
}
patch.status = status as CreatorWorldStatus
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 (!updated) fail(404, 'world_not_found')
if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
if (patch.status && patch.status !== world.status) {
if (patch.status && patch.status !== world.status) {
@@ -386,7 +436,23 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -386,7 +436,23 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
if (!world || (world.ownerSubject !== user.subjectId && !isGamesStaff(user)))
if (!world || (world.ownerSubject !== user.subjectId && !isGamesStaff(user)))
return fail(404, 'world_not_found')
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', {
opts.log.info('creator world deleted', {
world: id,
world: id,
staff: world.ownerSubject !== user.subjectId,
staff: world.ownerSubject !== user.subjectId,
@@ -60,7 +60,9 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -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.
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
- **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
(`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.
account merge and go with an account deletion.
## Adding another game
## Adding another game