Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-presentations-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adcp/sdk': patch
---

Expose placement presentation reference and document validators from the package root, publish their types from the root, schemas, and types entrypoints, and document secure digest-pinned validation.
43 changes: 43 additions & 0 deletions docs/ZOD-SCHEMAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,49 @@ async function fetchProducts() {
}
```

### Digest-pinned placement presentations

Placement `presentation_ref` values point to publisher-controlled documents. Validate the reference before fetching, keep the fetch SSRF-safe and credential-free, verify the digest over the exact `response.body` bytes returned by `ssrfSafeFetch`, and only then parse the document with the canonical schema.

```typescript
import { createHash } from 'node:crypto';
import { resolvePreviewAuthority, ssrfSafeFetch } from '@adcp/sdk';
import {
PlacementPresentationDocumentSchema,
PlacementPresentationReferenceSchema,
type PlacementPresentationDocument,
} from '@adcp/sdk/schemas';

async function loadPlacementPresentation(rawRef: unknown): Promise<PlacementPresentationDocument> {
const ref = PlacementPresentationReferenceSchema.parse(rawRef);
const response = await ssrfSafeFetch(ref.uri, {
timeoutMs: 5_000,
maxBodyBytes: 256 * 1024,
});

if (response.status < 200 || response.status >= 300) {
throw new Error(`Presentation fetch failed with HTTP ${response.status}`);
}

const actualDigest = `sha256:${createHash('sha256').update(response.body).digest('hex')}`;
if (actualDigest !== ref.digest) {
throw new Error('Placement presentation digest mismatch');
}

const document = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(response.body));
return PlacementPresentationDocumentSchema.parse(document);
}

