// ── ctx.events.emit — the validating half of the engagement seam ──────────── // // ENGAGEMENT.md §4.3 and §5.2. A registrant fires a declared event with a // payload; this checks the payload against the declaration and, since Phase 4a, // hands the validated event to the engine. // // Landing the contract a phase before the engine was deliberate, and it is the // same argument registerCore() has always made: a seam whose first real exercise // is the thing that depends on it is a seam that has already drifted. Every // validation rule below was written in Phase 2 for a caller that did not exist // yet, and the engine needed none of them changed. // // **The engine call is deliberately not awaited** — see `emit` below. Phase 6 // migrates the Team mail onto this. // // **Two postures, one switch.** A malformed emit THROWS in development and is // DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture // and it is not a compromise: this is called from inside a game-event handler, // and a contract problem of core's must not become the module's control flow at // three in the morning. In development it must be loud, because a payload that // silently loses a variable is a template that silently renders `undefined`. const registries = require('../modules/registries') const engine = require('../engagement/engine') const scopedPrefs = require('../engagement/scopedPrefs') const createLogger = require('./logger') const log = createLogger('engagement') // The same character class `pageUrlTemplate` is validated with (registries.js), // for the same reason: a `url` variable is a string that ends up in an href. // Relative only — one leading slash, and the second character may not be // another, because `//evil.test/x` passes an "is it rooted" check and is a // PROTOCOL-RELATIVE url that would send a recipient off-site. const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/ // A dedupe key is stored in a VARCHAR(190) (§4.5 user_notifications.dedupe_key), // so it is bounded here rather than at the insert — a truncated key silently // collides with a different event, which is the one failure mode dedupe exists // to prevent. const DEDUPE_KEY_MAX = 190 // engagement_outbox.scope_key is VARCHAR(190), same reasoning as above. const SCOPE_KEY_MAX = 190 // An emitter asserting an audience asserts a BOUNDED one. `MAX_AUDIENCE` (5000) // already caps what the engine will load from a query; this is the matching bound // on a list a caller built itself, and it is the same number for the same reason. const RECIPIENTS_MAX = 5000 const isProd = () => process.env.NODE_ENV === 'production' /** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */ function coerce(variable, raw) { switch (variable.type) { case 'string': return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' } case 'int': return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' } case 'float': return typeof raw === 'number' && Number.isFinite(raw) ? { value: raw } : { error: 'expected a finite number' } case 'boolean': return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' } // Normalised to an ISO string at the boundary, so a template, a manifest // example and a stored outbox row all hold the same representation of a // moment. A Date and its ISO string are the same value everywhere downstream // only if one of them stops existing here. case 'datetime': { const d = raw instanceof Date ? raw : new Date(raw) if (!(d instanceof Date) || Number.isNaN(d.getTime())) return { error: 'expected a date' } return { value: d.toISOString() } } case 'url': if (typeof raw !== 'string') return { error: 'expected a string' } return RELATIVE_URL.test(raw) ? { value: raw } : { error: 'expected a site-relative path beginning with a single "/"' } default: // Unreachable — registerEventTriggers refuses an undeclared type — and it // fails CLOSED anyway rather than passing an unchecked value through. return { error: `unsupported type "${variable.type}"` } } } /** * Check a payload against a trigger declaration. * * Returns `{ ok: true, data }` with a NEW object holding only declared * variables, or `{ ok: false, errors }` listing every problem rather than the * first — a module author fixing one emit at a time is a module author making * six round trips through a game server restart. * * Undeclared keys are dropped rather than rejected. They can never be * interpolated (the editor only offers declared names, §4.3 property 2), so * refusing the whole emit over one would be strictness with no safety behind it; * they are named in a debug line so a typo is still findable. */ function validatePayload(declaration, raw) { const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {} const errors = [] const data = {} for (const variable of declaration.variables) { const present = Object.prototype.hasOwnProperty.call(input, variable.name) const value = input[variable.name] if (!present || value === undefined || value === null) { if (variable.required) errors.push(`${variable.name}: required`) continue } const { value: coerced, error } = coerce(variable, value) if (error) errors.push(`${variable.name}: ${error}`) else data[variable.name] = coerced } if (raw && typeof raw === 'object' && !Array.isArray(raw)) { const declared = new Set(declaration.variables.map((v) => v.name)) const extra = Object.keys(raw).filter((k) => !declared.has(k)) if (extra.length) log.debug('emit carried undeclared variables', { trigger: declaration.id, extra }) } return errors.length ? { ok: false, errors } : { ok: true, data } } /** * Emit a declared event. Core's implementation; `ctx.events.emit` wraps it. * * `owner` is bound by the CALLER — the loader passes the module's own id and * core passes `'core'` — and is never taken from the arguments. A module emits * its own triggers and nothing else: without that, `ctx.events.emit` would be a * way to fire another module's event with a payload of your choosing, and every * rule an operator wrote against that trigger would fire on it. * * @returns {{ ok: true, event: object } | { ok: false, reason: string }} */ function emit(owner, triggerId, envelope = {}) { const fail = (reason, detail) => { if (!isProd()) { const suffix = detail ? ` (${detail})` : '' throw new Error(`ctx.events.emit: ${reason}${suffix}`) } log.warn('emit dropped', { owner, trigger: triggerId, reason, detail }) return { ok: false, reason } } const declaration = registries.eventTrigger(triggerId) if (!declaration) { // Names the holder when the id is taken by the OTHER facet, because under // one namespace "there is no such trigger" and "that id is a stream nobody // gave a payload contract to" are different problems with the same symptom. const heldBy = registries.eventOwner(triggerId) return fail( `unknown event trigger "${triggerId}"`, heldBy ? `the id is registered as a notification stream by "${heldBy}"` : null, ) } if (declaration.owner !== owner) { return fail(`"${triggerId}" belongs to "${declaration.owner}"`, `emitted by "${owner}"`) } // A scheduled trigger is fired by the periodic evaluator, not by a caller // (§7.1 Q6). There is no evaluator yet, and this is still the right refusal: // it keeps `kind` meaning something from the day it is declarable. if (declaration.kind !== 'event') { return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`) } const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {} const payload = validatePayload(declaration, data) if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; ')) // The subject is what a cooldown is keyed on (§4.1): "once per house", not // "once per user". An explicit `subject` wins; otherwise it is read from the // variable the declaration named, which is why checkTriggerShape insists that // variable exists. let resolvedSubject = null if (subject !== undefined && subject !== null) { if (typeof subject !== 'string' && typeof subject !== 'number') { return fail('subject must be a string or a number') } resolvedSubject = String(subject) } else if (declaration.subjectKey && payload.data[declaration.subjectKey] !== undefined) { resolvedSubject = String(payload.data[declaration.subjectKey]) } if (ownerUserId !== undefined && ownerUserId !== null) { if (!Number.isInteger(ownerUserId) || ownerUserId < 1) { return fail('ownerUserId must be a positive integer') } } // The scope this event is ABOUT: `team:12`, or absent. Distinct from `subject`, // which is what a cooldown counts — see the engine's enqueue. It is a stable // identifier because an unsubscribe token is signed over it and sits in a // mailbox for months; a display name would orphan the link on the first rename. let resolvedScope = null if (scopeKey !== undefined && scopeKey !== null) { if (typeof scopeKey !== 'string' || !scopedPrefs.parse(scopeKey)) { return fail('scopeKey must be a string of the form ":"') } if (scopeKey.length > SCOPE_KEY_MAX) return fail(`scopeKey must be at most ${SCOPE_KEY_MAX} characters`) resolvedScope = scopeKey } // **The audience this particular firing is about** (Phase 6, decision 2). An // emitter that has already computed an access-checked recipient set — the Team // fan-out is the case that forced it — hands it over here, and a rule whose // audience is `members` resolves to it. It is a NARROWING input, not a // widening one: `audiences.resolveForRule` still filters it through // `users.status`, the ceiling is still `members`, and the G24 check still runs. // A rule with any other audience ignores it entirely. let resolvedRecipients = null if (recipientUserIds !== undefined && recipientUserIds !== null) { if (!Array.isArray(recipientUserIds)) return fail('recipientUserIds must be an array') if (recipientUserIds.length > RECIPIENTS_MAX) { // Bounded here rather than at the query, because the bound is about what an // emitter may assert. `MAX_AUDIENCE` already caps what the engine will load; // this stops a caller building a list that large in the first place. return fail(`recipientUserIds must hold at most ${RECIPIENTS_MAX} ids`) } if (!recipientUserIds.every((n) => Number.isInteger(n) && n > 0)) { return fail('recipientUserIds must be positive integers') } resolvedRecipients = [...new Set(recipientUserIds)] } if (dedupeKey !== undefined && dedupeKey !== null) { if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) { return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`) } } let at = new Date() if (occurredAt !== undefined && occurredAt !== null) { const parsed = occurredAt instanceof Date ? occurredAt : new Date(occurredAt) if (Number.isNaN(parsed.getTime())) return fail('occurredAt is not a date') at = parsed } const event = { triggerId, owner, version: declaration.version, subject: resolvedSubject, ownerUserId: ownerUserId === undefined ? null : ownerUserId, scopeKey: resolvedScope, recipientUserIds: resolvedRecipients, dedupeKey: dedupeKey === undefined ? null : dedupeKey, occurredAt: at.toISOString(), data: payload.data, } // The values are NOT logged. A payload carries player names, house locations // and forum excerpts, and an event log that reproduces them is a second copy // of exactly the content §4.5 was careful to keep out of `engagement_sends` // (which hashes the address rather than storing it). The keys are enough to // debug a contract problem, which is what this line is for. log.info('event emitted', { trigger: triggerId, owner, subject: resolvedSubject, variables: Object.keys(event.data), }) // **Not awaited, and this is the point of the whole seam.** `emit` is called // from inside a game-event handler; the caller's job is to say the event // happened, and it must not be made to wait on rule lookups, audience // resolution and a dozen inserts to find out whether it is allowed to carry on. // That is the same reason the C# side's `Emit()` enqueues and returns rather // than touching the socket from the Core thread. `dispatch` catches everything // internally and never rejects, and the `.catch` is the belt to that braces. // // The consequence a test has to know about: `emit` returns before the outbox // rows exist. `engine.dispatch(event)` is exported for a caller that needs to // await the delivery decision, and the tests use it directly. engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message })) return { ok: true, event } } module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX, SCOPE_KEY_MAX, RECIPIENTS_MAX }