fix: server handler payload aliases#1990
Conversation
There was a problem hiding this comment.
Approving. Closes the v5 raw-response-builder write-only-field leak it targets and aligns ServerPayload<T> with the runtime contract — spec-conformant per core.generated.ts:997, 1072, 1104, 1123, fail-closed at the seam.
Things I checked
BusinessEntity.bankandNotificationConfig.authentication.credentialsare normatively write-only in the spec (generated-type docstrings cite the schema directly). Stripping them at the v5 raw builders is enforcing aMUST NOT be echoedcontract, not witness violation.- Runtime helpers in
src/lib/server/responses.ts:160-206mirror the v6projectBillingEntity/projectNotificationConfigprior art atsrc/lib/server/decisioning/account.ts:884-942. Same spread-clone-then-strip shape, non-mutating by inspection. StripWriteOnlyResponseFields<T>evaluation order insrc/lib/types/server-payload.ts:43-55is correct: recursive map → stripbank→ stripauthentication.credentials.JsonPrimitivedistribution works. Readonly-array arm is unreachable because mutable arrays match the earlier(infer TItem)[]arm.- Five v5 builders touched (
mediaBuyResponse,updateMediaBuyResponse,getMediaBuysResponse,listAccountsResponse,syncAccountsResponse) cover every wire response shape that carriesBusinessEntityor nestedaccount.notification_configson the v5 path. - Sync-arm idempotency cache stores
formatted(post-strip) atcreate-adcp-server.ts:4806-4822, so the replay path is clean. Test name "before idempotent replay caching" is correctly placed. - Signature narrowings on
AccountStore(upsert,syncGovernance,list,reportUsage,getAccountFinancials) and the brand-rights / sales specialism methods are backward-compat under structural subtyping — extra fields in the adopter's return value remain assignable to the narrower*Payloadtype. - New runtime tests in
test/server-responses.test.jsandtest/lib/account-notification-config-projection.test.jsexercise the new strip paths at all four affected v5 builders, including nestedaccount.billing_entity.bankinside media buys.
Follow-ups (non-blocking — file as issues before the next beta cut)
- v6 HITL
tasks/get+ completion-webhook side-channel echoes unstrippedresult.from-platform.ts:2571-2574(HITL completion strip) and:1895-1897(tasks/getecho) bypassmediaBuyResponseentirely. An adopter returningCreateMediaBuySuccessfromctx.handoffToTask(...)withaccount.billing_entity.bankornotification_configs.authentication.credentialsleaks both to the polling buyer AND the completion webhook receiver. Same chokepoint pattern PR #1562 added forctx_metadata/implementation_config—bank/credentialsneed the same treatment. Fix: add astripWriteOnlyResponseFieldswalk inctx-metadata/wire-shape.tsmirroringstripCtxMetadata's carrier traversal, invoke atfrom-platform.ts:2289-2292(projectSync) and:2571-2574(HITL completion). That single addition closes this AND #2 below. - Sync-arm
emitSyncCompletionWebhookreads pre-wrap()projected payload.from-platform.ts:2459— whenautoEmitCompletionWebhooks: true, the webhook receiver gets the unstripped payload before the dispatcher's wrap strips it. Closed by the sameprojectSyncstrip in follow-up #1. ctxMetadataStore.setResourcepersists unstripped resources at rest.from-platform.ts:2870viaautoStoreResources. At-rest exposure in the configurable Postgres/Redis backend. Lightweight fix: reuse the wire-shape walk inautoStoreResourcesat:2860-2864beforestore.setResource.- Tighten
StripBusinessEntityBank<T>predicate.server-payload.ts:38matches any object with a top-levelbank?:— today onlyBusinessEntityqualifies, but a future schema landing that addsbank?:anywhere else (or an adopter usingServerPayload<MyType>with an unrelatedbankfield) silently drops it with no diagnostic. The runtime strip inresponses.ts:160only targetsbilling_entity/invoice_recipientparents — types and runtime diverge. Either narrow the type predicate to{ legal_name: string; bank?: unknown }or walk by key name (mirror runtime). - Changeset doesn't disclose the
ServerPayload<T>recursive narrowing. The body documents the*Payload/*HandlerResultalias swaps, not the fact that every signature now returning*Payloadwill rejectbank/authentication.credentialsat compile time. Adopters mid-migration who spread an incoming request into a response will hit fresh TS errors. Worth a line in the changeset.
Minor nits (non-blocking)
let strippedshould beconst.src/lib/server/responses.ts:189— never reassigned.@typescript-eslint/prefer-constwill flag.BuildCreativeReturnandBuildCreativePayloadduplicated acrosscreative.tsandcreative-ad-server.ts. Identical bodies pointing at the same generatedBuildCreativeSuccess/BuildCreativeMultiSuccess. The index aliases the creative-ad-server variants, so no.d.tsduplicate-symbol risk today. Silent-drift risk if one is modified without the other. Consider extracting to a shareddecisioning/creative-shared.ts.ListAccountsPayloadvsListAccountsHandlerResultnaming collision.account.ts:233-246— same domain, different shape (wire-stripped{ accounts: [...] }vs framework richCursorPage<Account<TCtxMeta>>). Worth a 2-line tsdoc on each distinguishing handler-layer from wire-payload.@ts-expect-errorincheck-adopter-types.ts:193-202can silently skip the assertion. Ifaccounts[0]?.notification_configs?.[0]is undefined the guard short-circuits and the type check never runs against a populated value. Use non-null assertions so the type check actually exercises the strip.brand-rights.ts:138, 165-175TSDoc lag. Doc aboveacquireRightsstill names the three concreteAcquireRights*arms; signature now returnsAcquireRightsPayload. Same forupdateRights. Doc-vs-signature drift, easy fix.
Notable timing aside: the changeset prose was updated in this PR (file modified, not added) while the version got bumped to beta.9 in package-lock.json and version.ts without a corresponding package.json change in the diff — read as the regen catching up after a base-branch release, not a manual bump. Worth a glance before merge to confirm package.json is consistent with what npm will see.
Safe to merge once the v6 follow-ups (#1 + #2 above) get tracked as issues. Spec-conformance plus the v5-path coverage carry the approval.
There was a problem hiding this comment.
LGTM. Type-level and v5-runtime stripping are now symmetric. Follow-ups below — the most important is the v6 framework path parity.
Things I checked
- Type recursion in
src/lib/types/server-payload.ts:33-71.StripWriteOnlyResponseFields<T>distributes correctly overT extends unknown(preserves discriminated unions —AcquireRightsPayloadstill discriminates onrights_status,CreativeApprovalPayloadonapproval_status, both renamed fromstatusin 3.1 per adcp#4878), recurses through arrays and readonly arrays, and bottoms out atJsonPrimitive. Bounded by the depth of generated types (4-6 levels max onGetProductsResponse/GetMediaBuysResponse); proven under--noEmitbydecisioning.type-checks.ts:436-484. - Runtime symmetry on v5.
stripBusinessEntityBank(responses.ts:160-172),stripAccountWriteOnlyFields(:174-176),stripMediaBuyWriteOnlyFields(:188-196), andstripMediaBuysWriteOnlyFields(:198-206) coverlistAccountsResponse/syncAccountsResponse/mediaBuyResponse/updateMediaBuyResponse/getMediaBuysResponse. The destructure-and-rest idiom forbankmatches the existingprojectBillingEntityprecedent ataccount.ts:938-942— excludes own-enumerable, prototype-chain, and Proxy-backed bank fields by ES rest-spread evaluation order. - Spec authority.
BusinessEntity.bankwrite-only atsrc/lib/types/v3-1-beta/tools.generated.ts:12767, 12821("MUST NOT be echoed in responses");NotificationConfig.authentication.credentialswrite-only at:12500, 12848, 12864("sellers MUST NOT echo"). The PR's stripping is normatively required, not a stylistic choice. - Changeset present.
.changeset/fix-server-handler-payload-aliases.mdatpatch. Defensible on the8.1.0-beta.xline; would beminoron the GA line because the new alias exports are additive ANDServerPayload<T>narrows existing types. Migration prose in the changeset body covers the adopter switch. - Version sync.
LIBRARY_VERSION8.1.0-beta.8 → beta.9 inversion.ts+package-lock.jsonis a catch-up topackage.jsonwhich was bumped by release PR #1980. Not a manual cut. - Test coverage. New cases in
test/server-responses.test.js:163-194, 272-282, 308-345andtest/lib/account-notification-config-projection.test.js:73-138cover all five hardened response builders.5473/5473 passafter the rebase, per the green CI on thefix: address ci failurescommit.
Follow-ups (non-blocking — file as issues)
- v6 framework path doesn't runtime-strip
invoice_recipient.bank.runtime/from-platform.ts:3658, 3750, 3878returns the platform result raw fromcreateMediaBuy/updateMediaBuy/getMediaBuys. TypeScript adopters are protected byPromise<CreateMediaBuyPayload>at the seam; JS adopters andas-cast paths still leak bank coordinates onto the wire AND into the idempotency replay cache — exactly the threat the comment ataccount.ts:899-908names forgovernance_agents.authentication.credentials. Add atoWireMediaBuy(...)projection mirroringtoWireAccount(account.ts:804-840) and apply it in the three v6 paths.stripMediaBuyWriteOnlyFieldsinresponses.ts:188-196is the right model — factor it out. This PR fixed the v5 deprecated path; v6 parity is the next ticket. patchvsminoron the GA line. Document theServerPayload<T>narrowing explicitly in the changeset body so the GA-cut release-note generator picks it up. Adopters whose handlers currently setinvoice_recipient.bank(silly but legal pre-PR) will fail typecheck on bump.- Conformance assertion for write-only absence on v6.
decisioning.type-checks.ts:507-565only proves compile-time. A storyboard step assertingbank/credentialsabsence in the dispatched wire oncreate_media_buy/update_media_buy/get_media_buyswould close the runtime gap above and prevent regression after thetoWireMediaBuyfollow-up lands.
Minor nits (non-blocking)
JsonPrimitiveordering.src/lib/types/server-payload.ts:33includesundefined. Works in practice — the mapped type evaluates first — butT extends string | number | boolean | nullwould make the conditional ordering load-bearing on shape, not optionality. Tighten it.StripAuthenticationCredentialsover-matches by shape, not name.src/lib/types/server-payload.ts:35-39strips.credentialsfrom ANY object with a typedauthentication?: objectproperty. Today onlyNotificationConfigandPushNotificationConfigqualify, and both are write-only. If a future schema adds an unrelatedauthentication: { credentials }(e.g., a brand-rightsgeneration_credentialsecho block —responses.ts:768hints at one), this strips it silently. Document the assumption inline.CreateMediaBuyHandlerResult = CreateMediaBuyPayload | TaskHandoff<CreateMediaBuyPayload>. WrappingServerPayload<T>insideTaskHandoff<T>is correct — the deferred value is also wire-shaped, so the strip applies once at the framework dispatch boundary. Worth a one-line comment so future readers don't strip-twice.sales.ts:115-116.
Approving on the strength of the spec-pinned write-only contract being met on the v5 path plus the type-level guarantee at the v6 seam. v6 runtime parity is the third drift-cleanup ticket in this area — file it before the next adopter ships invoice_recipient: { bank: db.row.bank } from a JS handler.
There was a problem hiding this comment.
LGTM. Fixes #1988 with the right shape — named *Payload / *HandlerResult aliases that drop the protocol envelope so adopter helper layers wrapping Result<Payload, E> stop being forced to hand-stamp status: TaskStatus. The bonus hardening — recursive write-only stripping in ServerPayload<T> plus the raw v5 builders — is the right shape too: the spec calls these MUST-NOT-echo fields (core.generated.ts:704,997,1123), so stripping is contract enforcement, not translation.
Things I checked
- The new
StripWriteOnlyResponseFields<T>insrc/lib/types/server-payload.ts:43-55only collides with twobank?:sites across the entire generated graph (core.generated.ts:1074,tools.generated.ts:8157) — bothBusinessEntity. Same forauthentication?:— onlyNotificationConfigis reachable from response shapes; the required-authenticationsites (ReportingWebhook,artifact_webhook,push_notification_config) are request-side or already envelope-stripped. The optionality widening onauthentication?:is therefore harmless today. - Runtime strip helpers in
src/lib/server/responses.ts:160-206walkbilling_entity,invoice_recipient, and nestedaccount.*correctly. Control flow onstripMediaBuyWriteOnlyFields:188-196traced — early-returns the bank-stripped result whenaccountis absent; no silent data loss. - The
// @ts-expect-errorregression alarms indecisioning.type-checks.ts:529,534,558,563andscripts/check-adopter-types.ts:228,233are robust — if the write-only fields come back, the access compiles, the directive becomes unused, andtsc --noEmitfails loudly. - Tests in
test/server-responses.test.js:164-194,273-283,309-344andtest/lib/account-notification-config-projection.test.jsassert behavior ('bank' in result.structuredContent…), not implementation. Right shape. - Changeset exists at
.changeset/fix-server-handler-payload-aliases.md.
Follow-ups (non-blocking — file as issues)
-
v6 framework dispatch path leaks
invoice_recipient.bankand nestedaccount.*write-only fields.createAdcpServerFromPlatform'screateMediaBuy/updateMediaBuy/getMediaBuys/listCreatives/syncCreativesdispatch throughprojectSync(fn, r => r)atsrc/lib/server/decisioning/runtime/from-platform.ts:3651,3745,3796,3873,3928.projectSynconly stripsctx_metadata+implementation_config(from-platform.ts:2289-2292). An adopter who spreads a DB row (oras-casts) into the platform return ships IBAN to the buyer wire and the idempotency replay cache. The type strip catches strictly-typed returns; loose paths bypass it. Pre-existing — this PR doesn't introduce the regression — but the natural follow-up is atoWireMediaBuyprojector mirroringtoWireAccount(account.ts:804-882), reusing the new helpers at the wire-emit boundary. Same shapetoWireSyncGovernanceRowalready uses. -
listCreativesResponse/syncCreativesResponsev5 raw builders also leak.ListCreativesResponse.creatives[].account(tools.generated.ts:13609) andSyncCreativesSuccess.creatives[].account(tools.generated.ts:13945) carry the samebilling_entity.bank+notification_configs[].authentication.credentialssurfaces stripped onlistAccountsResponse/getMediaBuysResponse. Builders atresponses.ts:462,570passdatathrough verbatim. One-line extension:stripCreativesWriteOnlyFieldsmirroringstripMediaBuysWriteOnlyFields. Squarely in this PR's stated scope; landed for the highest-traffic paths but missed these two. -
Changeset
patchvsminor.ServerPayload<T>is a public exported type and now stripsbank/credentialsfrom nested objects. Any adopter currently typingServerPayload<ListAccountsResponse>['accounts'][n]['billing_entity']['bank']gets a fresh type error. Defensible as a bug fix aligning the type with the wire's MUST-NOT-echo contract, butminoris the more honest bump for the public-type surface change. Alternatively, call the type narrowing out explicitly in the changeset body so it surfaces in release notes. -
Handler-result alias contract asymmetry.
CreateMediaBuyHandlerResult/SyncCreativesHandlerResultcarryPayload | TaskHandoff<Payload>(sales.ts:116-117) — real semantic content.ReportUsageHandlerResult = ReportUsagePayloadandGetAccountFinancialsHandlerResult = GetAccountFinancialsSuccessPayload(account.ts:58-59) are trivial 1:1 re-exports. IfreportUsageever gains handoff support, adopters who annotated handlers asReportUsagePayloadwould silently keep compiling against a wider return type. Either add a one-line comment noting the trivial-alias status, or unify by giving every handler thePayload | TaskHandoff<Payload>shape on day one.
Minor nits (non-blocking)
let strippedis never reassigned atsrc/lib/server/responses.ts:188—const. Control flow checked; correct.- Document the
StripBusinessEntityBank<T>assumption atsrc/lib/types/server-payload.ts:41. Today onlyBusinessEntityhasbank?:; future schema additions could collide. Either tighten toT extends BusinessEntityLikeor drop a one-line comment alongsideStripAuthenticationCredentialsexplaining the codegen-audit invariant. - Note the
authentication?:optionality widening rationale alongsideStripAuthenticationCredentialsatserver-payload.ts:35-39. Safe today (no response type has a requiredauthenticationblock), but the audit is non-obvious and worth pinning in source.
Safe to merge. Follow-up issues for items 1 and 2 above should land before the next release if any v5 raw-handler adopters remain on listCreatives / syncCreatives, and item 1 (v6 framework gap) is the bigger spec-compliance win and worth its own PR soon.
Summary
*Payloadand*HandlerResultaliases across decisioning surfaces and re-export them from@adcp/sdk/server.Result<Payload, E>,TaskHandoff, account handler aliases, broad alias exports, and write-only field stripping.Root Cause
The previous tests covered direct
SalesPlatformpayload returns, but not adopter helper layers that annotate results asResult<GeneratedWireResponse, E>. Those generated response types include protocol task-envelope fields such asstatus: TaskStatus, so adopter helpers were forced to return envelope fields that the SDK owns and stamps later.During expert review, we also found that widening public payload aliases needed to stay aligned with the write-only response contract for nested webhook credentials and bank details. This PR fixes both the type surface and the raw response-builder runtime projection.
Expert Review
authentication.credentialsandBusinessEntity.bankexposure risk in public aliases; fixed inServerPayload<T>and guarded by type tests.Validation
npm run typechecknpm run build:libnode --test test/server-responses.test.js test/lib/account-notification-config-projection.test.jsnpm run check:adopter-typesgit diff --checktypecheck+build:libFixes #1988