From 37f462306888b209f3236cb54fe0d284baed8f1a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 08:06:37 -0500 Subject: [PATCH] feat(events): targeted leases, value sets and searchable sources (Phase 12b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core half of Phase 12b, and the half Phase 12a did not need. A targeted lease is a shape `core.lease` did not have. Every lease before this named a SINGLE value, so the lease id WAS the target and none of the four callables took one. `Spawner.MaxCount` is not that shape: it is one capability over thousands of spawners, and a reservation on the id alone would let one run turning up one spawner refuse every other run every other spawner. So a lease may declare a `target`, the callables are handed it, and the ledger ref becomes `#` -- which puts the two-events-one-target refusal at the granularity the world actually has while leaving it coming from the same unique index it always did. Extending core rather than giving the module a lease verb of its own is what §F decided in Phase 8 ("the verb is core's"): a lease verb per module would re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and wrong in the first one that forgot. Half that objection no longer holds -- the target check comes free from the index whichever verb reserves the row -- and the other half still does. Three readers of a lease ref, not one. `cleanup.restoreLease` and `ledger.normalise` both looked a lease up by the whole `row.ref`, and both were correct for exactly as long as a ref was a bare id. Left alone, a targeted row would have missed in both -- cleanup reporting "no module registers the lease" and refusing to restore a world that really was changed, which is the worst failure this table has. All three now go through `eventLeaseForRef`. `values` closes a `string` lease's set. `min`/`max` bound the numeric types and nothing bounded `string`, so the only check on a string lease's value was the game side's -- a refusal arriving unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside `min`/`max` would be a second bound with no rule about which wins. Option sources become searchable, and the first one that needed it forced this phase's shape. `resolveOptionSource(id)` took no argument and every source answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn points, so a flat list would have dropped two thirds of the world and said nothing about which two thirds -- the failure 12a named for decoration, arriving for real. `resolve({ q })` is additive: every source is passed a term, none is required to read one, and a `searchable` flag says which do, because inferring it from a truncated answer reads correctly right up until a small deployment's list happens to fit. `MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the shape every phase since P10 has used while this workstream sits on `edge`. The swagger regeneration carries one incidental change: the committed spec said the session cookie is `rg_rig`, which is neither the documented default nor what this repo's own `server/.env` sets. It was generated somewhere with that env var set. The regeneration corrects it to `rg_token`. 2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite requires. Six new tests cover the targeted-lease shape, both refusal directions, the value set, and the search term. Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- server/src/config/coreEventActions.js | 100 ++++++++- server/src/events/cleanup.js | 14 +- server/src/events/ledger.js | 5 +- server/src/modules/registries.js | 135 +++++++++++- .../src/router/v1/admin/events.controller.js | 7 +- server/src/router/v1/admin/events.router.js | 8 +- server/swagger/swagger-output.json | 21 +- server/test/eventCleanup.test.js | 50 +++++ server/test/eventModuleContract.test.js | 193 ++++++++++++++++++ 9 files changed, 512 insertions(+), 21 deletions(-) diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js index 468e704..f7cd358 100644 --- a/server/src/config/coreEventActions.js +++ b/server/src/config/coreEventActions.js @@ -50,6 +50,12 @@ const registries = require('../modules/registries') +// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref +// from the lease id and the target, so this is the one place a caller can push a +// ref past the column — and the ledger's own rule applies: refuse, never +// truncate, because a truncated ref is a restore pointed at another object. +const MAX_LEASE_REF = 190 + /** * Turn the `value` param's text into whatever the named lease says it holds. @@ -60,7 +66,19 @@ const registries = require('../modules/registries') */ function coerceLeaseValue(lease, raw) { const text = String(raw === undefined || raw === null ? '' : raw).trim() - if (lease.type === 'string') return { ok: true, value: text } + if (lease.type === 'string') { + // A string lease with a declared value set is bounded here, at authoring + // time, exactly as a numeric one is by its range (Phase 12b). Without it the + // only check on the value is the game side's, and that refusal arrives + // unattended, mid-run, from a step nobody is watching. + if (lease.values && !lease.values.includes(text)) { + return { + ok: false, + error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`, + } + } + return { ok: true, value: text } + } if (lease.type === 'bool') { if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true } if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false } @@ -305,6 +323,25 @@ const ACTIONS = [ example: '3.0', description: 'What to hold it at, in whatever type the lease declares.', }, + { + // **Optional here, required by the LEASE** (Phase 12b), and the two are + // not the same statement. A param's `required` is a property of the + // action, and this action serves both a config key (which has no target) + // and an object property (which cannot be named without one) — so the + // field is declared optional and `perform` refuses a targeted lease with + // nothing in it, in the lease's own words. + // + // It carries no `source` for the same reason: the values behind it are + // the chosen LEASE's, and a param declares one source for all time. The + // lease's own `target.source` is what the authoring form reads once the + // author has picked a lease, which is the only moment the right list is + // knowable. + name: 'target', + type: 'string', + required: false, + example: '003f11b8-9bfa-4587-991e-ca263004efe6', + description: 'Which one, for a value that exists on many things. Leave empty otherwise.', + }, { name: 'minutes', type: 'int', @@ -350,6 +387,26 @@ const ACTIONS = [ const coerced = coerceLeaseValue(lease, params.value) if (!coerced.ok) return { ok: false, retry: false, error: coerced.error } + // **The target is checked before anything else about the world is read** + // (Phase 12b), because both of its failures are authoring mistakes rather + // than outages: a targeted lease with no target names nothing, and a target + // on a lease that has none is an author who has confused two fields. Both + // are `retry: false` — the second attempt has the same params. + const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim() + if (lease.target && !targetRaw) { + return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` } + } + if (!lease.target && targetRaw) { + return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` } + } + const target = lease.target ? targetRaw : null + const ref = registries.leaseRef(lease.id, target) + // Refused rather than truncated, on the ledger's own rule for a resource + // ref: a truncated ref is a restore pointed at the wrong object. + if (ref.length > MAX_LEASE_REF) { + return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` } + } + const minutes = Number(params.minutes) if (!Number.isFinite(minutes) || minutes <= 0) { return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` } @@ -368,11 +425,22 @@ const ACTIONS = [ // allowed. What it deliberately does not do is reserve the target — a // verify that took a lease would be a dry run that changed something, and // it would then refuse the real run that followed it. + // + // It also does not check that the TARGET exists, and that is the same + // rule rather than an exception: asking the game side whether a spawner is + // there is a live read the shard may be down for, and a dry run that fails + // because a shard is restarting would make `verified_at` a property of the + // moment rather than of the version (§K). if (verify) return { ok: true } - const baseline = await lease.read() + const baseline = await lease.read({ target }) if (!baseline || baseline.ok !== true) { - return { ok: false, error: `could not read the current value of ${lease.label}` } + return { + ok: false, + error: baseline && baseline.error + ? String(baseline.error) + : `could not read the current value of ${lease.label}`, + } } const until = new Date(Date.now() + ms) @@ -381,8 +449,20 @@ const ACTIONS = [ stepId, owner: lease.owner || 'core', kind: 'override', - ref: lease.id, - payload: { target: lease.id, baseline: baseline.value, applied: coerced.value, until: until.toISOString() }, + // **The ref carries the target, and that is what makes the unique index + // right rather than merely present.** Reserved under the lease id alone, + // an event turning up one spawner would lock every other run out of every + // other spawner — a conflict check that refuses correct work is as wrong + // as one that permits a collision, and on a shard with 6,707 spawners it + // is the failure an operator would actually meet. + ref, + payload: { + target: ref, + leaseTarget: target, + baseline: baseline.value, + applied: coerced.value, + until: until.toISOString(), + }, leaseUntil: until, }) if (!reserved.ok) { @@ -401,7 +481,7 @@ const ACTIONS = [ // rather than one stuck changed indefinitely. let applied try { - applied = await lease.apply(coerced.value, until) + applied = await lease.apply(coerced.value, until, { target }) } catch (err) { applied = { ok: false, error: err.message } } @@ -451,7 +531,11 @@ const ACTIONS = [ for (const row of resources || []) { if (row.kind !== 'override') continue - const lease = registries.eventLease(row.ref) + // Resolved through the ref parser rather than by a bare map lookup: a + // targeted row's ref is `#` and `eventLease` would miss it, + // which would silently report every property lease still in force. + const found = registries.eventLeaseForRef(row.ref) + const lease = found && found.lease if (!lease || typeof lease.inForce !== 'function') { inForce.push(row.ref) @@ -460,7 +544,7 @@ const ACTIONS = [ let answer try { - answer = await lease.inForce({ ref: row.ref, payload: row.payload || null }) + answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null }) } catch (err) { answer = null } diff --git a/server/src/events/cleanup.js b/server/src/events/cleanup.js index 4fe2af5..b9878b2 100644 --- a/server/src/events/cleanup.js +++ b/server/src/events/cleanup.js @@ -100,7 +100,12 @@ function classifyRevert(raw, what) { /** Call a lease's `restore`, under a deadline, never throwing. */ async function restoreLease(row) { - const lease = registries.eventLease(row.ref) + // Through the ref parser, because a targeted lease's ref is `#` + // (Phase 12b). A bare map lookup would miss every property lease and report + // "no module registers" for one that is registered — leaving a spawner turned + // up for good and blaming an uninstalled module for it. + const found = registries.eventLeaseForRef(row.ref) + const lease = found && found.lease if (!lease) { // The module that owned it is uninstalled or failed to boot. Not a failure to // retry away — nothing will change until an operator reinstalls it — and not @@ -113,7 +118,12 @@ async function restoreLease(row) { let raw try { raw = await withDeadline( - () => lease.restore(payload.baseline, { expected: payload.applied, runId: row.run_id }), + () => + lease.restore(payload.baseline, { + expected: payload.applied, + runId: row.run_id, + target: found.target, + }), DEFAULT_REVERT_BUDGET_MS, row.ref, ) diff --git a/server/src/events/ledger.js b/server/src/events/ledger.js index 7638619..69372d2 100644 --- a/server/src/events/ledger.js +++ b/server/src/events/ledger.js @@ -98,7 +98,10 @@ function normalise(entry, actionId) { // an `override` through the lease registry — that is the split §F draws — so a // ref naming nothing registered is a resource core would be recording with no // way to undo it, which is the promise rule 2 exists to stop core making. - if (kind === 'override' && !registries.eventLease(ref)) { + // Through the ref parser: a targeted lease's ref carries its target after a + // `#` (Phase 12b), and matching the whole string against the registry would + // reject a lease that IS registered. + if (kind === 'override' && !registries.eventLeaseForRef(ref)) { return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` } } diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index db2539b..a837ae1 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -488,10 +488,49 @@ const allEventLeases = () => /** One lease, callables included. `core.lease` and the cleanup sweep read it. */ const eventLease = (id) => eventLeases.get(id) || null +// How a targeted lease's reservation ref is written, and the one place that +// knows it. `#` is safe as the separator because `EVENT_ID` admits only +// `[a-z0-9_.]`, so no lease id can contain one and the split is unambiguous +// however odd a module's target string is. +const LEASE_TARGET_SEP = '#' + +/** Compose the ref an `override` row is reserved under. */ +const leaseRef = (id, target) => + target === null || target === undefined || target === '' ? id : `${id}${LEASE_TARGET_SEP}${target}` + +/** + * Split an `override` row's ref back into the lease and the target it names. + * + * **Every reader of a ledgered lease needs this, not just the one that wrote + * it.** `cleanup.restoreLease` and `ledger.normalise` both look a lease up by + * `row.ref`, and both were correct for exactly as long as a ref was a bare lease + * id. A targeted row would have missed in both — cleanup reporting a lease no + * module registers and refusing to restore a world that really was changed, + * which is the worst failure this table has. + */ +function parseLeaseRef(ref) { + const text = String(ref === undefined || ref === null ? '' : ref) + const at = text.indexOf(LEASE_TARGET_SEP) + if (at < 0) return { id: text, target: null } + return { id: text.slice(0, at), target: text.slice(at + 1) } +} + +/** The lease an `override` row names, target included. Null when nothing registers it. */ +function eventLeaseForRef(ref) { + const { id, target } = parseLeaseRef(ref) + const lease = eventLeases.get(id) || null + return lease ? { lease, target } : null +} + /** Every option source WITHOUT its resolver — the authoring form's list. */ const allEventOptionSources = () => [...eventOptionSources.values()].map(({ resolve, ...rest }) => rest) +// The longest search term a source is asked to honour. Bounded rather than +// refused: a term this long is a paste rather than a search, and truncating it +// still answers something, where refusing would blank a form mid-keystroke. +const MAX_OPTION_QUERY = 120 + /** * Resolve one option source, or say why not. Never throws. * @@ -509,12 +548,25 @@ const allEventOptionSources = () => * rather than to nothing — a dropdown of blank rows is a worse field than the * text box it replaced. */ -async function resolveOptionSource(id) { +async function resolveOptionSource(id, { q = '' } = {}) { const entry = eventOptionSources.get(id) if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` } + // **The search term is passed to every source and required of none** (Phase + // 12b). A source answers a list; one whose catalog is larger than a dropdown + // can hold answers a list NARROWED BY a term, and the two are the same + // callable because the difference is the source's business rather than the + // form's. A resolver that ignores the argument behaves exactly as it did + // before this existed, which is what made this additive. + // + // It exists because the first source with more entries than `MAX_OPTIONS` is + // Phase 12b's spawner target — 6,707 spawn points against a 2,000 bound. Every + // source before it fitted, so the bound had only ever truncated in theory; a + // dropdown quietly missing two thirds of the world is the same failure 12a + // named for decoration, and truncation cannot be the answer to it. + const term = String(q || '').trim().slice(0, MAX_OPTION_QUERY) let raw try { - raw = await entry.resolve() + raw = await entry.resolve({ q: term }) } catch (err) { log.error('option source resolver failed', { source: id, @@ -534,7 +586,11 @@ async function resolveOptionSource(id) { if (o.group) option.group = String(o.group) options.push(option) } - return { ok: true, id, label: entry.label, owner: entry.owner, options } + // `searchable` is reported so the authoring form knows which field is a + // typeahead and which is a plain select. Without it P13 would have to infer it + // from whether a truncated list came back, which is a guess that reads + // correctly right up until a small shard's spawner list fits. + return { ok: true, id, label: entry.label, owner: entry.owner, searchable: entry.searchable, options, q: term } } // ── Shape checks, run the moment a registrant calls ──────────────────────── @@ -1212,6 +1268,61 @@ function checkEventLeaseShape(entry) { throw new Error(`registerEventLeases: ${l.id} inForce must be a function`) } + // **A TARGETED lease is a family of values rather than one** (Phase 12b). + // + // Every lease before this one named a single value — a config key, a rate — + // and the lease id WAS the target, which is why the four callables took none + // and why the reservation ref was the id alone. An object property is not that + // shape: `Spawner.MaxCount` is one lease over thousands of spawners, and two + // runs turning up two DIFFERENT spawners must both be allowed while two runs + // turning up the same one must not. + // + // So a lease may declare a target, and when it does core adds one to the + // reservation ref (`#`) and hands it to every callable. The + // two-events-one-target refusal then comes from the same unique index it + // always did, at the granularity the world actually has. **Core still owns the + // duration bound and the conflict check** — which is the whole of why §F put + // the verb in core, and the reason this is an extension of `core.lease` rather + // than a lease verb of the module's own. + let target = null + if (l.target !== undefined && l.target !== null) { + const t = l.target + if (typeof t !== 'object' || Array.isArray(t)) { + throw new Error(`registerEventLeases: ${l.id} target must be an object`) + } + if (!t.label) throw new Error(`registerEventLeases: ${l.id} target has no label`) + // The source is checked for shape only, never for existence, on + // `resolveOptionSource`'s own argument: a source that cannot answer degrades + // its field to free text rather than blocking the form, and a source + // registered by a module that boots later must not make this one throw. + if (t.source !== undefined && !OPTION_SOURCE_ID.test(t.source || '')) { + throw new Error(`registerEventLeases: ${l.id} target names a bad option source "${t.source}"`) + } + target = { + label: String(t.label), + source: t.source || null, + example: t.example === undefined ? null : String(t.example), + description: t.description || '', + } + } + + // **A closed set of values, for the type that has no range** (Phase 12b). + // `min`/`max` bound the numeric types and nothing bounded `string`, so a string + // lease was free text validated only by the game side — which means an invalid + // value is a refusal at DISPATCH, unattended, halfway through a run, instead of + // a refusal on the form. Optional, because a genuinely free-text lease still + // exists; enforced by `coerceLeaseValue` when present. + let values = null + if (l.values !== undefined && l.values !== null) { + if (!Array.isArray(l.values) || !l.values.length) { + throw new Error(`registerEventLeases: ${l.id} values must be a non-empty array`) + } + if (l.type !== 'string') { + throw new Error(`registerEventLeases: ${l.id} is ${l.type}; values is for string leases`) + } + values = l.values.map((v) => String(v)) + } + return { id: l.id, label: l.label, @@ -1219,6 +1330,8 @@ function checkEventLeaseShape(entry) { type: l.type, min, max, + values, + target, maxDurationMs, read: l.read, apply: l.apply, @@ -1250,7 +1363,18 @@ function checkEventOptionSourceShape(entry) { if (typeof s.resolve !== 'function') { throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`) } - return { id: s.id, label: s.label, description: s.description || '', resolve: s.resolve } + // **A declaration, not an inference** (Phase 12b). `resolve({ q })` is passed a + // term whether or not the source reads it, so nothing here can tell from the + // callable alone whether a term would narrow the answer. The module says, and + // the authoring form renders a typeahead rather than a select for the ones + // that do. + return { + id: s.id, + label: s.label, + description: s.description || '', + searchable: s.searchable === true, + resolve: s.resolve, + } } @@ -1898,6 +2022,9 @@ module.exports = { isEventBudget, allEventLeases, eventLease, + eventLeaseForRef, + parseLeaseRef, + leaseRef, allEventOptionSources, resolveOptionSource, allEngagementSeeds, diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js index fd45270..9eb4eaf 100644 --- a/server/src/router/v1/admin/events.controller.js +++ b/server/src/router/v1/admin/events.controller.js @@ -533,7 +533,12 @@ exports.actions = async (_req, res) => { * accepts. The registry answers it, so it names no game noun here. */ exports.options = async (req, res) => { - const result = await registries.resolveOptionSource(String(req.params.sourceId || '')) + // `q` is passed straight through and bounded by the registry, not here: the + // registry is what every caller of a source goes through, and a bound written + // on the route would be a bound the next caller does not have. + const result = await registries.resolveOptionSource(String(req.params.sourceId || ''), { + q: String(req.query.q || ''), + }) return res.json(result) } diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js index b4f91b2..20c2b26 100644 --- a/server/src/router/v1/admin/events.router.js +++ b/server/src/router/v1/admin/events.router.js @@ -72,9 +72,13 @@ eventsRouter.get( '/catalog/options/:sourceId', // #swagger.tags = ['Admin · Events'] // #swagger.summary = 'Resolve the values behind a param option source' - // #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it.' + // #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it. Phase 12b adds the optional `q`: a source whose catalog is larger than a dropdown can hold (the first is the spawner target, 6,707 entries against a 2,000 bound) narrows its answer by it, and one that ignores it answers exactly as before. `searchable` on the response says which is which, so the form renders a typeahead rather than a select.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */ + // A single-key `schema` on purpose: swagger-autogen renders a two-key one as an + // object schema whose properties are `type` and `maxLength`, which documents a + // query parameter that takes a JSON object. The bound is stated in the description. + /* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */ + /* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ controller.options, ) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index f019d8a..3b24427 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3,7 +3,7 @@ "info": { "title": "Runic Gateway API", "version": "1.0.0", - "description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_rig`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." + "description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." }, "servers": [ { @@ -3964,7 +3964,7 @@ "Admin · Events" ], "summary": "Resolve the values behind a param option source", - "description": "EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it.", + "description": "EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it. Phase 12b adds the optional `q`: a source whose catalog is larger than a dropdown can hold (the first is the spawner target, 6,707 entries against a 2,000 bound) narrows its answer by it, and one that ignores it answers exactly as before. `searchable` on the response says which is which, so the form renders a typeahead rather than a select.", "parameters": [ { "name": "sourceId", @@ -3973,6 +3973,15 @@ "schema": { "type": "string" } + }, + { + "name": "q", + "in": "query", + "description": "Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -3995,6 +4004,12 @@ "owner": { "type": "string" }, + "searchable": { + "type": "boolean" + }, + "q": { + "type": "string" + }, "reason": { "type": "string" }, @@ -17884,7 +17899,7 @@ "cookieAuth": { "type": "apiKey", "in": "cookie", - "name": "rg_rig", + "name": "rg_token", "description": "Session JWT set as an httpOnly cookie by POST /api/v1/auth/login." }, "bearerAuth": { diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js index 9b3b5ca..b1eb4ae 100644 --- a/server/test/eventCleanup.test.js +++ b/server/test/eventCleanup.test.js @@ -396,6 +396,56 @@ test('a lease whose module is uninstalled is unresolved, never assumed restored' assert.match([...store.rows.values()][0].last_error, /no module registers the lease "demo.rate"/) }) +test('a targeted lease is restored through the lease its ref NAMES, target in hand', async () => { + // Phase 12b. A targeted row's ref is `#`, and both readers of + // one — this sweep and `ledger.normalise` — looked their lease up by the whole + // string. Left alone, every property lease would have come back "no module + // registers the lease", which is the worst answer this table has: it leaves a + // spawner turned up for good AND blames an uninstalled module for it. + let seen = null + registerLease({ + id: 'demo.spawner.count', + type: 'int', + min: 0, + max: 50, + target: { label: 'Which spawner' }, + restore: async (baseline, opts) => { seen = { baseline, opts }; return { ok: true } }, + }) + addResource({ + kind: 'override', + ref: 'demo.spawner.count#003f11b8-9bfa', + step_id: null, + payload: { baseline: 3, applied: 30 }, + }) + + const summary = await cleanup.cleanupRun(RUN) + assert.equal(summary.reverted, 1) + assert.equal(seen.baseline, 3) + assert.equal(seen.opts.expected, 30) + // The half that makes the restore land on the right object rather than on some + // other run's spawner: the module is handed the target, not asked to parse the + // ref core composed. + assert.equal(seen.opts.target, '003f11b8-9bfa') +}) + +test('an unregistered TARGETED lease still names itself, ref and all', async () => { + // The fail-closed direction is unchanged by the parser: a row whose module is + // gone stays unresolved with the reason on it, and the reason quotes the ref + // the operator will actually see in the console rather than the bare id. + addResource({ + kind: 'override', + ref: 'demo.spawner.count#003f11b8-9bfa', + step_id: null, + payload: { baseline: 3, applied: 30 }, + }) + const summary = await cleanup.cleanupRun(RUN) + assert.equal(summary.failed, 1) + assert.match( + [...store.rows.values()][0].last_error, + /no module registers the lease "demo\.spawner\.count#003f11b8-9bfa"/, + ) +}) + // ── The sweep ────────────────────────────────────────────────────────────── test('the sweep only touches TERMINAL runs', async () => { diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index 9efd157..6c4fbd5 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -795,3 +795,196 @@ test('a dry run of core.lease checks everything and takes nothing', async () => ) assert.equal(bad.outcome, 'terminal') }) + +// ── Targeted leases and searchable sources (Phase 12b) ────────────────────── + +test('a targeted lease is a family of values, and the target rides in the ref', async () => { + // The shape §F did not have before this phase. Every lease until now named ONE + // value, so the lease id WAS the target and the callables needed none. An + // object property is one lease over thousands of objects, and the reservation + // has to distinguish them or a run turning up one spawner locks out every other + // run and every other spawner. + assertRegistered(loadModule('demo', `module.exports = (ctx, api) => { + const seen = [] + api.registerEventOptionSources([{ + id: 'demo.options.seen', label: 'Seen', + async resolve() { return seen.map((s, i) => ({ value: String(i), label: s })) }, + }]) + api.registerEventLeases([{ + id: 'demo.spawner.count', + label: 'Spawner count', + type: 'int', min: 0, max: 50, + maxDurationMs: 3600000, + target: { label: 'Which spawner', source: 'demo.options.seen', example: 'abc' }, + async read(a) { seen.push('read:' + JSON.stringify(a)); return { ok: true, value: 3 } }, + async apply(v, until, a) { seen.push('apply:' + JSON.stringify(a)); return { ok: true } }, + async restore() { return { ok: true } }, + }]) + }`)) + registries.registerCore() + + const lease = registries.eventLease('demo.spawner.count') + assert.deepEqual(lease.target, { + label: 'Which spawner', + source: 'demo.options.seen', + example: 'abc', + description: '', + }) + + // The ref composes, and the parser takes it apart again — the property every + // reader of a ledgered lease depends on. + const ref = registries.leaseRef('demo.spawner.count', '0x40001234') + assert.equal(ref, 'demo.spawner.count#0x40001234') + assert.deepEqual(registries.parseLeaseRef(ref), { + id: 'demo.spawner.count', + target: '0x40001234', + }) + assert.equal(registries.eventLeaseForRef(ref).lease.id, 'demo.spawner.count') + assert.equal(registries.eventLeaseForRef(ref).target, '0x40001234') + + // An untargeted ref still parses to a bare id, so nothing about the leases that + // existed before this phase changes. + assert.deepEqual(registries.parseLeaseRef('demo.rate'), { id: 'demo.rate', target: null }) +}) + +test('a targeted lease refuses an empty target, and an untargeted one refuses a target', async () => { + // Both are authoring mistakes rather than outages, so both are terminal: the + // second attempt has exactly the same params, and a retry loop against a form + // error is a run that pauses for nothing. + assertRegistered(loadModule('demo', `module.exports = (ctx, api) => { + api.registerEventLeases([ + { + id: 'demo.spawner.count', label: 'Spawner count', type: 'int', min: 0, max: 50, + maxDurationMs: 3600000, + target: { label: 'Which spawner' }, + async read() { return { ok: true, value: 3 } }, + async apply() { return { ok: true } }, + async restore() { return { ok: true } }, + }, + { + id: 'demo.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5, + maxDurationMs: 3600000, + async read() { return { ok: true, value: 1 } }, + async apply() { return { ok: true } }, + async restore() { return { ok: true } }, + }, + ]) + }`)) + registries.registerCore() + + const noTarget = await dispatch.dispatchStep( + step('core.lease', { lease: 'demo.spawner.count', value: '9', minutes: 10 }), + { run: RUN, verify: true }, + ) + assert.equal(noTarget.outcome, 'terminal') + assert.match(noTarget.error, /needs a which spawner/i) + + const spuriousTarget = await dispatch.dispatchStep( + step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10, target: '0x1' }), + { run: RUN, verify: true }, + ) + assert.equal(spuriousTarget.outcome, 'terminal') + assert.match(spuriousTarget.error, /takes no target/) +}) + +test('a string lease with a declared value set is bounded at authoring time', async () => { + // `min`/`max` bound the numeric types and nothing bounded `string`, so the only + // check on a string lease's value was the game side's — a refusal that arrives + // unattended, mid-run. The set closes that at the form. + assertRegistered(loadModule('demo', `module.exports = (ctx, api) => { + api.registerEventLeases([{ + id: 'demo.season.status', label: 'Seasonal status', type: 'string', + values: ['Inactive', 'Active', 'Seasonal'], + maxDurationMs: 3600000, + target: { label: 'Which event' }, + async read() { return { ok: true, value: 'Inactive' } }, + async apply() { return { ok: true } }, + async restore() { return { ok: true } }, + }]) + }`)) + registries.registerCore() + + const bad = await dispatch.dispatchStep( + step('core.lease', { lease: 'demo.season.status', value: 'On', minutes: 10, target: 'Fellowship' }), + { run: RUN, verify: true }, + ) + assert.equal(bad.outcome, 'terminal') + assert.match(bad.error, /Inactive, Active, Seasonal/) + + const good = await dispatch.dispatchStep( + step('core.lease', { lease: 'demo.season.status', value: 'Active', minutes: 10, target: 'Fellowship' }), + { run: RUN, verify: true }, + ) + assert.equal(good.outcome, 'done') +}) + +test('a values list belongs to a string lease and to no other type', () => { + // Closed like `type` itself, and for the same reason: a set on an int lease + // would be a second bound beside `min`/`max` with no rule about which wins. + const api = registries.stage('demo') + assert.throws( + () => api.registerEventLeases([{ + id: 'demo.n', label: 'N', type: 'int', min: 0, max: 5, values: ['1', '2'], + maxDurationMs: 1000, + read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }), + }]), + /values is for string leases/, + ) + assert.throws( + () => api.registerEventLeases([{ + id: 'demo.s', label: 'S', type: 'string', values: [], + maxDurationMs: 1000, + read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }), + }]), + /non-empty array/, + ) + assert.throws( + () => api.registerEventLeases([{ + id: 'demo.t', label: 'T', type: 'bool', target: { source: 'nope' }, + maxDurationMs: 1000, + read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }), + }]), + /target has no label/, + ) +}) + +test('a source is passed the search term whether or not it reads one', async () => { + // Additive on purpose: `resolve({ q })` reaches every source, and one that + // ignores the argument answers exactly as it did before this existed. Only the + // sources too large for a dropdown have to care, and `searchable` is what says + // which those are — inferring it from a truncated answer would read correctly + // right up until a small shard's list happens to fit. + assertRegistered(loadModule('demo', `module.exports = (ctx, api) => { + api.registerEventOptionSources([ + { + id: 'demo.options.big', label: 'Big', searchable: true, + async resolve({ q } = {}) { + const all = ['alpha', 'beta', 'gamma'] + const hits = q ? all.filter((n) => n.includes(q)) : all + return hits.map((n) => ({ value: n, label: n })) + }, + }, + { id: 'demo.options.small', label: 'Small', async resolve() { return [{ value: 'only' }] } }, + ]) + }`)) + + const all = await registries.resolveOptionSource('demo.options.big') + assert.deepEqual(all.options.map((o) => o.value), ['alpha', 'beta', 'gamma']) + assert.equal(all.searchable, true) + + const narrowed = await registries.resolveOptionSource('demo.options.big', { q: 'et' }) + assert.deepEqual(narrowed.options.map((o) => o.value), ['beta']) + assert.equal(narrowed.q, 'et') + + // The source that ignores it is unchanged, and says it is not searchable so the + // form renders a select rather than a box that appears to filter and does not. + const small = await registries.resolveOptionSource('demo.options.small', { q: 'anything' }) + assert.deepEqual(small.options, [{ value: 'only', label: 'only' }]) + assert.equal(small.searchable, false) + + // Bounded rather than refused: a term this long is a paste, and truncating it + // still answers something where refusing would blank the form mid-keystroke. + const long = await registries.resolveOptionSource('demo.options.big', { q: 'x'.repeat(400) }) + assert.equal(long.ok, true) + assert.equal(long.q.length, 120) +})