fix: enforce propertyNames key pattern on the generated Signals schema - #40
Merged
damaz91 merged 1 commit intoAug 11, 2026
Merged
Conversation
quicktype's typescript-zod target emits only an object's shape, so the
`propertyNames` key constraint and `additionalProperties: true` on
`shopping/types/signals.json` are both dropped from the generated
`CheckoutCreateRequestSignalsSchema` (src/spec_generated.ts).
Observed (current main, committed models):
CheckoutCreateRequestSignalsSchema.parse({
"dev.ucp.buyer_ip": "1.2.3.4", "bogus KEY!": "x",
});
// succeeds; "bogus KEY!" is silently stripped rather than rejected
CheckoutCreateRequestSignalsSchema.parse({ "com.example.device_id": "a" });
// succeeds but returns {}; the reverse-domain extra is lost
Expected: the malformed key is rejected and the reverse-domain extra is
preserved. signals.json requires every property name to match
`^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$` (signals.json propertyNames.pattern,
the same pattern as shopping/types/reverse_domain_name.json), and sets
`additionalProperties: true`.
Fix (source-driven, in scripts/inject-schema-constraints.mjs): scan the source
schemas for objects that declare propertyNames alongside named properties, read
the key pattern from source (inline pattern or a $ref, never hand-copied), and
splice `.catchall(z.any()).superRefine(...)` onto the matching z.object so extras
are retained (additionalProperties: true) and every key is checked. RegExp.test
matches JSON Schema's unanchored pattern semantics and, for the `^...$`-anchored
source pattern, rejects a trailing-newline key in ECMA-262 (no `m` flag) — the JS
analogue of the python-sdk#66 sibling's re.match -> re.fullmatch fix; pinned by a
test.
Scope: Signals is the only propertyNames + named-properties object in the
2026-04-08 spec. The pure dict-map propertyNames registries (ucp.json
services/capabilities/payment_handlers) render as z.record(z.string(), V), which
does not enforce the key pattern; unlike python-sdk#66's dict[ReverseDomainName]
they are a genuine residual, left as a scoped follow-up needing a distinct
z.record-key mechanism. Other propertyNames sites (requires.capabilities,
supported_versions, identity_linking scopes) are not emitted.
Sibling of python-sdk#66. Regeneration from the pinned spec is byte-identical
(model-drift job stays green); npm test, npm run build, and pre-commit are clean.
damaz91
approved these changes
Aug 11, 2026
damaz91
merged commit Aug 11, 2026
78351ce
into
Universal-Commerce-Protocol:main
13 of 14 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
propertyNamesis not enforced on the one generated schema where it maps to anextra-permitting object with named fields. quicktype's
typescript-zodtargetemits only the object shape, so both the key pattern (
propertyNames) and theextra-key retention (
additionalProperties: true) are dropped.Observed (on current
main, committed models,src/spec_generated.ts):Expected: the malformed key is rejected, and the well-formed reverse-domain
extra is preserved.
signals.jsonrequires every property name to match thereverse-domain pattern
^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$(
shopping/types/signals.jsonpropertyNames.pattern, the same pattern asshopping/types/reverse_domain_name.json), and it setsadditionalProperties: true, so a well-formed extra such ascom.example.device_idmust be kept.Why it happens / why CI did not catch it
signals.jsondeclarespropertyNamesalongside namedproperties(
dev.ucp.buyer_ip,dev.ucp.user_agent) andadditionalProperties: true.quicktype emits it as a bare
z.object({...})with the two named fields.z.objectstrips unknown keys by default, so extra keys are neither retained norchecked against the key pattern. No test asserted key-pattern rejection, so the
dropped constraint shipped green.
Fix (source-driven, in the generation pipeline)
scripts/inject-schema-constraints.mjsalready re-attaches the value constraintsquicktype drops (#34/#37). This extends it to the object-level
propertyNameskey constraint: it scans the source schemas for objects that declare
propertyNamesand carry namedproperties, reads the key pattern from thesource (inline
pattern, or a$refto e.g.reverse_domain_name.json— neverduplicated in code), and splices onto the matching
z.objectcall:.catchall(z.any())keeps the reverse-domain extras a barez.objectwould drop;the
.superRefinematches every property name (named fields and retained extrasalike) against the source pattern. The injected regex is byte-identical to
signals.jsonpropertyNames.pattern.RegExp.testis used deliberately. It implements JSON Schema's unanchoredpatternsemantics, and for the source's^...$-anchored pattern it matchesonly end-of-input in ECMA-262 (no
mflag), so a trailing-newline key(
"com.example.k\n") is rejected. This is the JS analogue of the python-sdk#66sibling fix, where Python's
re.matchhad to becomere.fullmatchbecausere.matchadmits a trailing newline against a$-anchored pattern; JavaScript's$does not, and a pinned test asserts it.Scope —
propertyNamessites in the 2026-04-08 specThe change deliberately handles only the
propertyNames+ named-properties(extra-permitting object) shape. Every
propertyNamessite in the spec isaccounted for:
shopping/types/signals.json(root)dev.ucp.buyer_ip,dev.ucp.user_agent);additionalProperties: truez.object—CheckoutCreateRequestSignalsSchema(aliasedLookupRequestSignalsSchema)ucp.json#/$defs/baseservices/capabilities/payment_handlersz.record(z.string(), V)inUcpResponseSchema;servicesalso inUcpSchemaucp.json#/$defs/requirescapabilitiesucp.jsonbusinesssupported_versionscommon/identity_linking.jsonconfig.scopesSignalsis the onlypropertyNames+ named-propertiesobject in the spec, sothe enforcement lands on it and its alias.
Note on the
z.recordregistries (honest residual, not "already safe")The pure dict-map registries that do project —
UcpResponseSchemacapabilities/payment_handlers/services, andUcpSchemaservices—render as
z.record(z.string(), V), whosez.string()key schema does notenforce the reverse-domain pattern:
So in js-sdk these keys are currently unvalidated. This differs from python-sdk#66,
where the equivalent maps became
dict[ReverseDomainName, V]and pydantic alreadyvalidates the keys — that reasoning does not carry over to zod. Enforcing
these needs a distinct mechanism (rewriting the
z.recordkey schema toz.record(z.string().regex(<pattern>), V)), not the object-scoped named-propertyinjector this PR extends. It is left as a scoped follow-up rather than folded in,
to keep this change focused; flagged here so it is not mistaken for already-safe.
minProperties(source:shopping/types/description.json,shopping/types/available_payment_instrument.json) is a separate constraintclass with no
propertyNamesand is likewise left as a clean follow-up.Known boundary
zod-core silently drops an own
__proto__key from everyz.object(aprototype-pollution safeguard) before
.catchall/.superRefinerun, so a{"__proto__": ...}payload parses successfully with the key absent from theoutput — dropped rather than rejected. The direction is safe: the key is not
preserved and the prototype is not polluted. This is an SDK-wide zod trait
(
.passthrough()and.catchall()drop it identically), not specific toSignals, so this PR pins the behavior with a test rather than special-casing one
schema with a raw-input wrapper. Every other pattern-violating own key —
including
constructorandprototype— is rejected as normal; only the literal__proto__is affected.Verification
(stripped), reverse-domain extra lost. Green after regeneration. Kill-test:
neutralizing the injector's
propertyNamespath turns the new semantic testsred (4 of 5), the named-field test stays green.
"com.example.k\n",anchor safety); preserves the well-formed reverse-domain extra; the named
fields still populate; the alias enforces the same.
npm test: all green (48 tests)../generate_models.sh <ucp @ release/2026-04-08>)and normalized with the repo's pinned prettier hook; the diff is limited to the
Signalsschema, and a second regeneration is byte-identical, so themodel-drift job stays green.
npm run build(cjs/esm/types) clean; fullpre-commiton the changed filesclean (prettier, codespell, whitespace, shebang/executable checks).
This is the js-sdk sibling of python-sdk#66 (the other SDK; prior art, not a
duplicate).