From 40ab1ce8d238bd466dac667ed58a8d5d6e2c7702 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 01:22:00 -0500 Subject: [PATCH] fix(modules): bump the CLIENT half of MODULE_API_VERSION to 1.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two halves version ONE contract and a test asserts they agree (client/test/moduleRegistry.test.js). I bumped server/src/modules/version.js and not client/src/modules/version.js, so client-build went red — the job runs the client suite before it builds. Nothing on the client half changed: a seed is server-side data and core's seeders write it on the boot path. It bumps for the reason its own header gives — a module declares one `coreApi` range against both halves, and a client claiming 1.8.0 while the server answers 1.9.0 is two answers to one question. 327 client tests green; client build clean. Co-Authored-By: Claude --- client/src/modules/version.js | 9 ++++++++- server/test/emailTemplates.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/client/src/modules/version.js b/client/src/modules/version.js index e23e393..bd6f97b 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.9.0 - a module may ship its own message bodies and rules: +// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase +// 11b, decision 7). Nothing on this half changed - a seed is server-side data +// and core's seeders write it on the boot path - but the bodies it ships are +// edited through the template editor this half already renders, and an operator +// meets them there. This file bumps for the reason at the top: the two halves +// state ONE version, and a module declares one `coreApi` range against both. // 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on // this half changed: a ceiling is declared on the server's `api` and enforced // there, and the admin screens that render one read the vocabulary from @@ -58,4 +65,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.8.0' +export const MODULE_API_VERSION = '1.9.0' diff --git a/server/test/emailTemplates.test.js b/server/test/emailTemplates.test.js index 9ef94af..1c4cfe8 100644 --- a/server/test/emailTemplates.test.js +++ b/server/test/emailTemplates.test.js @@ -19,6 +19,7 @@ const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('../src/engagement/templ const templatesDb = require('../src/model/engagement/engagementTemplates.db') const settings = require('../src/model/settings/settings.model') const templates = require('../src/engagement/templates') +const registries = require('../src/modules/registries') const SITE = 'Runic Gateway' const BASE = 'https://shard.example.com' @@ -400,6 +401,30 @@ test('variablesFor answers from the seed for a template with no trigger, plus th assert.deepEqual(templates.variablesFor({}).map((v) => v.name), ['siteName', 'siteUrl', 'logoUrl', 'year']) }) +test('a TRIGGER-bound template also gets the per-delivery variables', () => { + // Phase 11b. `emailChannel.deliver` computes an unsubscribe token per recipient + // and merges it last, so `{{unsubscribeUrl}}` has always RENDERED — but a + // trigger-bound template takes its variable list from the trigger, and a + // trigger has no business declaring a fact about how the mail was sent. Without + // this, a body carrying an unsubscribe line rendered correctly and then the + // save-time undeclared-variable check refused the first operator who edited it. + // + // Nothing had taken this path before: core's `notify.event` declares the + // variable in its own seed and is bound to no trigger. + registries.registerCore() + const names = templates.variablesFor({ trigger_id: 'news.post' }).map((v) => v.name) + assert.ok(names.includes('unsubscribeUrl'), 'a trigger-bound body may reference it') + assert.ok(names.includes('title'), 'and still gets the trigger\'s own') + assert.ok(names.includes('siteName'), 'and the ambient set') + + // A SEEDLESS, triggerless template gets neither — there is no delivery to + // describe, and an unsubscribe link on a password reset is meaningless. + assert.equal( + templates.variablesFor({}).map((v) => v.name).includes('unsubscribeUrl'), + false, + ) +}) + // ── The seeder and the render entrypoint ──────────────────────────────────── test('the shipped default is used when the row is missing, and when it is unusable', async () => {