fix: scope the value ceiling to the names a template references

Addresses Copilot's round-19 review on #2035 -- a divergence introduced by
my own round-14 guard rather than by the original code.

The SDK validated a value inside `encodeValue`, i.e. only after looking one
up by a DECLARED name. The restored guard checked the caller's whole map, so
an unrelated entry failed a template that never mentions it. Measured on
9caac43d:

  tryExpandUriTemplate("x://{id}", { id: "7", stale: "a".repeat(1_000_001) })
    =>  error: The value for "stale" exceeds the 1000000-character limit

RFC 6570 ignores an extra variable, so this refused a perfectly valid read.
`valueLengthError` now takes the names to consider and both callers pass the
template's declared set.

Copilot's second half is worth taking on its own: strict expansion parsed
the template THREE times -- inside `templateError`, again for the name list,
again to expand -- and the template is server-controlled and may be 1 MB.
It is now parsed once and reused, with validation split into
`sdkTemplateError` (the constructor's unclosed-brace and length/count
limits) and `partsError` (this module's own grammar), so `templateError` and
strict expansion share both halves rather than duplicating either.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 01:54:37 -04:00
commit 7bcf155e06
2 changed files with 91 additions and 14 deletions

View file

@ -144,6 +144,15 @@ describe("previewUriTemplate - the value ceiling", () => {
);
});
it("ignores an oversized value the template never references", () => {
expect(
previewUriTemplate("x://{id}", {
id: "7",
stale: "a".repeat(1_000_001),
}),
).toBe("x://7");
});
it("still previews a value at the limit", () => {
const value = "a".repeat(1_000_000);
expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`);

View file

@ -11,6 +11,7 @@
*/
import {
declaredNames,
definedValues,
encodeLiteral,
expandTemplateExpression,
@ -79,20 +80,25 @@ export function previewUriTemplate(
): string {
const defined = definedValues(values);
// The same ceiling `tryExpandUriTemplate` enforces for the read. Without it
// the guard was half a guard: submission was refused while this path still
// handed the oversized value to the encoders on every keystroke, so a large
// paste froze the UI anyway. Checked before any encoding happens.
if (valueLengthError(defined) !== null) return uriTemplate;
// The same verdict the read is gated on. Without it this expanded expression
// by expression regardless: `x://{a}}/{b}` with both fields filled previewed
// `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview
// promising something the form it sits in can never send.
if (templateError(uriTemplate) !== null) return uriTemplate;
const parts = parseUriTemplate(uriTemplate);
// The same ceiling `tryExpandUriTemplate` enforces for the read, scoped the
// same way -- to the names this template references. Without it the guard was
// half a guard: submission was refused while this path still handed the
// oversized value to the encoders on every keystroke, so a large paste froze
// the UI anyway. Checked before any encoding happens.
if (valueLengthError(defined, declaredNames(parts)) !== null) {
return uriTemplate;
}
try {
return parseUriTemplate(uriTemplate)
return parts
.map((part) => {
if (part.kind === "literal") return encodeLiteral(part.text);
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are