const presentation = await loadPlacementPresentation(placement.presentation_ref);
const authority = resolvePreviewAuthority({
targetPlacementId: placement.placement_id,
publisherPresentation: { placementId: placement.placement_id, value: presentation },
manifest,
});
```

`ssrfSafeFetch` rejects private and non-HTTPS targets by default, pins DNS resolution, does not follow redirects, and enforces the supplied timeout and body limit. Do not attach ambient credentials when fetching presentation documents or their image decorations.

### Form Validation

```typescript
Expand Down
9 changes: 9 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,15 @@ export type {
export type { BrandJson, AdagentsJson } from './types/wellknown-schemas.generated';
export { BrandJsonSchema, AdagentsJsonSchema } from './types/wellknown-schemas.generated';

// ====== PLACEMENT PRESENTATION ======
// These boundary validators are explicit root-level exceptions to the general
// @adcp/sdk/schemas-only policy below. Consumers fetch presentation_ref from a
// publisher-controlled URL, so keeping the canonical validation path visible
// alongside ssrfSafeFetch and resolvePreviewAuthority avoids unsupported deep
// imports or locally copied schemas.
export type { PlacementPresentationDocument, PlacementPresentationReference } from './types/core.generated';
export { PlacementPresentationDocumentSchema, PlacementPresentationReferenceSchema } from './types/schemas.generated';

// ====== ERROR CODES ======
// Standard error code vocabulary for programmatic error handling
export type { Error as TaskErrorDetail } from './types/core.generated';
Expand Down
1 change: 1 addition & 0 deletions src/lib/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import { resolveAdcpVersion } from '../utils/adcp-version-config';

export * from '../types/schemas.generated';
export type { PlacementPresentationDocument, PlacementPresentationReference } from '../types/core.generated';

type LooseObjectShapeFor<T extends object> = {
[K in keyof T]-?: undefined extends T[K]
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type {
CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven,
CanonicalFormatVASTVideo,
ExtensionObject,
PlacementPresentationDocument,
PlacementPresentationReference,
} from './core.generated';
import type { FormatReferenceStructuredObject } from './core.generated';
export type { RequireCacheScopeWhenProducts, ServerPayload } from './server-payload';
Expand Down
117 changes: 115 additions & 2 deletions test/lib/public-barrel-exports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
ensureGetProductsCacheScope,
getFormatAssets,
LEGACY_PURCHASE_PUBLICATION_PROOF_RETENTION_MS,
PlacementPresentationDocumentSchema,
PlacementPresentationReferenceSchema,
legacyPurchaseSettlementFingerprint,
resolveTaskState,
type CanonicalFormatParams,
Expand All @@ -34,6 +36,8 @@ import {
type ManagerRevalidationRequest,
type ManagerRevalidationResponse,
type Placement,
type PlacementPresentationDocument,
type PlacementPresentationReference,
type ProductFormatDeclaration,
type ResolvedTaskState,
type SyncCreativesPayload,
Expand All @@ -50,7 +54,13 @@ import {
type CanonicalRef,
type CanonicalReferenceResolutionResult,
} from '@adcp/sdk/v2/format-schema';
import { CreateMediaBuyRequestSchema } from '@adcp/sdk/schemas';
import {
CreateMediaBuyRequestSchema,
PlacementPresentationDocumentSchema as SubpathPlacementPresentationDocumentSchema,
PlacementPresentationReferenceSchema as SubpathPlacementPresentationReferenceSchema,
type PlacementPresentationDocument as SubpathPlacementPresentationDocument,
type PlacementPresentationReference as SubpathPlacementPresentationReference,
} from '@adcp/sdk/schemas';
import {
AuthInvalidError,
AuthMissingError,
Expand All @@ -60,6 +70,8 @@ import {
import type {
ProductFormatDeclaration as TypesProductFormatDeclaration,
Placement as TypesPlacement,
PlacementPresentationDocument as TypesPlacementPresentationDocument,
PlacementPresentationReference as TypesPlacementPresentationReference,
RequireCacheScopeWhenProducts,
} from '@adcp/sdk/types';

Expand All @@ -76,6 +88,16 @@ const built = CanonicalFormat.nativeInFeed(
);
const builtKind: 'native_in_feed' = built.format_kind;
const mediaBuyShape = CreateMediaBuyRequestSchema.shape;
const parsePlacementPresentationDocument = PlacementPresentationDocumentSchema.safeParse;
const parsePlacementPresentationReference = PlacementPresentationReferenceSchema.safeParse;
const sameDocumentSchema = PlacementPresentationDocumentSchema === SubpathPlacementPresentationDocumentSchema;
const sameReferenceSchema = PlacementPresentationReferenceSchema === SubpathPlacementPresentationReferenceSchema;
const acceptsPresentationDocument = (
_document: PlacementPresentationDocument | SubpathPlacementPresentationDocument | TypesPlacementPresentationDocument
) => {};
const acceptsPresentationReference = (
_reference: PlacementPresentationReference | SubpathPlacementPresentationReference | TypesPlacementPresentationReference
) => {};

const formatAssetsInput: FormatAssetsInput = {
assets: [FormatAsset.image({ asset_id: 'hero', required: true })],
Expand Down Expand Up @@ -203,6 +225,12 @@ const acceptsTypesPlacement = (_placement: TypesPlacement) => {};
void typedNative;
void builtKind;
void mediaBuyShape;
void parsePlacementPresentationDocument;
void parsePlacementPresentationReference;
void sameDocumentSchema;
void sameReferenceSchema;
void acceptsPresentationDocument;
void acceptsPresentationReference;
void inspectedAssets;
void serverSyncError;
void authErrors;
Expand Down Expand Up @@ -269,7 +297,92 @@ test('root barrel exports webhook dedup error classes', () => {
assert.ok(new root.WebhookDedupConflictError() instanceof Error);
});

test('schema exports stay behind @adcp/sdk/schemas', () => {
test('placement presentation validators resolve through root and schemas package exports', async () => {
const root = require('@adcp/sdk');
const schemas = require('@adcp/sdk/schemas');

for (const name of ['PlacementPresentationReferenceSchema', 'PlacementPresentationDocumentSchema']) {
assert.strictEqual(typeof root[name]?.safeParse, 'function', `${name} missing from @adcp/sdk`);
assert.strictEqual(typeof schemas[name]?.safeParse, 'function', `${name} missing from @adcp/sdk/schemas`);
assert.strictEqual(root[name], schemas[name], `${name} must be the same instance from both entrypoints`);
}

const esmRoot = await import('@adcp/sdk');
const esmSchemas = await import('@adcp/sdk/schemas');
assert.strictEqual(esmRoot.PlacementPresentationReferenceSchema, esmSchemas.PlacementPresentationReferenceSchema);
assert.strictEqual(esmRoot.PlacementPresentationDocumentSchema, esmSchemas.PlacementPresentationDocumentSchema);
});

test('placement presentation declarations resolve through CJS and ESM package export conditions', () => {
const contextDir = path.resolve(__dirname, '../../.context');
fs.mkdirSync(contextDir, { recursive: true });

const source = `
import {
PlacementPresentationDocumentSchema,
PlacementPresentationReferenceSchema,
type PlacementPresentationDocument,
type PlacementPresentationReference,
} from '@adcp/sdk';
import {
PlacementPresentationDocumentSchema as SubpathDocumentSchema,
PlacementPresentationReferenceSchema as SubpathReferenceSchema,
type PlacementPresentationDocument as SubpathDocument,
type PlacementPresentationReference as SubpathReference,
} from '@adcp/sdk/schemas';
import type {
PlacementPresentationDocument as TypesDocument,
PlacementPresentationReference as TypesReference,
} from '@adcp/sdk/types';

const schemas = [
PlacementPresentationDocumentSchema,
PlacementPresentationReferenceSchema,
SubpathDocumentSchema,
SubpathReferenceSchema,
];
const acceptDocuments = (_value: PlacementPresentationDocument | SubpathDocument | TypesDocument) => {};
const acceptReferences = (_value: PlacementPresentationReference | SubpathReference | TypesReference) => {};
void schemas;
void acceptDocuments;
void acceptReferences;
`;

const sourceFiles = ['placement-presentation-consumer.cts', 'placement-presentation-consumer.mts'];
for (const file of sourceFiles) {
fs.writeFileSync(path.join(contextDir, file), source, 'utf8');
}

const tsconfigPath = path.join(contextDir, 'placement-presentation-exports-tsconfig.json');
fs.writeFileSync(
tsconfigPath,
JSON.stringify(
{
compilerOptions: {
target: 'ES2022',
module: 'NodeNext',
moduleResolution: 'NodeNext',
strict: true,
skipLibCheck: true,
noEmit: true,
},
files: sourceFiles,
},
null,
2
),
'utf8'
);

const result = spawnSync('npx', ['tsc', '-p', tsconfigPath], {
cwd: contextDir,
encoding: 'utf8',
});

assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
});

test('general generated schema exports stay behind @adcp/sdk/schemas', () => {
const root = require('../../dist/lib/index.js');
const types = require('../../dist/lib/types/index.js');
const schemas = require('../../dist/lib/schemas/index.js');
Expand Down
Loading