From f1b0c5b1be567eab1a3ae116b5eacff3e39afec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:30:19 +0000 Subject: [PATCH 1/6] wip(service-messaging): notification template hole placement judge and save hooks Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01ApJv4hDHQ6M1JprNZFKzAW --- .../src/messaging-service-plugin.ts | 30 + .../src/notification-template-placement.ts | 172 ++++++ .../src/template-hole-placement.ts | 553 ++++++++++++++++++ .../src/template-renderer.ts | 45 +- 4 files changed, 798 insertions(+), 2 deletions(-) create mode 100644 packages/services/service-messaging/src/notification-template-placement.ts create mode 100644 packages/services/service-messaging/src/template-hole-placement.ts diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 9780050d20d..94cb796d8f9 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -13,6 +13,11 @@ import { HttpDispatcher } from './http-dispatcher.js'; import { createEmailChannel } from './email-channel.js'; import { createSmsChannel } from './sms-channel.js'; import { NotificationTemplateStore } from './template-renderer.js'; +import { + bindTemplateHolePlacement, + unbindTemplateHolePlacement, + type PlacementHookEngine, +} from './notification-template-placement.js'; import { NotificationTextRenderer, type NotificationTextI18n } from './notification-text.js'; import { convertWildcardPreferenceUsers } from './preference-user-conversion.js'; import { @@ -196,6 +201,8 @@ export class MessagingServicePlugin implements Plugin { private readonly options: Required; private dispatcher?: NotificationDispatcher; private httpDispatcher?: HttpDispatcher; + /** The engine the template placement hooks are bound to — unbound on teardown. */ + private placementEngine?: PlacementHookEngine; constructor(options: MessagingServicePluginOptions = {}) { this.options = { @@ -333,6 +340,27 @@ export class MessagingServicePlugin implements Plugin { ], }); + // [#25082] A sys_notification_template save whose html / mjml body + // places a hole the renderer cannot encode for where it lands is + // refused (`400 VALIDATION_FAILED` at `body`), judged over the merged + // record — engine hooks, bound here because the engine is a declared + // dependency, so its absence now is a fact rather than an early read. + // See notification-template-placement.ts. + let engine: PlacementHookEngine | undefined; + try { + engine = ctx.getService('objectql'); + } catch { + engine = undefined; + } + if (bindTemplateHolePlacement(engine)) { + this.placementEngine = engine; + } else { + ctx.logger.warn( + '[messaging] no ObjectQL engine with a hook registry — a sys_notification_template save whose html or ' + + 'mjml body places a hole outside element text or a quoted plain-text attribute value is NOT refused', + ); + } + // ADR-0029 D8 — contribute this service's object translations to the // i18n service on kernel:ready (the i18n plugin may register after // this one). @@ -625,6 +653,8 @@ export class MessagingServicePlugin implements Plugin { * timers must release them on the hook the kernel actually calls. */ async destroy(): Promise { + unbindTemplateHolePlacement(this.placementEngine); + this.placementEngine = undefined; await this.dispatcher?.stop(); this.dispatcher = undefined; await this.httpDispatcher?.stop(); diff --git a/packages/services/service-messaging/src/notification-template-placement.ts b/packages/services/service-messaging/src/notification-template-placement.ts new file mode 100644 index 00000000000..3b33315920f --- /dev/null +++ b/packages/services/service-messaging/src/notification-template-placement.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_notification_template` refuses a save whose html or mjml body places a + * hole the renderer cannot encode for where it lands — the notification half + * of ruling `6335348115` on #25082 (items 2–4), under the maintainer's ruling + * 3C on #25040: every outlet that renders a text field into HTML escapes by + * context. + * + * ## What is refused + * + * A write that leaves a row whose `format` sends its body to the `html` face + * (`isHtmlFormat`: `html` or `mjml`) with a hole outside the two placements the + * renderer's encoding is right for — element text and a quoted plain-text + * attribute value (`template-hole-placement.ts` says where those come from and + * how a placement is read). Every writer is judged, system context included: + * the verdict is about the row's content, not the caller's authority, the same + * stance the object's `channel_renders_template` rule takes. + * + * ## Judged over the merged record (ruling item 4) + * + * An insert is judged on the row as written, its `format` defaulting as the + * object declares. An update is judged on the stored row overlaid with the + * write, on every dispatch path — by id, and per matched row of a predicate + * update — because the engine binds `previous` on both before any + * `beforeUpdate` hook runs. So, as with the channel rule, a row stored before + * this rule that breaks it is refused on ANY edit that leaves it breaking it, + * until an edit moves its holes, or its format off html / mjml: frozen, not + * bricked. It is still read, listed and deleted (a delete is not judged), and + * it keeps rendering and delivering as it did — nothing here runs at send, and + * no stored row is rewritten. A payload-only judge would let an edit that + * leaves the body alone pass, which is why the write is not judged alone. + * + * ## The envelope + * + * `400 VALIDATION_FAILED` (built by `validationFailure`, which both HTTP doors + * map) with one `fields[]` entry at `field: 'body'`, `code: 'invalid_value'`. + * Its message names each refused hole and its placement, and the supported + * placements. An import commit reports it as that row's failure and a bulk + * update as that record's; a predicate update over a set holding a refused row + * writes no row. No new error code (ADR-0112, ADR-0114). + * + * ## Hooks, not a validation rule + * + * A declarative rule's condition is CEL, which has no HTML placement reading; + * so the invariant is engine `beforeInsert` / `beforeUpdate` hooks, the shape + * plugin-security's grant-name refusal uses for a check a rule cannot express. + */ + +import { validationFailure } from '@objectstack/types'; +import { NotificationTemplate } from './objects/notification-template.object.js'; +import { isHtmlFormat, TEMPLATE_OBJECT } from './template-renderer.js'; +import { + HOLE_PLACEMENT_LABELS, + judgeTemplateHtmlHoles, + SUPPORTED_HOLE_PLACEMENTS, + type TemplateHtmlHole, +} from './template-hole-placement.js'; + +/** The `packageId` both hooks register under, so they bind and unbind as one. */ +export const TEMPLATE_PLACEMENT_HOOK_PACKAGE = 'service-messaging:template-hole-placement'; + +const HOOK_PRIORITY = 30; +const BODY_FIELD = 'body'; +const FORMAT_FIELD = 'format'; + +const fields = NotificationTemplate.fields as Record; +/** The body column's declared label — what the refusal names it by. */ +const BODY_LABEL: string = typeof fields[BODY_FIELD]?.label === 'string' ? (fields[BODY_FIELD].label as string) : 'Body'; +/** The format an insert that names none is stored with. */ +const DECLARED_FORMAT_DEFAULT: unknown = fields[FORMAT_FIELD]?.defaultValue; + +/** How many refused holes the message lists by name before it counts the rest. */ +const LISTED_HOLES = 5; + +/** The sentence an author reads: the refused holes and their placements, the supported ones, the fix. */ +export function holePlacementMessage(refused: readonly TemplateHtmlHole[]): string { + const listed = refused + .slice(0, LISTED_HOLES) + .map((hole) => `${hole.text} in ${HOLE_PLACEMENT_LABELS[hole.placement]}`) + .join('; '); + const rest = refused.length > LISTED_HOLES ? `; and ${refused.length - LISTED_HOLES} more` : ''; + const supported = SUPPORTED_HOLE_PLACEMENTS.map((p) => HOLE_PLACEMENT_LABELS[p]).join(' and in '); + return ( + `${BODY_LABEL}: a value is encoded for where it lands only in ${supported}, so an html or mjml template ` + + `body may place a hole only there. Refused: ${listed}${rest}. Move each refused hole into element text or ` + + `a quoted plain-text attribute value, or set the format to text or markdown. A link whose address comes ` + + `from a value belongs in an email template (email_template), which declares that variable as a URL; ` + + `name it in the notify step's template.` + ); +} + +/** The refusal: `VALIDATION_FAILED` (400 at both HTTP doors), `invalid_value` at `body`. */ +export function holePlacementError(refused: readonly TemplateHtmlHole[]): Error { + const message = holePlacementMessage(refused); + return validationFailure(message, [{ field: BODY_FIELD, code: 'invalid_value', message, label: BODY_LABEL }]); +} + +/** + * The holes a template row's body places where the renderer cannot encode + * them — empty for a row that renders no html face, and for one whose every + * hole is placed where its encoding is right. + */ +export function refusedTemplateHoles(row: Record): TemplateHtmlHole[] { + if (!isHtmlFormat(row[FORMAT_FIELD])) return []; + const body = row[BODY_FIELD]; + if (body === null || body === undefined) return []; + return judgeTemplateHtmlHoles(String(body)).filter((hole) => !hole.supported); +} + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); + +function assertPlacements(row: Record): void { + const refused = refusedTemplateHoles(row); + if (refused.length > 0) throw holePlacementError(refused); +} + +/** `beforeInsert`: one dispatch per row; a row that names no format takes the declared default. */ +function onInsert(ctx: any): void { + if (ctx?.object !== undefined && ctx.object !== TEMPLATE_OBJECT) return; + const data = ctx?.input?.data; + if (!isRecord(data)) return; + const row = data[FORMAT_FIELD] === undefined ? { ...data, [FORMAT_FIELD]: DECLARED_FORMAT_DEFAULT } : data; + assertPlacements(row); +} + +/** + * `beforeUpdate`: one dispatch per matched row, `previous` bound on both + * dispatch paths; judged over the stored row overlaid with the write (a key the + * write carries as `undefined` leaves the stored value standing). + */ +function onUpdate(ctx: any): void { + if (ctx?.object !== undefined && ctx.object !== TEMPLATE_OBJECT) return; + const data = ctx?.input?.data; + if (!isRecord(data)) return; + const merged: Record = isRecord(ctx?.previous) ? { ...ctx.previous } : {}; + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) merged[key] = value; + } + assertPlacements(merged); +} + +/** The engine surface the hooks bind through — `ObjectQL`'s hook registry. */ +export interface PlacementHookEngine { + registerHook?: ( + event: string, + handler: (ctx: any) => void | Promise, + options?: { object?: string | string[]; priority?: number; packageId?: string }, + ) => void; + unregisterHooksByPackage?: (packageId: string) => number; +} + +/** + * Bind both hooks on the engine. Re-binding replaces the previous binding + * rather than stacking a second one. Returns `false` when the engine exposes + * no hook registry (a test double), so the caller can say so. + */ +export function bindTemplateHolePlacement(engine: PlacementHookEngine | null | undefined): boolean { + if (!engine || typeof engine.registerHook !== 'function') return false; + unbindTemplateHolePlacement(engine); + const options = { object: TEMPLATE_OBJECT, priority: HOOK_PRIORITY, packageId: TEMPLATE_PLACEMENT_HOOK_PACKAGE }; + engine.registerHook('beforeInsert', onInsert, options); + engine.registerHook('beforeUpdate', onUpdate, options); + return true; +} + +/** Unbind both hooks (plugin teardown). */ +export function unbindTemplateHolePlacement(engine: PlacementHookEngine | null | undefined): void { + if (engine && typeof engine.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(TEMPLATE_PLACEMENT_HOOK_PACKAGE); + } +} diff --git a/packages/services/service-messaging/src/template-hole-placement.ts b/packages/services/service-messaging/src/template-hole-placement.ts new file mode 100644 index 00000000000..26affecc402 --- /dev/null +++ b/packages/services/service-messaging/src/template-hole-placement.ts @@ -0,0 +1,553 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a hole may sit in the HTML face of a notification template — the judge + * the `sys_notification_template` save hooks read + * (`notification-template-placement.ts`; ruling `6335348115` on #25082, items 2 + * and 3). + * + * ## Derived from the encoder, not from a list of bad patterns + * + * `renderNotification` encodes a hole on the `html` / `mjml` face ONE way + * (`escapeHtml` in `template-renderer.ts`): the five characters an HTML parser + * reads as markup in element text and in a quoted attribute value — `&`, `<`, + * `>`, `"`, `'` — become character references, and nothing else changes. That + * is exactly the encoding two placements need, so those two are supported: + * + * - **element text** — the text content of an element, including the two + * elements whose text is read with references decoded and no markup inside + * (`title`, `textarea`); + * - **a quoted attribute value** whose content the client reads as plain text: + * double- or single-quoted, on an attribute that is not read as a URL, a + * script, a style sheet, markup or a setting of the document. + * + * Every other placement needs an encoding the renderer does not apply, so a + * hole there is refused — context-strict, by the placement the hole occupies + * in the parsed template. There is no unencoded hole form on this face (the + * renderer has none), so there is no exception to the two. + * + * ## Every reading a parser can give the template + * + * A tokenizer scan reads most of a template exactly as a mail client's parser + * does. Two things it cannot read from the template alone, because the + * parser's tree builder decides them from the elements around them: + * + * - **whether an element's text is raw.** `script`, `style` and their kind + * switch the parser to raw text (`title` and `textarea` to escapable text) — + * but `noscript` only with scripting enabled, which a mail client has off, + * and none of them inside a `select` or after a `frameset`, where the tree + * builder ignores the start tag. So at a `noscript` start tag, and at every + * such start tag once a `select` or `frameset` has been opened, the scan + * FORKS: one reading enters the element's text, the other reads on as + * markup. A hole is supported only when it is supported under EVERY reading, + * so markup that straddles such an element's end tag cannot move a hole out + * of one reading's view. + * - **where foreign content ends.** After an `svg` or `math` start tag the + * parser leaves HTML rules (it recognises CDATA sections and switches no + * element to raw text), and where it returns depends on breakout tags, + * integration points and unmatched end tags. The scan does not model that: + * from the root's start tag on, every hole is refused as foreign content. + * + * Both rules only ever refuse more than the parser would; neither can accept a + * hole the parser places elsewhere. + * + * ## The same vocabulary as the email half + * + * The email half of this ruling (`plugin-email`'s renderer, judged by + * `@objectstack/lint`'s authoring rule) names placements with the same class + * names and author-facing labels, so an administrator meets one vocabulary on + * both template kinds. This half keeps its own judge (ruling item 2): the + * renderers' hole grammars differ, and this one reads `template-renderer.ts`'s + * grammar through {@link templateHoles}. + * + * ## Limits, stated so they are not read as more + * + * - The judge classifies the TEMPLATE, with each hole standing for a value the + * renderer encoded. It says nothing about a value's content and does not + * replace the renderer's encoding. + * - It runs when a row is saved. A row stored before the rule keeps rendering + * until an edit judges it (ruling item 4). + */ + +import { templateHoles } from './template-renderer.js'; + +// ─── Placement classes ────────────────────────────────────────────── + +/** Every placement a hole can occupy in an HTML body, as a closed set. */ +export type TemplateHolePlacement = + | 'element-text' + | 'quoted-attribute' + | 'url-attribute' + | 'event-handler-attribute' + | 'style-attribute' + | 'markup-attribute' + | 'meta-attribute' + | 'unquoted-attribute' + | 'tag' + | 'raw-text-element' + | 'comment' + | 'foreign-content'; + +/** The placements whose encoding the renderer applies to a hole. */ +export const SUPPORTED_HOLE_PLACEMENTS: readonly TemplateHolePlacement[] = ['element-text', 'quoted-attribute']; + +/** How each placement is named to an author — one noun phrase, the refusal's vocabulary. */ +export const HOLE_PLACEMENT_LABELS: Readonly> = { + 'element-text': 'element text', + 'quoted-attribute': 'a quoted plain-text attribute value', + 'url-attribute': 'the value of an attribute read as a URL', + 'event-handler-attribute': 'the value of an event-handler attribute', + 'style-attribute': 'the value of a style attribute', + 'markup-attribute': 'the value of an attribute read as markup', + 'meta-attribute': 'the value of a meta element attribute, read as a setting of the document', + 'unquoted-attribute': 'an unquoted attribute value', + tag: 'a tag, outside any attribute value', + 'raw-text-element': 'the content of an element whose text is not parsed as HTML text (script, style and their kind)', + comment: 'a comment or markup declaration', + 'foreign-content': 'SVG or MathML content', +}; + +/** One hole the renderer substitutes, with the placement the template gives it. */ +export interface TemplateHtmlHole { + /** Offset of the hole's first brace in the body. */ + start: number; + /** Offset one past the hole's last brace. */ + end: number; + /** The hole as written, braces included. */ + text: string; + /** The dotted path the hole reads. */ + path: string; + /** + * The placement the template gives the hole. When the readings disagree, + * the first refused reading's placement: the one the author has to move. + */ + placement: TemplateHolePlacement; + /** Whether the renderer's encoding is right for the hole under every reading. */ + supported: boolean; +} + +/** + * Every hole the renderer substitutes in `html`, in body order, each with its + * placement and verdict. Text the renderer leaves verbatim (a brace pair that + * is not a hole) is the template's own markup and is read as such. + */ +export function judgeTemplateHtmlHoles(html: string): TemplateHtmlHole[] { + if (typeof html !== 'string' || html === '') return []; + const holes = templateHoles(html); + if (holes.length === 0) return []; + const verdicts = classifyHoles(html, holes); + return holes.map((hole, i) => ({ ...hole, ...verdicts[i] })); +} + +// ─── The placement tables ─────────────────────────────────────────── + +/** Elements whose text the parser reads raw: no references decoded, no markup, never HTML text. */ +const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript']); +/** Elements whose text is read with references decoded and no markup inside. */ +const ESCAPABLE_TEXT_ELEMENTS = new Set(['title', 'textarea']); +/** Roots of foreign content. */ +const FOREIGN_ROOTS = new Set(['svg', 'math']); +/** + * Attributes read as a URL (or a list of URLs), current and legacy, on any + * element. A namespaced attribute (`xlink:href`, `xml:base`, `xmlns:…`) is read + * as one too. + */ +const URL_ATTRIBUTES = new Set([ + 'href', 'src', 'srcset', 'imagesrcset', 'action', 'formaction', 'cite', 'data', 'poster', + 'background', 'longdesc', 'usemap', 'codebase', 'classid', 'archive', 'code', 'profile', + 'manifest', 'icon', 'ping', 'lowsrc', 'dynsrc', 'datasrc', 'itemtype', 'itemid', 'xmlns', +]); +/** Element-specific attributes read as a URL: a plugin parameter. */ +const ELEMENT_URL_ATTRIBUTES: Readonly>> = { + param: new Set(['value']), +}; +/** Attributes whose value is parsed as a document. */ +const MARKUP_ATTRIBUTES = new Set(['srcdoc']); + +function attributePlacement(element: string, attribute: string): TemplateHolePlacement { + // A meta element's every attribute configures the document — its content + // can be a refresh directive's URL, its charset the decoding of the whole + // body — so none of them is plain text. + if (element === 'meta') return 'meta-attribute'; + if (attribute.startsWith('on')) return 'event-handler-attribute'; + if (attribute === 'style') return 'style-attribute'; + if (MARKUP_ATTRIBUTES.has(attribute)) return 'markup-attribute'; + if (URL_ATTRIBUTES.has(attribute) || attribute.includes(':')) return 'url-attribute'; + if (ELEMENT_URL_ATTRIBUTES[element]?.has(attribute)) return 'url-attribute'; + return 'quoted-attribute'; +} + +// ─── The scan ─────────────────────────────────────────────────────── + +/** + * The tokenizer states the scan distinguishes — the HTML tokenizer's own, + * merged where the difference never moves a hole. + */ +type State = + | 'data' + | 'tag-open' + | 'end-tag-open' + | 'tag-name' + | 'before-attr-name' + | 'attr-name' + | 'after-attr-name' + | 'before-attr-value' + | 'attr-value-dq' + | 'attr-value-sq' + | 'attr-value-uq' + | 'after-attr-value' + | 'self-closing' + | 'declaration-open' + | 'declaration-dash' + | 'comment-start' + | 'comment-start-dash' + | 'comment' + | 'comment-end-dash' + | 'comment-end' + | 'comment-end-bang' + | 'bogus-comment' + | 'text' + | 'text-lt' + | 'text-end-open' + | 'text-end-name' + | 'plaintext'; + +/** One way a parser can read the template, as tokenizer state. */ +interface Reading { + state: State; + /** The tag being read (lower-cased), and whether it is an end tag. */ + tagName: string; + endTag: boolean; + /** The attribute whose name or value is being read (lower-cased). */ + attrName: string; + /** In `text*` states: the element whose end tag closes the text, and whether it is escapable (RCDATA). */ + textElement: string; + escapable: boolean; + /** In `text-end-name`: the end tag name read so far. */ + buffer: string; + /** In a script's text: past a comment opener and not yet past a closer. */ + scriptEscaped: boolean; + /** The last three characters of a script's text, for the opener and closer. */ + recent: string; + /** Past an `svg` / `math` start tag: never left (module note). */ + foreign: boolean; + /** Past a `select` / `frameset` start tag: a text element's start tag may be ignored from here on. */ + textStartsMayBeIgnored: boolean; +} + +/** + * Readings above this many are not tracked; every later hole is refused. A + * fork's text reading rejoins the markup reading at the element's end tag, so + * real markup holds a handful at most. + */ +const MAX_READINGS = 64; + +const isSpace = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'; +const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + +/** The data state, carrying only what outlives a token. */ +function dataReading(from?: Reading): Reading { + return { + state: 'data', tagName: '', endTag: false, attrName: '', textElement: '', escapable: false, + buffer: '', scriptEscaped: false, recent: '', + foreign: from?.foreign ?? false, + textStartsMayBeIgnored: from?.textStartsMayBeIgnored ?? false, + }; +} + +function readingKey(r: Reading): string { + return [ + r.state, r.tagName, r.endTag, r.attrName, r.textElement, r.escapable, r.buffer, + r.scriptEscaped, r.recent, r.foreign, r.textStartsMayBeIgnored, + ].join('\u0000'); +} + +/** + * Walk the body under every reading, with each hole standing for one inert + * character — what an encoded value is in the two supported placements — and + * record each hole's verdict. + */ +function classifyHoles( + html: string, + holes: ReadonlyArray<{ start: number; end: number }>, +): Array<{ placement: TemplateHolePlacement; supported: boolean }> { + const verdicts: Array<{ placement: TemplateHolePlacement; supported: boolean }> = []; + let readings: Reading[] = [dataReading()]; + let untracked = false; + + let h = 0; + let i = 0; + while (i < html.length) { + if (h < holes.length && i === holes[h].start) { + if (untracked) { + verdicts.push({ placement: 'tag', supported: false }); + } else { + const placements = readings.map(placementOf); + const refused = placements.find((p) => !SUPPORTED_HOLE_PLACEMENTS.includes(p)); + verdicts.push(refused ? { placement: refused, supported: false } : { placement: placements[0], supported: true }); + } + readings = advance(readings, 'x'); + i = holes[h].end; + h += 1; + continue; + } + readings = advance(readings, html[i]); + i += 1; + } + return verdicts; + + function advance(current: Reading[], c: string): Reading[] { + if (untracked) return current; + const next: Reading[] = []; + const seen = new Set(); + for (const reading of current) { + for (const stepped of step(reading, c)) { + const key = readingKey(stepped); + if (seen.has(key)) continue; + seen.add(key); + next.push(stepped); + } + } + if (next.length > MAX_READINGS) { + untracked = true; + return []; + } + return next; + } +} + +/** The placement a hole has under one reading. */ +function placementOf(r: Reading): TemplateHolePlacement { + if (r.foreign) return 'foreign-content'; + switch (r.state) { + case 'data': + return 'element-text'; + case 'text': + return r.escapable ? 'element-text' : 'raw-text-element'; + case 'text-lt': + case 'text-end-open': + case 'text-end-name': + case 'plaintext': + // A hole that could complete the closing tag, or a text the parser + // never leaves: refused with the element's own class. + return r.escapable ? 'tag' : 'raw-text-element'; + case 'declaration-open': + case 'declaration-dash': + case 'comment-start': + case 'comment-start-dash': + case 'comment': + case 'comment-end-dash': + case 'comment-end': + case 'comment-end-bang': + case 'bogus-comment': + return 'comment'; + case 'attr-value-dq': + case 'attr-value-sq': + if (r.endTag) return 'tag'; + if (FOREIGN_ROOTS.has(r.tagName)) return 'foreign-content'; + return attributePlacement(r.tagName, r.attrName); + case 'before-attr-value': + case 'attr-value-uq': + if (r.endTag) return 'tag'; + if (FOREIGN_ROOTS.has(r.tagName)) return 'foreign-content'; + return 'unquoted-attribute'; + default: + if (!r.endTag && FOREIGN_ROOTS.has(r.tagName)) return 'foreign-content'; + return 'tag'; + } +} + +/** + * One character under one reading: the readings it leads to — one, or two + * where a start tag forks the reading (module note). + */ +function step(r: Reading, c: string): Reading[] { + const s = { ...r }; + switch (s.state) { + case 'data': + if (c === '<') s.state = 'tag-open'; + return [s]; + case 'tag-open': + if (c === '!') { s.state = 'declaration-open'; return [s]; } + if (c === '/') { s.state = 'end-tag-open'; return [s]; } + if (c === '?') { s.state = 'bogus-comment'; return [s]; } + if (isAlpha(c)) { s.state = 'tag-name'; s.tagName = c.toLowerCase(); s.endTag = false; return [s]; } + // A character that opens no tag is reconsumed in the data state. + return step({ ...s, state: 'data' }, c); + case 'end-tag-open': + if (isAlpha(c)) { s.state = 'tag-name'; s.tagName = c.toLowerCase(); s.endTag = true; return [s]; } + if (c === '>') return [settle(s)]; + s.state = 'bogus-comment'; + return [s]; + case 'tag-name': + if (isSpace(c)) { s.state = 'before-attr-name'; return [s]; } + if (c === '/') { s.state = 'self-closing'; return [s]; } + if (c === '>') return emitTag(s, false); + s.tagName += c.toLowerCase(); + return [s]; + case 'before-attr-name': + if (isSpace(c)) return [s]; + if (c === '/') { s.state = 'self-closing'; return [s]; } + if (c === '>') return emitTag(s, false); + s.state = 'attr-name'; + s.attrName = c.toLowerCase(); + return [s]; + case 'attr-name': + if (isSpace(c)) { s.state = 'after-attr-name'; return [s]; } + if (c === '/') { s.state = 'self-closing'; return [s]; } + if (c === '=') { s.state = 'before-attr-value'; return [s]; } + if (c === '>') return emitTag(s, false); + s.attrName += c.toLowerCase(); + return [s]; + case 'after-attr-name': + if (isSpace(c)) return [s]; + if (c === '/') { s.state = 'self-closing'; return [s]; } + if (c === '=') { s.state = 'before-attr-value'; return [s]; } + if (c === '>') return emitTag(s, false); + s.state = 'attr-name'; + s.attrName = c.toLowerCase(); + return [s]; + case 'before-attr-value': + if (isSpace(c)) return [s]; + if (c === '"') { s.state = 'attr-value-dq'; return [s]; } + if (c === "'") { s.state = 'attr-value-sq'; return [s]; } + if (c === '>') return emitTag(s, false); + s.state = 'attr-value-uq'; + return [s]; + case 'attr-value-dq': + if (c === '"') s.state = 'after-attr-value'; + return [s]; + case 'attr-value-sq': + if (c === "'") s.state = 'after-attr-value'; + return [s]; + case 'attr-value-uq': + if (isSpace(c)) { s.state = 'before-attr-name'; return [s]; } + if (c === '>') return emitTag(s, false); + return [s]; + case 'after-attr-value': + if (isSpace(c)) { s.state = 'before-attr-name'; return [s]; } + if (c === '/') { s.state = 'self-closing'; return [s]; } + if (c === '>') return emitTag(s, false); + // A missing space between attributes: the character starts the next name. + return step({ ...s, state: 'before-attr-name' }, c); + case 'self-closing': + if (c === '>') return emitTag(s, true); + return step({ ...s, state: 'before-attr-name' }, c); + + // Markup declarations: `')) s.scriptEscaped = false; + s.recent = seen.slice(-3); + } + switch (s.state) { + case 'text-lt': + if (c === '/') { s.state = 'text-end-open'; return s; } + s.state = 'text'; + return c === '<' ? { ...s, state: 'text-lt' } : s; + case 'text-end-open': + if (isAlpha(c)) { s.state = 'text-end-name'; s.buffer = c.toLowerCase(); return s; } + s.state = 'text'; + return c === '<' ? { ...s, state: 'text-lt' } : s; + case 'text-end-name': { + const closes = s.buffer === s.textElement && !s.scriptEscaped; + if (closes && (isSpace(c) || c === '/' || c === '>')) { + const tag: Reading = { ...s, tagName: s.textElement, endTag: true, buffer: '', textElement: '', escapable: false, recent: '', scriptEscaped: false }; + if (isSpace(c)) return { ...tag, state: 'before-attr-name' }; + if (c === '/') return { ...tag, state: 'self-closing' }; + return settle(tag); + } + if (isAlpha(c)) { s.buffer += c.toLowerCase(); return s; } + s.state = 'text'; + s.buffer = ''; + return c === '<' ? { ...s, state: 'text-lt' } : s; + } + default: + if (c === '<') s.state = 'text-lt'; + return s; + } +} + +/** Back to the data state, with nothing of the finished token carried along. */ +function settle(s: Reading): Reading { + return dataReading(s); +} + +/** + * A tag's end. An end tag returns to data. A start tag of a foreign root + * enters foreign content for good. A start tag of a raw-text, escapable-text or + * `plaintext` element switches to that element's text — and FORKS the reading + * where the parser may not switch (module note): the other reading reads on + * as markup, as a parser that ignored the start tag or ran with scripting off + * would. + */ +function emitTag(s: Reading, selfClosing: boolean): Reading[] { + const data = settle(s); + if (s.endTag || s.foreign) return [data]; + if (FOREIGN_ROOTS.has(s.tagName)) return [selfClosing ? data : { ...data, foreign: true }]; + if (s.tagName === 'select' || s.tagName === 'frameset') return [{ ...data, textStartsMayBeIgnored: true }]; + let text: Reading; + if (s.tagName === 'plaintext') text = { ...data, state: 'plaintext', textElement: 'plaintext' }; + else if (RAW_TEXT_ELEMENTS.has(s.tagName)) text = { ...data, state: 'text', textElement: s.tagName, escapable: false }; + else if (ESCAPABLE_TEXT_ELEMENTS.has(s.tagName)) text = { ...data, state: 'text', textElement: s.tagName, escapable: true }; + else return [data]; + const forks = s.tagName === 'noscript' || s.textStartsMayBeIgnored; + return forks ? [text, data] : [text]; +} diff --git a/packages/services/service-messaging/src/template-renderer.ts b/packages/services/service-messaging/src/template-renderer.ts index 1aee4ae5831..bd45c545935 100644 --- a/packages/services/service-messaging/src/template-renderer.ts +++ b/packages/services/service-messaging/src/template-renderer.ts @@ -39,6 +39,41 @@ export interface RenderInput { const TOKEN = /\{\{\s*([\w.$]+)\s*\}\}/g; +/** One hole the renderer substitutes: its span in the template, braces included, and the path it reads. */ +export interface TemplateHole { + start: number; + end: number; + text: string; + path: string; +} + +/** + * Every hole {@link interpolate} and {@link renderNotification} substitute in + * `template`, in order. It reads the renderer's own grammar ({@link TOKEN}), so + * the placement judge the template object's save hooks run + * (`template-hole-placement.ts`) judges exactly the spans this module replaces — + * never text it leaves verbatim, never a span it misses. + */ +export function templateHoles(template: string): TemplateHole[] { + if (!template) return []; + const holes: TemplateHole[] = []; + for (const m of template.matchAll(TOKEN)) { + const start = m.index ?? 0; + holes.push({ start, end: start + m[0].length, text: m[0], path: m[1] }); + } + return holes; +} + +/** + * Whether a template row's `format` sends its body to the `html` face, where + * every hole is HTML-encoded (see {@link renderNotification}). One spelling for + * the renderer and the placement judge, so the rows the judge holds to the + * placement rule are exactly the rows rendered with that encoding. + */ +export function isHtmlFormat(format: unknown): boolean { + return format === 'html' || format === 'mjml'; +} + /** How one hole's value is encoded for the face it lands in. */ type HoleEncoder = (value: string) => string; @@ -106,7 +141,13 @@ function lookup(ctx: Record, path: string): unknown { * template's own markup is the author's and renders as written; only the * values are encoded. There is no unescaped hole form here — a template that * needs a pre-rendered fragment is an email template (`sys_email_template`, - * named in a notify step's `template`), whose renderer has one. + * named in a notify step's `template`), whose renderer has one. An `mjml` + * body is not compiled: it reaches the `html` face as written. + * - that encoding is right in element text and in a quoted plain-text + * attribute value, and in no other place a hole can sit, so a row whose + * html / mjml body puts a hole anywhere else is refused when it is saved + * (ruling `6335348115` on #25082, `template-hole-placement.ts`). A row stored + * before that rule keeps rendering here unchanged. * - the subject and a `markdown` / `text` body are plain text: values render * verbatim, because an entity in plain text is a corrupted character. */ @@ -124,7 +165,7 @@ export function renderNotification( if (template && (template.subject || template.body)) { const subject = interpolate(String(template.subject ?? ''), ctx) || String(ctx.title ?? input.topic); - const isHtml = template.format === 'html' || template.format === 'mjml'; + const isHtml = isHtmlFormat(template.format); const renderedBody = substitute(String(template.body ?? ''), ctx, isHtml ? escapeHtml : verbatim); return isHtml ? { subject, html: renderedBody } : { subject, text: renderedBody }; } From e739572a9f91efc5fbad6dc4ee568f59b3c9665d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:33:46 +0000 Subject: [PATCH 2/6] wip(service-messaging): judge unit tests Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01ApJv4hDHQ6M1JprNZFKzAW --- .../src/template-hole-placement.test.ts | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/services/service-messaging/src/template-hole-placement.test.ts diff --git a/packages/services/service-messaging/src/template-hole-placement.test.ts b/packages/services/service-messaging/src/template-hole-placement.test.ts new file mode 100644 index 00000000000..22091239536 --- /dev/null +++ b/packages/services/service-messaging/src/template-hole-placement.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25082 — the notification template's HTML hole placement judge, one + * placement class at a time, and the readings it takes where a parser's tree + * builder decides. + * + * Each case names the class it asserts; the bodies carry holes and markup only, + * never a value. Grammar parity with the renderer is pinned at the bottom, + * against `renderNotification` itself. + */ +import { describe, expect, it } from 'vitest'; + +import { + HOLE_PLACEMENT_LABELS, + judgeTemplateHtmlHoles, + SUPPORTED_HOLE_PLACEMENTS, + type TemplateHolePlacement, +} from './template-hole-placement.js'; +import { isHtmlFormat, renderNotification, templateHoles } from './template-renderer.js'; + +const one = (html: string) => { + const holes = judgeTemplateHtmlHoles(html); + expect(holes, html).toHaveLength(1); + return holes[0]; +}; + +describe('the supported placements', () => { + it.each([ + ['

{{ who }}

', 'element-text'], + ['{{who}}', 'element-text'], + ['
{{ payload.who }}
', 'element-text'], + ['

a < {{who}} > b

', 'element-text'], + ['

&{{who}};

', 'element-text'], + ['{{who}}', 'element-text'], + ['', 'element-text'], + ['

x

', 'quoted-attribute'], + ["

x

", 'quoted-attribute'], + ['x', 'quoted-attribute'], + ['{{who}}', 'quoted-attribute'], + ['

x

', 'quoted-attribute'], + ['{{who}}', 'quoted-attribute'], + ] as const)('%s → %s, supported', (html, placement) => { + const holes = judgeTemplateHtmlHoles(html); + expect(holes.length).toBeGreaterThan(0); + expect(holes.every((hole) => hole.supported), html).toBe(true); + expect(holes[0].placement).toBe(placement); + }); + + it('the supported list is the two encoded placements', () => { + expect([...SUPPORTED_HOLE_PLACEMENTS]).toEqual(['element-text', 'quoted-attribute']); + }); +}); + +describe('the refused placements, by class', () => { + it.each([ + ['', 'raw-text-element'], + ['', 'raw-text-element'], + ['{{x}}', 'raw-text-element'], + ['', 'raw-text-element'], + ['{{x}}', 'raw-text-element'], + ['{{x}}', 'raw-text-element'], + ['', 'raw-text-element'], + ['{{x}}', 'raw-text-element'], + ['<!-- {{x}} -->', 'comment'], + ['<!--[if mso]><p>{{x}}</p><![endif]-->', 'comment'], + ['<!DOCTYPE {{x}}>', 'comment'], + ['<!{{x}}>', 'comment'], + ['<? {{x}} ?>', 'comment'], + ['<td width={{x}}>a</td>', 'unquoted-attribute'], + ['<td width=1{{x}}>a</td>', 'unquoted-attribute'], + ['<p {{x}}>a</p>', 'tag'], + ['<{{x}}>', 'tag'], + ['</{{x}}>', 'tag'], + ['<p>a <{{x}}</p>', 'tag'], + ['<p title="a"{{x}}>a</p>', 'tag'], + ['<p data-{{x}}="1">a</p>', 'tag'], + ['<p>a</p title="{{x}}">', 'tag'], + ['<title>a <{{x}}</title>', 'tag'], + ['<title>a </{{x}}</title>', 'tag'], + ['<a href="{{x}}">a</a>', 'url-attribute'], + ['<a href="https://example.test/{{x}}">a</a>', 'url-attribute'], + ['<a HREF="{{x}}">a</a>', 'url-attribute'], + ['<img src="{{x}}">', 'url-attribute'], + ['<img srcset="{{x}} 2x">', 'url-attribute'], + ['<form action="{{x}}"></form>', 'url-attribute'], + ['<td background="{{x}}">a</td>', 'url-attribute'], + ['<a xlink:href="{{x}}">a</a>', 'url-attribute'], + ['<param name="m" value="{{x}}">', 'url-attribute'], + ['<mj-button href="{{x}}">a</mj-button>', 'url-attribute'], + ['<meta http-equiv="refresh" content="{{x}}">', 'meta-attribute'], + ['<meta charset="{{x}}">', 'meta-attribute'], + ['<meta name="description" content="{{x}}">', 'meta-attribute'], + ['<p onclick="{{x}}">a</p>', 'event-handler-attribute'], + ['<p style="color: {{x}}">a</p>', 'style-attribute'], + ['<iframe srcdoc="{{x}}"></iframe>', 'markup-attribute'], + ['<svg><text>{{x}}</text></svg>', 'foreign-content'], + ['<svg width="{{x}}"></svg>', 'foreign-content'], + ['<math><mi>{{x}}</mi></math>', 'foreign-content'], + ] as const)('%s → %s, refused', (html, placement) => { + const hole = one(html); + expect(hole.placement).toBe(placement); + expect(hole.supported).toBe(false); + }); + + it('every class has an author-facing label', () => { + const classes: TemplateHolePlacement[] = [ + 'element-text', 'quoted-attribute', 'url-attribute', 'event-handler-attribute', 'style-attribute', + 'markup-attribute', 'meta-attribute', 'unquoted-attribute', 'tag', 'raw-text-element', 'comment', + 'foreign-content', + ]; + expect(Object.keys(HOLE_PLACEMENT_LABELS).sort()).toEqual([...classes].sort()); + }); +}); + +describe('where an element ends, the markup after it is judged afresh', () => { + it('a hole after a closed raw-text element or comment is judged by the markup that follows', () => { + const holes = judgeTemplateHtmlHoles( + '<style>p{}</style><p>{{a}}</p><!-- c --><b>{{b}}</b><script></SCRIPT ><u>{{c}}</u><title>t</title><i title="{{d}}">x</i>', + ); + expect(holes.map((h) => [h.path, h.placement, h.supported])).toEqual([ + ['a', 'element-text', true], + ['b', 'element-text', true], + ['c', 'element-text', true], + ['d', 'quoted-attribute', true], + ]); + }); + + it('text inside a raw-text element that only looks like a closing tag does not close it', () => { + expect(one('<script></scriptx>{{x}}</script>').placement).toBe('raw-text-element'); + expect(one('<style></style-x>{{x}}</style>').placement).toBe('raw-text-element'); + expect(one('<style></sty{{x}}le><p>a</p>').placement).toBe('raw-text-element'); + }); + + it('past a comment opener in a script, no end tag closes it until the comment closer', () => { + expect(one('<script><!-- </script><p>{{x}}</p>').placement).toBe('raw-text-element'); + expect(one('<script><!-- a --></script><p>{{x}}</p>')).toMatchObject({ placement: 'element-text', supported: true }); + }); + + it('an abruptly closed comment ends where the parser ends it', () => { + expect(one('<!--><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<!---><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<!-- a --!><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<!-- a -- b --><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<!-- a -><p>{{x}}</p>').placement).toBe('comment'); + }); + + it('a markup declaration other than a comment ends at the next >', () => { + expect(one('<!DOCTYPE html><p>{{x}}</p>').placement).toBe('element-text'); + expect(one('<![if !mso]><p>{{x}}</p><![endif]>').placement).toBe('element-text'); + expect(one('<![CDATA[ a > b ]]><p>{{x}}</p>').placement).toBe('element-text'); + }); + + it('a character that opens no tag is reconsumed as text', () => { + expect(one('<p>1 <2 {{x}}</p>').placement).toBe('element-text'); + expect(one('<p><<b>{{x}}</b></p>').placement).toBe('element-text'); + }); +}); + +describe('the readings a parser\'s tree builder decides', () => { + it('foreign content is never left: every hole after an svg or math root is refused', () => { + for (const html of [ + '<svg></svg><p>{{x}}</p>', + '<math></math><p>{{x}}</p>', + '<svg></math><p title="{{x}}">a</p>', + '<svg><foreignObject><div></svg></div><p>{{x}}</p>', + ]) { + expect(one(html), html).toMatchObject({ placement: 'foreign-content', supported: false }); + } + }); + + it('a self-closed svg root opens no foreign content', () => { + expect(one('<svg/><p>{{x}}</p>')).toMatchObject({ placement: 'element-text', supported: true }); + }); + + it('a noscript element is read both ways: as raw text, and as markup (scripting off, as in a mail client)', () => { + // Read as raw text, the comment opener is text and the hole lands in a + // paragraph; read as markup, the same hole lands in a comment. + expect(one('<noscript><!--</noscript><p>{{x}}</p>-->')).toMatchObject({ placement: 'comment', supported: false }); + // A hole each reading places where the encoding is right stays supported. + expect(one('<noscript><p title="</noscript>{{x}}">a</p>').supported).toBe(true); + // A noscript holding plain text rejoins one reading at its end tag. + expect(one('<noscript>text</noscript><p>{{x}}</p>')).toMatchObject({ placement: 'element-text', supported: true }); + }); + + it('inside a select or after a frameset, a text element is read both ways, since its start tag may be ignored', () => { + expect(one('<select><style><p style="</style>{{x}}">').supported).toBe(false); + expect(one('<frameset><textarea><a href="</textarea>{{x}}">').supported).toBe(false); + // Outside those, a style element is raw text and nothing else. + expect(one('<style><p style="</style>{{x}}">')).toMatchObject({ placement: 'element-text', supported: true }); + }); + + it('a hole refused under one reading is refused, with that reading\'s placement', () => { + expect(one('<noscript>{{x}}</noscript>')).toMatchObject({ placement: 'raw-text-element', supported: false }); + }); +}); + +describe('what is and is not a hole — the renderer\'s grammar', () => { + it('text the renderer leaves verbatim is markup, not a hole', () => { + expect(judgeTemplateHtmlHoles('<p>{{ not a path }}</p>')).toEqual([]); + expect(judgeTemplateHtmlHoles('<p>{x}</p>')).toEqual([]); + expect(judgeTemplateHtmlHoles('<p>{{ x | upper }}</p>')).toEqual([]); + expect(judgeTemplateHtmlHoles('')).toEqual([]); + }); + + it('a verbatim brace pair still moves the markup around it', () => { + const holes = judgeTemplateHtmlHoles('<p>{{ <b }}{{x}}</p>'); + expect(holes).toHaveLength(1); + expect(holes[0]).toMatchObject({ path: 'x', placement: 'tag', supported: false }); + }); + + it('a hole\'s span, braces included', () => { + expect(judgeTemplateHtmlHoles('a {{ payload.x }} b {{{z}}} c')).toEqual([ + expect.objectContaining({ start: 2, end: 17, text: '{{ payload.x }}', path: 'payload.x' }), + expect.objectContaining({ start: 21, end: 26, text: '{{z}}', path: 'z' }), + ]); + }); + + it('the judge reads exactly the spans renderNotification substitutes on the html face', () => { + const corpus = [ + '<p>{{ a }} {{b.c}} {{ $d }}</p>', + '<p title="{{e}}">{{{f}}}</p>', + '{{ not a path }} {{g}} {x} {{ h | upper }} {{\ni\n}}', + ]; + for (const body of corpus) { + const holes = templateHoles(body); + const payload: Record<string, unknown> = {}; + for (const hole of holes) { + const segments = hole.path.split('.'); + let cursor = payload; + for (const segment of segments.slice(0, -1)) cursor = (cursor[segment] ??= {}) as Record<string, unknown>; + cursor[segments[segments.length - 1]] = 'Z'; + } + const rendered = renderNotification({ subject: 's', body, format: 'html' }, { topic: 't', payload }).html; + // Replacing every span the judge reads with the value renders the + // renderer's output, byte for byte. + let expected = ''; + let at = 0; + for (const hole of judgeTemplateHtmlHoles(body)) { + expected += body.slice(at, hole.start) + 'Z'; + at = hole.end; + } + expected += body.slice(at); + expect(rendered, body).toBe(expected); + } + }); + + it('the formats judged are exactly the ones rendered to the html face, and an mjml body reaches it as written', () => { + for (const format of ['html', 'mjml', 'markdown', 'text', null, undefined] as const) { + const rendered = renderNotification({ subject: 's', body: '<mj-text>{{x}}</mj-text>', format }, { topic: 't', payload: { x: 'Z' } }); + expect(rendered.html !== undefined, String(format)).toBe(isHtmlFormat(format)); + if (rendered.html !== undefined) expect(rendered.html).toBe('<mj-text>Z</mj-text>'); + } + }); +}); From 4c2bad444972a60b99f4c34f692d3c51a1088da9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 22 Oct 2026 16:37:20 +0000 Subject: [PATCH 3/6] wip(service-messaging): save-hook integration tests; refusal message leads with the refused holes Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApJv4hDHQ6M1JprNZFKzAW --- .../src/notification-template-placement.ts | 13 +- ...ion-template-placement.integration.test.ts | 337 ++++++++++++++++++ 2 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 packages/services/service-messaging/src/objects/notification-template-placement.integration.test.ts diff --git a/packages/services/service-messaging/src/notification-template-placement.ts b/packages/services/service-messaging/src/notification-template-placement.ts index 3b33315920f..0335195b660 100644 --- a/packages/services/service-messaging/src/notification-template-placement.ts +++ b/packages/services/service-messaging/src/notification-template-placement.ts @@ -80,13 +80,14 @@ export function holePlacementMessage(refused: readonly TemplateHtmlHole[]): stri .map((hole) => `${hole.text} in ${HOLE_PLACEMENT_LABELS[hole.placement]}`) .join('; '); const rest = refused.length > LISTED_HOLES ? `; and ${refused.length - LISTED_HOLES} more` : ''; - const supported = SUPPORTED_HOLE_PLACEMENTS.map((p) => HOLE_PLACEMENT_LABELS[p]).join(' and in '); + const supported = SUPPORTED_HOLE_PLACEMENTS.map((p) => HOLE_PLACEMENT_LABELS[p]).join(' and '); + // The refused holes and the way out come first: an import row reports the + // message's head only (`sanitizeRowError` keeps 300 characters). return ( - `${BODY_LABEL}: a value is encoded for where it lands only in ${supported}, so an html or mjml template ` + - `body may place a hole only there. Refused: ${listed}${rest}. Move each refused hole into element text or ` + - `a quoted plain-text attribute value, or set the format to text or markdown. A link whose address comes ` + - `from a value belongs in an email template (email_template), which declares that variable as a URL; ` + - `name it in the notify step's template.` + `${BODY_LABEL}: an html or mjml body encodes a hole's value only for ${supported}, so a hole may sit only ` + + `there. Refused: ${listed}${rest}. Move each refused hole there, or set the format to text or markdown. ` + + `A link whose address comes from a value belongs in an email template (email_template), which declares ` + + `that variable as a URL; name it in the notify step's template.` ); } diff --git a/packages/services/service-messaging/src/objects/notification-template-placement.integration.test.ts b/packages/services/service-messaging/src/objects/notification-template-placement.integration.test.ts new file mode 100644 index 00000000000..f90602980a8 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-template-placement.integration.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_notification_template` refuses a save whose html or mjml body places a + * hole the renderer cannot encode for where it lands (#25082, ruling + * `6335348115` items 2–4). The judge is `template-hole-placement.ts`, pinned + * class by class in its own test; this file pins the save hooks + * (`notification-template-placement.ts`) on a real `ObjectQL` over `SqlDriver` + * (better-sqlite3), through the real `ObjectStackProtocolImplementation`, the + * REST data door's own error classifier (`mapDataError`) and the import runner + * (`runImport`): + * + * - NEW ROWS: a create whose html or mjml body places a hole outside element + * text and quoted plain-text attribute values is refused `400 + * VALIDATION_FAILED`, one finding at `body` (`invalid_value`) naming the + * hole and its placement; so is a by-id update moving a row's format to + * html over such a body, and the failed row of an import commit. A text or + * markdown body is not judged, and a row naming no format takes the + * declared default (markdown). + * - STORED ROWS (written straight through the driver — the shape a row saved + * before this rule has): any edit that leaves a refused hole in place is + * refused — an edit of another column, a bulk update's row, a predicate + * update over a set holding it — and the row is left exactly as stored. It + * is still read and deleted, an edit moving the hole or the format is + * accepted, and it keeps rendering as it did. + * - THE PLUGIN binds the hooks on the kernel's engine at init and unbinds them + * at destroy. + * + * Bodies carry holes and markup only. ⛔ Nothing here rewrites or deletes a + * stored row on the rule's behalf: the only deletes are the caller's own. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, runImport, type ExportFieldMeta } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { mapDataError } from '@objectstack/types'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { NotificationTemplate } from './notification-template.object.js'; +import { NotificationTemplateStore, renderNotification, TEMPLATE_OBJECT } from '../template-renderer.js'; +import { bindTemplateHolePlacement } from '../notification-template-placement.js'; +import { HOLE_PLACEMENT_LABELS } from '../template-hole-placement.js'; +import { MessagingServicePlugin } from '../messaging-service-plugin.js'; + +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; + +const ORG = 'org_a'; +const TOPIC = 'deal.won'; +const CTX = { isSystem: true, tenantId: ORG } as ExecutionContext; +const STORED_ID = 'tpl_stored_misplaced'; + +/** Every hole where the encoding is right: element text and a quoted plain-text attribute value. */ +const PLACED = '<p title="{{ title }}">{{ body }}</p>'; +/** One hole in element text, one in a URL attribute. */ +const MISPLACED = '<a href="{{ link }}">{{ title }}</a>'; +/** The refused hole of {@link MISPLACED}, as the refusal names it. */ +const REFUSED_NAMED = `{{ link }} in ${HOLE_PLACEMENT_LABELS['url-attribute']}`; + +const silentLogger: any = { + debug() {}, info() {}, warn() {}, error() {}, fatal() {}, + child() { return silentLogger; }, +}; + +let engine: ObjectQL | undefined; + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; +}); + +async function boot(): Promise<{ ql: ObjectQL; driver: SqlDriver; protocol: ObjectStackProtocolImplementation }> { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const ql = new ObjectQL({ logger: silentLogger }); + ql.registerDriver(driver, true); + await ql.init(); + for (const def of [SYS_ORGANIZATION, NotificationTemplate]) { + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); + } + await ql.syncSchemas(); + ql.setTenancyPostureProvider(() => 'single'); + await ql.insert('sys_organization', { id: ORG, name: ORG }, { context: { isSystem: true } as ExecutionContext }); + expect(bindTemplateHolePlacement(ql)).toBe(true); + engine = ql; + return { ql, driver, protocol: new ObjectStackProtocolImplementation(ql as any) }; +} + +/** Boot, plus one html row with a misplaced hole stored straight through the driver. */ +async function bootWithStoredMisplacedRow(): Promise<{ ql: ObjectQL; protocol: ObjectStackProtocolImplementation }> { + const booted = await boot(); + await booted.driver.create(TEMPLATE_OBJECT, { id: STORED_ID, organization_id: ORG, ...row('html', MISPLACED) }); + return booted; +} + +function row(format: string | undefined, body: string, channel = 'email'): Record<string, unknown> { + const r: Record<string, unknown> = { topic: TOPIC, channel, locale: 'en', subject: 'subject', body, is_active: true }; + if (format !== undefined) r.format = format; + return r; +} + +async function storedRows(ql: ObjectQL): Promise<Array<Record<string, unknown>>> { + const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['id', 'channel', 'format', 'body', 'is_active'] }, { context: CTX })) ?? []; + return rows.map((r: Record<string, unknown>) => ({ id: r.id, channel: r.channel, format: r.format, body: r.body, is_active: r.is_active })); +} + +async function storedRow(ql: ObjectQL, id: string): Promise<Record<string, unknown> | undefined> { + return (await storedRows(ql)).find((r) => r.id === id); +} + +/** The error a write threw — and a failure when it did not throw at all. */ +async function thrownBy(write: Promise<unknown>): Promise<any> { + try { + await write; + } catch (err) { + return err; + } + throw new Error('the write was accepted; a refusal was expected'); +} + +/** + * The finding's message: the supported placements, the refused hole and its + * placement, the way out — and, where the whole message reaches the caller, + * where a link from a value belongs. + */ +function expectPrescription(message: unknown, named = REFUSED_NAMED, whole = true): void { + const text = String(message); + expect(text.startsWith( + `Body: an html or mjml body encodes a hole's value only for ${HOLE_PLACEMENT_LABELS['element-text']} and ` + + `${HOLE_PLACEMENT_LABELS['quoted-attribute']}, so a hole may sit only there.`, + )).toBe(true); + expect(text).toContain(`Refused: ${named}. Move each refused hole there, or set the format to text or markdown.`); + if (whole) expect(text).toContain('email_template'); +} + +/** + * The REST data door's answer to a thrown write (`mapDataError`, as `POST` / + * `PATCH /data/:object` call it): `400 VALIDATION_FAILED`, one finding at + * `body`, `invalid_value`, with the prescription. + */ +function expectPlacementRefusal(err: any, named = REFUSED_NAMED): void { + expect(err?.code).toBe('VALIDATION_FAILED'); + const answer = mapDataError(err, TEMPLATE_OBJECT); + expect(answer.status).toBe(400); + expect(answer.body.code).toBe('VALIDATION_FAILED'); + const fields = answer.body.fields as Array<Record<string, unknown>>; + expect(fields).toHaveLength(1); + expect(fields[0]).toMatchObject({ field: 'body', code: 'invalid_value' }); + expectPrescription(fields[0].message, named); +} + +const META_MAP = new Map<string, ExportFieldMeta>( + Object.entries(NotificationTemplate.fields).map(([name, f]: [string, any]) => [name, { name, type: f.type, label: f.label }]), +); + +describe('sys_notification_template.body: a new html or mjml row placing a hole the renderer cannot encode is refused', () => { + it.each(['html', 'mjml'])('a %s create is refused 400 VALIDATION_FAILED at the REST data door, and nothing is stored', async (format) => { + const { ql, protocol } = await boot(); + expectPlacementRefusal(await thrownBy(protocol.createData({ object: TEMPLATE_OBJECT, data: row(format, MISPLACED), context: CTX }))); + expect(await storedRows(ql)).toEqual([]); + }); + + it('names every refused hole with its placement, and counts past five', async () => { + const { protocol } = await boot(); + const body = `<p>{{ a }}</p><style>{{ b }}</style><img src="{{ c }}"><b id={{ d }}>x</b>` + + '<p onclick="{{ e }}" style="{{ f }}"></p><!-- {{ g }} -->'; + const err = await thrownBy(protocol.createData({ object: TEMPLATE_OBJECT, data: row('html', body), context: CTX })); + const named = [ + `{{ b }} in ${HOLE_PLACEMENT_LABELS['raw-text-element']}`, + `{{ c }} in ${HOLE_PLACEMENT_LABELS['url-attribute']}`, + `{{ d }} in ${HOLE_PLACEMENT_LABELS['unquoted-attribute']}`, + `{{ e }} in ${HOLE_PLACEMENT_LABELS['event-handler-attribute']}`, + `{{ f }} in ${HOLE_PLACEMENT_LABELS['style-attribute']}`, + ].join('; '); + expectPlacementRefusal(err, `${named}; and 1 more`); + }); + + it.each([ + ['html', PLACED], + ['mjml', '<mjml><mj-body><mj-text css-class="{{ title }}">{{ body }}</mj-text></mj-body></mjml>'], + ])('a %s create placing every hole where the encoding is right is stored (the positive twins)', async (format, body) => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row(format, body), context: CTX }); + expect(await storedRow(ql, String(created.id))).toMatchObject({ format, body }); + }); + + it.each(['text', 'markdown', undefined])('a body with format %j is not judged: its holes render verbatim', async (format) => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row(format, MISPLACED), context: CTX }); + expect(await storedRow(ql, String(created.id))).toMatchObject({ format: format ?? 'markdown', body: MISPLACED }); + }); + + it('a by-id update moving a text row to html over a misplaced hole is refused, and the row keeps text', async () => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('text', MISPLACED), context: CTX }); + expectPlacementRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: String(created.id), data: { format: 'html' }, context: CTX }))); + expect(await storedRow(ql, String(created.id))).toMatchObject({ format: 'text', body: MISPLACED }); + }); + + it('a by-id update moving a hole of an html row into a URL attribute is refused, and the row keeps its body', async () => { + const { ql, protocol } = await boot(); + const created = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('html', PLACED), context: CTX }); + expectPlacementRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: String(created.id), data: { body: MISPLACED }, context: CTX }))); + expect(await storedRow(ql, String(created.id))).toMatchObject({ format: 'html', body: PLACED }); + }); + + it('an import commit stores the placed row and fails the misplaced row at body, with the prescription', async () => { + const { ql, protocol } = await boot(); + const summary = await runImport({ + p: protocol as any, + objectName: TEMPLATE_OBJECT, + context: CTX, + rows: [row('html', PLACED), row('html', MISPLACED, 'sms')], + metaMap: META_MAP, + writeMode: 'insert', + matchFields: [], + dryRun: false, + runAutomations: false, + trimWhitespace: true, + createMissingOptions: false, + skipBlankMatchKey: false, + }); + expect(summary).toMatchObject({ created: 1, errors: 1 }); + expect(summary.results[0]).toMatchObject({ row: 1, ok: true, action: 'created' }); + expect(summary.results[1]).toMatchObject({ row: 2, ok: false, action: 'failed', field: 'body', code: 'invalid_value' }); + // An import row reports the message's head; the refused hole and the way out are in it. + expectPrescription(summary.results[1].error, REFUSED_NAMED, false); + expect((await storedRows(ql)).map((r) => r.body)).toEqual([PLACED]); + }); +}); + +describe('sys_notification_template.body: a row already stored with a misplaced hole', () => { + it('is refused on an edit of another column at the REST data door, and is left as stored', async () => { + const { ql, protocol } = await bootWithStoredMisplacedRow(); + expectPlacementRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { is_active: false }, context: CTX }))); + expectPlacementRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { subject: 'edited' }, context: CTX }))); + expect(await storedRow(ql, STORED_ID)).toEqual({ id: STORED_ID, channel: 'email', format: 'html', body: MISPLACED, is_active: true }); + }); + + it('is refused on a write that keeps the misplaced hole while moving the others', async () => { + const { ql, protocol } = await bootWithStoredMisplacedRow(); + expectPlacementRefusal(await thrownBy(protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { body: `<div>${MISPLACED}</div>` }, context: CTX }))); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ body: MISPLACED }); + }); + + it('fails its own row of a bulk update, VALIDATION_FAILED / 400, while the placed row lands', async () => { + const { ql, protocol } = await bootWithStoredMisplacedRow(); + const placed = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('html', PLACED, 'sms'), context: CTX }); + const answer = await protocol.updateManyData({ + object: TEMPLATE_OBJECT, + records: [{ id: STORED_ID, data: { is_active: false } }, { id: String(placed.id), data: { is_active: false } }], + options: { continueOnError: true }, + context: CTX, + } as any); + expect(answer).toMatchObject({ success: false, total: 2, succeeded: 1, failed: 1 }); + const [storedResult, placedResult] = answer.results as Array<Record<string, any>>; + expect(storedResult).toMatchObject({ id: STORED_ID, success: false }); + expect(storedResult.errors).toHaveLength(1); + expect(storedResult.errors[0]).toMatchObject({ code: 'VALIDATION_FAILED', httpStatus: 400 }); + expectPrescription(storedResult.errors[0].message); + expect(placedResult).toMatchObject({ id: String(placed.id), success: true }); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ is_active: true }); + expect(await storedRow(ql, String(placed.id))).toMatchObject({ is_active: false }); + }); + + it('refuses a predicate update over a set holding it, and that update writes no row', async () => { + const { ql, protocol } = await bootWithStoredMisplacedRow(); + const placed = await protocol.createData({ object: TEMPLATE_OBJECT, data: row('html', PLACED, 'sms'), context: CTX }); + expectPlacementRefusal(await thrownBy(ql.update(TEMPLATE_OBJECT, { is_active: false }, { where: { topic: TOPIC }, multi: true, context: CTX }))); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ is_active: true }); + expect(await storedRow(ql, String(placed.id))).toMatchObject({ is_active: true }); + }); + + it.each([ + ['moves the hole into element text', { body: '<a href="#">{{ link }} {{ title }}</a>' }], + ['moves the format to text', { format: 'text' }], + ])('is accepted on an edit that %s, and edits normally from then on', async (_label, repair) => { + const { ql, protocol } = await bootWithStoredMisplacedRow(); + await protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: repair, context: CTX }); + await protocol.updateData({ object: TEMPLATE_OBJECT, id: STORED_ID, data: { is_active: false }, context: CTX }); + expect(await storedRow(ql, STORED_ID)).toMatchObject({ ...repair, is_active: false }); + }); + + it('is still read and deleted', async () => { + const { ql, protocol } = await bootWithStoredMisplacedRow(); + const read = await protocol.getData({ object: TEMPLATE_OBJECT, id: STORED_ID, context: CTX } as any); + expect(read.record).toMatchObject({ id: STORED_ID, format: 'html', body: MISPLACED }); + await protocol.deleteData({ object: TEMPLATE_OBJECT, id: STORED_ID, context: CTX } as any); + expect(await storedRows(ql)).toEqual([]); + }); + + it('keeps rendering as it did: nothing runs at send', async () => { + const { ql } = await bootWithStoredMisplacedRow(); + const store = new NotificationTemplateStore({ getData: () => ql as any }); + const loaded = await store.load(TOPIC, 'email', 'en', ORG); + expect(loaded).toMatchObject({ format: 'html', body: MISPLACED }); + expect(renderNotification(loaded, { topic: TOPIC, payload: {} })).toEqual({ subject: 'subject', html: '<a href=""></a>' }); + }); +}); + +describe('MessagingServicePlugin binds the save hooks on the kernel\'s engine', () => { + const openKernels: ObjectKernel[] = []; + const openDrivers: Array<{ disconnect?: () => Promise<void> }> = []; + + afterEach(async () => { + while (openKernels.length) { + try { await openKernels.pop()?.shutdown(); } catch { /* already stopped */ } + } + while (openDrivers.length) { + try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ } + } + }); + + it('refuses a misplaced hole once the kernel is up, and not after the plugin is destroyed', async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); + openKernels.push(kernel); + const plugin = new MessagingServicePlugin({ dispatchIntervalMs: 60_000, partitionCount: 1 }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(plugin); + await kernel.bootstrap(); + + const ql = kernel.getService<ObjectQL>('objectql'); + const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + openDrivers.push(driver); + ql.registry.registerObject(SYS_ORGANIZATION as any, '@objectstack/service-messaging'); + await ql.syncSchemas(); + ql.setTenancyPostureProvider(() => 'single'); + await ql.insert('sys_organization', { id: ORG, name: ORG }, { context: { isSystem: true } as ExecutionContext }); + + expectPlacementRefusal(await thrownBy(ql.insert(TEMPLATE_OBJECT, row('html', MISPLACED), { context: CTX }))); + + await plugin.destroy(); + await ql.insert(TEMPLATE_OBJECT, row('html', MISPLACED), { context: CTX }); + expect((await storedRows(ql)).map((r) => r.body)).toEqual([MISPLACED]); + }); +}); From 8485bb1a481a80f23c0f9b8fd18775cbd1fa6ef7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 22 Oct 2026 16:41:46 +0000 Subject: [PATCH 4/6] wip(service-messaging): changeset and outlet census entries for the placement judge Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApJv4hDHQ6M1JprNZFKzAW --- ...82-notification-template-hole-placement.md | 19 +++++++++++++++++++ .../src/messaging-service-plugin.ts | 4 ++-- .../src/text-field-html-outlets.pin.test.ts | 6 ++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 .changeset/25082-notification-template-hole-placement.md diff --git a/.changeset/25082-notification-template-hole-placement.md b/.changeset/25082-notification-template-hole-placement.md new file mode 100644 index 00000000000..93d3611a156 --- /dev/null +++ b/.changeset/25082-notification-template-hole-placement.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-messaging': patch +--- + +fix(service-messaging)!: `sys_notification_template` refuses an html or mjml body that places a hole where the renderer's encoding is not right for it + +An html or mjml notification template body HTML-escapes every hole's value. That encoding is right in two placements only: element text, and a quoted plain-text attribute value. A hole anywhere else (an attribute read as a URL, an event handler, a style, an attribute holding markup, a meta element attribute, an unquoted attribute value, inside a tag, a raw-text element, a comment, or SVG / MathML content) received an encoding that does not fit where it lands. Such a body is now refused when it is saved. A hole counts as supported only when every way an HTML parser can read the body places it in element text or a quoted plain-text attribute value. The check is the template object's own save hooks, judged over the stored row overlaid with the write, so a row stored before this change that breaks the rule is refused on any edit that leaves it breaking it: frozen, not bricked. It is still read, listed and deleted, and it keeps rendering and delivering as before. No stored row is rewritten. + +Clause-②: no (narrowing) +Metadata-protocol: none + +<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the write accept set of `sys_notification_template` rows, a platform object this package ships and owns, whose rows are admin-authored data rather than metadata: no ADR-0087 conversion reaches a stored data row. The remedy is an author's edit of the row, moving a hole or changing the format, never a rewrite of a metadata source, and this change rewrites or deletes no stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + +**BREAKING** (a platform object's write accept set narrows, stored rows included). It ships as `patch` because the metadata protocol does not move. + +- **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row with format `html` or `mjml` and a hole outside element text and quoted plain-text attribute values was accepted and stored. A row already stored that way took every edit. +- **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding at `body` (`code: 'invalid_value'`) that names each refused hole and its placement. An import commit fails that row with `field: 'body'` and `code: 'invalid_value'`. A bulk update fails that row with `code: 'VALIDATION_FAILED'` and `httpStatus: 400`, and a predicate update over a set holding such a row writes no row. Every writer is judged, system context included. +- **Unchanged:** `markdown` and `text` bodies, whose holes render verbatim; html and mjml bodies whose every hole sits in element text or a quoted plain-text attribute value; reading, listing and deleting any row; and how every row renders and is delivered. No error code is added. +- **The fix for an author:** move each refused hole into element text or a quoted plain-text attribute value, or set the format to `text` or `markdown`. A link whose address comes from a value belongs in an email template (`email_template`), which declares that variable as a URL; name it in the notify step's `template`. diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 94cb796d8f9..37a64f553c4 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -356,8 +356,8 @@ export class MessagingServicePlugin implements Plugin { this.placementEngine = engine; } else { ctx.logger.warn( - '[messaging] no ObjectQL engine with a hook registry — a sys_notification_template save whose html or ' - + 'mjml body places a hole outside element text or a quoted plain-text attribute value is NOT refused', + '[messaging] no ObjectQL engine with a hook registry — a sys_notification_template save is NOT judged ' + + 'for where its body places a hole (#25082)', ); } diff --git a/packages/services/service-messaging/src/text-field-html-outlets.pin.test.ts b/packages/services/service-messaging/src/text-field-html-outlets.pin.test.ts index ff802a0ba0a..66ec7e686f0 100644 --- a/packages/services/service-messaging/src/text-field-html-outlets.pin.test.ts +++ b/packages/services/service-messaging/src/text-field-html-outlets.pin.test.ts @@ -81,6 +81,12 @@ const NOT_OUTLETS: Readonly<Record<string, NotOutletEntry>> = { 'objects/notification-template.object.ts': { why: 'declares the `format` vocabulary (markdown, html, text, mjml); renders nothing', }, + 'template-hole-placement.ts': { + why: 'reads an html / mjml template body to say where each hole sits; places no value into markup (#25082)', + }, + 'notification-template-placement.ts': { + why: 'refuses a template save whose html / mjml body places a hole outside the placements template-renderer.ts encodes for; renders nothing (#25082)', + }, }; // ── The census ────────────────────────────────────────────────────────────── From 5d129dbc24c3edb7f2ed5ab291eaa3db1cb16516 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 22 Oct 2026 16:48:25 +0000 Subject: [PATCH 5/6] docs(service-messaging): the template object's docblock names the placement save hooks Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApJv4hDHQ6M1JprNZFKzAW --- .../src/objects/notification-template.object.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 9675ee5837b..b1b6564f678 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -43,6 +43,13 @@ export const TEMPLATE_CHANNELS = ['email', 'sms'] as const; * additive. `channel` is one of {@link TEMPLATE_CHANNELS}; see there for who * reads a row and why no other value is accepted. * + * An `html` / `mjml` body may place a hole only in element text or a quoted + * plain-text attribute value, the two placements the renderer's encoding is + * right for. A save placing one anywhere else is refused by the save hooks in + * `notification-template-placement.ts` (#25082), which `MessagingServicePlugin` + * binds on the engine. They are hooks rather than a rule here because a rule's + * CEL condition has no reading of HTML placement. + * * Studio-configurable (contributed to the Setup → Configuration nav). Belongs to * `service-messaging`. */ From 4ca18be6c30c39bf186d02c68236d79b3af1af4a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 22 Oct 2026 16:52:59 +0000 Subject: [PATCH 6/6] fix(service-messaging): the placement hooks' missing-engine warning carries no tracker id Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApJv4hDHQ6M1JprNZFKzAW --- .../services/service-messaging/src/messaging-service-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 37a64f553c4..26a769f7e9c 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -357,7 +357,7 @@ export class MessagingServicePlugin implements Plugin { } else { ctx.logger.warn( '[messaging] no ObjectQL engine with a hook registry — a sys_notification_template save is NOT judged ' - + 'for where its body places a hole (#25082)', + + 'for where its body places a hole', ); }