Skip to content

Commit d8dc243

Browse files
committed
Merge origin/main into claude/issue-25049-cbp-write-shape-master-edit
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
2 parents 1b27f97 + a39389e commit d8dc243

5 files changed

Lines changed: 839 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
---
4+
5+
fix(service-messaging)!: an html or mjml `sys_notification_template` body escapes every hole's value for markup
6+
7+
The email channel renders a notification template row whose `format` is `html` or `mjml` into the mail's html part. Its `{{ payload.x }}` holes placed each value into that markup as stored, so a text field carrying markup characters — a person's display name, a record title, a comment — was read as markup by the recipient's mail client. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike, and the template's own markup renders as written. Stored text is never stripped or refused: a display name may contain any character, and the mail client shows it as typed.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- 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. An authored template body's own markup renders exactly as before; what changes is how the renderer encodes a hole's VALUE in the html and mjml faces. The remedy is an author's choice — put the markup in the template body instead of a payload value — never a rewrite of a stored row or a package source, and stored `sys_notification_template` rows are not touched. 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). -->
12+
13+
**BREAKING** (what an html or mjml notification template renders for a markup-bearing value narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** in an html or mjml `sys_notification_template` body, a `{{ payload.x }}` hole rendered its value verbatim, so a value carrying markup became markup in the sent html part.
16+
- **TO:** the same hole renders the value HTML-escaped: an ampersand, an angle bracket or a quote arrives as the matching HTML entity, which a mail client displays as the original character.
17+
- **Unchanged:** the subject and a `markdown` or `text` body are plain text and keep rendering values verbatim. A value without markup characters renders byte-identically. The email-template path (`sys_email_template`, named in a notify step's `template`) already escaped its html body by default and is unchanged, and no stored row is rewritten.
18+
- **What to do:** a template that relied on a payload value rendering as markup moves that markup into the template body, or into an email template, whose triple-braced hole is the one unescaped form.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Every outlet in this package that renders a text field into HTML escapes it
5+
* by context — ruling 3C on #25040.
6+
*
7+
* ## The rule
8+
*
9+
* A text field is stored as the person typed it: a display name may contain
10+
* any character, so nothing is stripped or refused at the write. The burden
11+
* sits on the OUTLET — the code that places a value into markup — and an
12+
* outlet escapes by default, for the context the value lands in.
13+
*
14+
* ## The list, and the pin on the list itself
15+
*
16+
* {@link OUTLETS} names every file of this package that renders a value into
17+
* HTML, each with pins that drive the real render path. A per-entry pin cannot
18+
* see an outlet nobody listed, so the LIST is pinned too: the census walks this
19+
* package's source and finds every file that touches HTML at all — an
20+
* identifier or a string naming `html`, or a string carrying an HTML tag — and
21+
* requires each one to be on {@link OUTLETS} or on {@link NOT_OUTLETS} with the
22+
* reason it renders nothing. A new file that touches HTML fails here until its
23+
* author says which it is.
24+
*
25+
* ## When the census fires
26+
*
27+
* ⛔ Not by adding the file to {@link NOT_OUTLETS} because it is in the way. If
28+
* the file places a value into markup it is an outlet: escape the value for its
29+
* context, add the entry to {@link OUTLETS} and give it a pin that renders a
30+
* text field carrying markup characters through the real path. Only a file that
31+
* moves already-rendered HTML, or names HTML as vocabulary, is a non-outlet,
32+
* and its entry says which.
33+
*
34+
* ⚠️ LIMIT, stated so it is not read as more: the census reads source text of
35+
* THIS package. It proves no unlisted file here touches HTML; it does not see
36+
* another package (each package holding an outlet carries its own list), and it
37+
* does not prove a listed outlet escapes — that is what the per-entry pins are
38+
* for.
39+
*/
40+
41+
import { readFileSync, readdirSync, statSync } from 'node:fs';
42+
import { dirname, join, relative, sep } from 'node:path';
43+
import { fileURLToPath } from 'node:url';
44+
45+
import ts from 'typescript';
46+
import { describe, it, expect } from 'vitest';
47+
import type { ApprovalRequestRow } from '@objectstack/spec/contracts';
48+
49+
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
50+
51+
/** …/packages/plugins/plugin-approvals/src — this package's whole source tree. */
52+
const SRC = dirname(fileURLToPath(import.meta.url));
53+
54+
/** A file that renders a value into HTML, and the pin that holds it to the rule. */
55+
interface OutletEntry {
56+
/** What it renders. */
57+
readonly renders: string;
58+
/** Registers the pins that drive its real render path — an outlet cannot be listed without one. */
59+
readonly pin: () => void;
60+
}
61+
62+
/** A file that touches HTML without rendering a value into it. */
63+
interface NotOutletEntry {
64+
readonly why: string;
65+
}
66+
67+
const OUTLETS: Readonly<Record<string, OutletEntry>> = {
68+
'action-link-pages.ts': {
69+
renders: 'the session-less confirm and result pages an approval link opens',
70+
pin: pinActionLinkPages,
71+
},
72+
};
73+
74+
const NOT_OUTLETS: Readonly<Record<string, NotOutletEntry>> = {
75+
'approval-service.ts': {
76+
why: 'wraps a page action-link-pages.ts rendered in a text/html Response; renders no value itself',
77+
},
78+
'approvals-plugin.ts': {
79+
why: 'serves a page action-link-pages.ts rendered with a text/html content type; renders no value itself',
80+
},
81+
};
82+
83+
// ── The census ──────────────────────────────────────────────────────────────
84+
85+
const HTML_WORD = /html/i;
86+
/** An HTML element opening or closing tag inside a string. */
87+
const HTML_TAG =
88+
/<(?:!doctype|\/?(?:html|head|body|title|meta|style|script|link|p|a|b|i|u|em|strong|small|span|div|br|hr|img|table|thead|tbody|tfoot|tr|td|th|ul|ol|li|h[1-6]|form|input|button|textarea|select|option|label|pre|code|blockquote|section|article|header|footer|nav|main|center|font|iframe|svg|mj-[a-z-]+))(?=[\s>/])/i;
89+
90+
/** Does this source touch HTML in CODE — comments never count. */
91+
function touchesHtml(fileName: string, text: string): boolean {
92+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
93+
let hit = false;
94+
const visit = (node: ts.Node): void => {
95+
if (hit) return;
96+
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
97+
if (HTML_WORD.test(node.text)) hit = true;
98+
} else if (
99+
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
100+
|| ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)
101+
) {
102+
if (HTML_WORD.test(node.text) || HTML_TAG.test(node.text)) hit = true;
103+
}
104+
if (!hit) ts.forEachChild(node, visit);
105+
};
106+
visit(source);
107+
return hit;
108+
}
109+
110+
/**
111+
* Every shipped source file, package-relative. Tests and test kits ship
112+
* nothing; the generated translation bundles are projections of the object
113+
* declarations (`check:i18n` owns them) and render nothing.
114+
*/
115+
function shippedSources(dir = SRC): string[] {
116+
const out: string[] = [];
117+
for (const entry of readdirSync(dir)) {
118+
const full = join(dir, entry);
119+
if (statSync(full).isDirectory()) {
120+
out.push(...shippedSources(full));
121+
continue;
122+
}
123+
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
124+
if (entry.endsWith('.test.ts') || entry.endsWith('.testkit.ts')) continue;
125+
const rel = relative(SRC, full).split(sep).join('/');
126+
if (rel.startsWith('translations/') && entry.endsWith('.generated.ts')) continue;
127+
out.push(rel);
128+
}
129+
return out.sort();
130+
}
131+
132+
describe('the outlet list — every file here that touches HTML is classified', () => {
133+
it('the census reads code, not comments (control)', () => {
134+
expect(touchesHtml('a.ts', '// renders html\nexport const x = 1;')).toBe(false);
135+
expect(touchesHtml('a.ts', 'export const x = `<p>${1}</p>`;')).toBe(true);
136+
expect(touchesHtml('a.ts', 'export const html = 1;')).toBe(true);
137+
expect(touchesHtml('a.ts', "export const t = 'text/html';")).toBe(true);
138+
});
139+
140+
it('the files that touch HTML are exactly the listed outlets and non-outlets', () => {
141+
const touching = shippedSources().filter((rel) => touchesHtml(rel, readFileSync(join(SRC, rel), 'utf8')));
142+
const listed = [...Object.keys(OUTLETS), ...Object.keys(NOT_OUTLETS)].sort();
143+
expect(touching).toEqual(listed);
144+
});
145+
146+
it('no file is both an outlet and a non-outlet', () => {
147+
expect(Object.keys(OUTLETS).filter((f) => f in NOT_OUTLETS)).toEqual([]);
148+
});
149+
});
150+
151+
// ── The per-entry pins ──────────────────────────────────────────────────────
152+
153+
/** A text field as a person may type it: markup characters, kept as typed. */
154+
const PERSON = 'Ada <b>Lovelace</b> & "Co" it\'s';
155+
const PERSON_IN_HTML = 'Ada &lt;b&gt;Lovelace&lt;/b&gt; &amp; &quot;Co&quot; it&#39;s';
156+
/** The fixture's markup as it would appear had a value passed through unescaped. */
157+
const PERSON_MARKUP = '<b>Lovelace</b>';
158+
159+
const REQUEST: ApprovalRequestRow = {
160+
id: 'apr_1',
161+
process_name: 'expense_approval',
162+
process_label: PERSON,
163+
object_name: 'expense',
164+
object_label: PERSON,
165+
record_id: 'exp_1',
166+
status: 'pending',
167+
current_step: 'manager',
168+
step_label: PERSON,
169+
submitter_id: 'usr_1',
170+
submitter_name: PERSON,
171+
};
172+
173+
function pinActionLinkPages(): void {
174+
it('the confirm page escapes every request text field, the acting address and the form attributes', () => {
175+
const page = renderConfirmPage({
176+
request: REQUEST,
177+
action: 'approve',
178+
approverId: PERSON,
179+
token: PERSON,
180+
actPath: '/api/v1/approvals/act',
181+
});
182+
183+
// Four request fields, the acting address, and the hidden token's value attribute.
184+
expect(page.split(PERSON_IN_HTML).length - 1).toBe(6);
185+
expect(page).toContain(`value="${PERSON_IN_HTML}"`);
186+
expect(page).not.toContain(PERSON_MARKUP);
187+
});
188+
189+
it('the result page escapes every request text field', () => {
190+
const page = renderResultPage('approved', REQUEST);
191+
192+
expect(page.split(PERSON_IN_HTML).length - 1).toBe(4);
193+
expect(page).not.toContain(PERSON_MARKUP);
194+
});
195+
}
196+
197+
// Registered last, so every fixture above is initialised before a pin reads it.
198+
for (const [file, entry] of Object.entries(OUTLETS)) {
199+
describe(`${file} — ${entry.renders}`, entry.pin);
200+
}

0 commit comments

Comments
 (0)