From 3ed627a195b88d9628277564ea65e19d422d0fe3 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 1 Dec 2025 15:24:56 +0100 Subject: [PATCH 1/3] feat(cdn): add versioned artifact endpoints --- .changeset/two-baboons-tie.md | 11 + .../tests/api/artifacts-cdn.spec.ts | 356 ++++++++++++++++++ .../providers/artifact-storage-writer.ts | 61 ++- .../schema/providers/schema-manager.ts | 2 +- .../schema/providers/schema-publisher.ts | 15 +- .../src/modules/shared/providers/storage.ts | 4 +- packages/services/cdn-worker/src/analytics.ts | 2 +- .../cdn-worker/src/artifact-handler.ts | 141 +++++++ .../cdn-worker/src/artifact-storage-reader.ts | 9 +- packages/services/storage/src/index.ts | 4 +- 10 files changed, 590 insertions(+), 15 deletions(-) create mode 100644 .changeset/two-baboons-tie.md diff --git a/.changeset/two-baboons-tie.md b/.changeset/two-baboons-tie.md new file mode 100644 index 00000000000..10794b712cc --- /dev/null +++ b/.changeset/two-baboons-tie.md @@ -0,0 +1,11 @@ +--- +'hive': minor +--- + +Add support for retrieving CDN artifacts by version ID. + +New CDN endpoints allow fetching schema artifacts for a specific version: +- `/artifacts/v1/:targetId/version/:versionId/:artifactType` +- `/artifacts/v1/:targetId/version/:versionId/contracts/:contractName/:artifactType` + +Artifacts are now written to both the latest path and a versioned path during schema publish, enabling retrieval of historical versions. diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index 837bf088cc4..c3d88777ce2 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -69,6 +69,15 @@ function buildEndpointUrl( return `${baseUrl}${targetId}/${resourceType}`; } +function buildVersionedEndpointUrl( + baseUrl: string, + targetId: string, + versionId: string, + resourceType: 'sdl' | 'supergraph' | 'services' | 'metadata', +) { + return `${baseUrl}${targetId}/version/${versionId}/${resourceType}`; +} + function generateLegacyToken(targetId: string) { const encoder = new TextEncoder(); return ( @@ -425,6 +434,353 @@ function runArtifactsCDNTests( await server.stop(); } }); + + test.concurrent('access versioned SDL artifact with valid credentials', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test latest endpoint + const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const latestResponse = await fetch(latestUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + expect(latestResponse.status).toBe(200); + const latestBody = await latestResponse.text(); + + // Test versioned endpoint + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + const versionedBody = await versionedResponse.text(); + + // Both should return the same content + expect(versionedBody).toBe(latestBody); + expect(versionedBody).toMatchInlineSnapshot(` + type Query { + ping: String + } + `); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/sdl`, + ); + expect(versionedArtifact.body).toBe(latestBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + test.concurrent( + 'versioned artifact returns 404 for non-existent version', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Use a non-existent but valid UUID + const nonExistentVersionId = '00000000-0000-0000-0000-000000000000'; + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + nonExistentVersionId, + 'sdl', + ); + + const response = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(response.status).toBe(404); + }, + ); + + test.concurrent( + 'versioned artifact returns 404 for invalid UUID format', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Use an invalid UUID format + const invalidVersionId = 'not-a-valid-uuid'; + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + invalidVersionId, + 'sdl', + ); + + const response = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(response.status).toBe(404); + }, + ); + + test.concurrent('access versioned federation supergraph artifact', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned supergraph endpoint + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'supergraph', + ); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + const supergraphBody = await versionedResponse.text(); + expect(supergraphBody).toContain('schema'); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/supergraph`, + ); + expect(versionedArtifact.body).toBe(supergraphBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + test.concurrent('access versioned federation services artifact', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned services endpoint + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'services', + ); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + expect(versionedResponse.headers.get('content-type')).toContain('application/json'); + const servicesBody = await versionedResponse.text(); + expect(servicesBody).toMatchInlineSnapshot( + '[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]', + ); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/services`, + ); + expect(versionedArtifact.body).toBe(servicesBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + test.concurrent('versioned artifact access without credentials', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, target } = await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const endpointBaseUrl = await getBaseEndpoint(); + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + + // Request without credentials + const response = await fetch(versionedUrl, { method: 'GET' }); + expect(response.status).toBe(400); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(await response.json()).toEqual({ + code: 'MISSING_AUTH_KEY', + error: 'Hive CDN authentication key is missing', + description: + 'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ', + }); + }); + + test.concurrent('versioned artifact access with invalid credentials', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, target } = await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const endpointBaseUrl = await getBaseEndpoint(); + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + + // Request with invalid credentials + const response = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': 'invalid-key', + }, + }); + expect(response.status).toBe(403); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(await response.json()).toEqual({ + code: 'INVALID_AUTH_KEY', + error: + 'Hive CDN authentication key is invalid, or it does not match the requested target ID.', + description: + 'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ', + }); + }); }); } diff --git a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts index fce17b7a754..2ce3f4b67b1 100644 --- a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts +++ b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts @@ -41,6 +41,7 @@ export class ArtifactStorageWriter { 'hive.target.id': args.targetId, 'hive.artifact.type': args.artifactType, 'hive.contract.name': args.contractName || '', + 'hive.version.id': args.versionId || '', }), }) async writeArtifact(args: { @@ -48,25 +49,75 @@ export class ArtifactStorageWriter { artifactType: keyof typeof artifactMeta; artifact: unknown; contractName: null | string; + versionId?: string | null; }) { - const key = buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName); + const latestKey = buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName); + const versionedKey = args.versionId + ? buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName, args.versionId) + : null; const meta = artifactMeta[args.artifactType]; + const body = meta.preprocessor(args.artifact); for (const s3 of this.s3Mirrors) { - const result = await s3.client.fetch([s3.endpoint, s3.bucket, key].join('/'), { + this.logger.debug( + 'Writing artifact to S3 (targetId=%s, artifactType=%s, contractName=%s, versionId=%s, latestKey=%s, versionedKey=%s)', + args.targetId, + args.artifactType, + args.contractName, + args.versionId, + latestKey, + versionedKey, + ); + + // Write versioned key first (if versionId provided) + // This order ensures that if versioned write fails, "latest" still points to the previous version + if (versionedKey) { + const versionedResult = await s3.client.fetch( + [s3.endpoint, s3.bucket, versionedKey].join('/'), + { + method: 'PUT', + headers: { + 'content-type': meta.contentType, + }, + body, + aws: { + signQuery: true, + }, + }, + ); + + if (versionedResult.statusCode !== 200) { + throw new Error( + `Unexpected status code ${versionedResult.statusCode} when writing versioned artifact (targetId=${args.targetId}, artifactType=${args.artifactType}, versionId=${args.versionId}, key=${versionedKey})`, + ); + } + } + + // Write to latest key (always) - only after versioned succeeds + const latestResult = await s3.client.fetch([s3.endpoint, s3.bucket, latestKey].join('/'), { method: 'PUT', headers: { 'content-type': meta.contentType, }, - body: meta.preprocessor(args.artifact), + body, aws: { // This boolean makes Google Cloud Storage & AWS happy. signQuery: true, }, }); - if (result.statusCode !== 200) { - throw new Error(`Unexpected status code ${result.statusCode} when writing artifact.`); + if (latestResult.statusCode !== 200) { + this.logger.error( + 'Failed to write latest artifact after versioned succeeded (targetId=%s, artifactType=%s, versionId=%s, versionedKey=%s written, latestKey=%s failed)', + args.targetId, + args.artifactType, + args.versionId, + versionedKey, + latestKey, + ); + throw new Error( + `Unexpected status code ${latestResult.statusCode} when writing latest artifact (targetId=${args.targetId}, artifactType=${args.artifactType}, contractName=${args.contractName}, key=${latestKey}). Note: versioned artifact was already written.`, + ); } } } diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index 45c4b40c506..b6f4fcdc7e9 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -399,7 +399,7 @@ export class SchemaManager { base_schema: string | null; metadata: string | null; projectType: ProjectType; - actionFn(): Promise; + actionFn(versionId: string): Promise; changes: Array; coordinatesDiff: SchemaCoordinatesDiffResult | null; previousSchemaVersion: string | null; diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 3e4f7d45b1c..e07df152861 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -1430,7 +1430,7 @@ export class SchemaPublisher { schemaMetadata: null, metadataAttributes: null, }), - actionFn: async () => { + actionFn: async (versionId: string) => { if (deleteResult.state.composable) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of deleteResult.state.contracts ?? []) { @@ -1451,6 +1451,7 @@ export class SchemaPublisher { // pass all schemas except the one we are deleting schemas: deleteResult.state.schemas, contracts, + versionId, }); } }, @@ -1945,7 +1946,7 @@ export class SchemaPublisher { metadata: input.metadata ?? null, projectType: project.type, github, - actionFn: async () => { + actionFn: async (versionId: string) => { if (composable && fullSchemaSdl) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of publishState.contracts ?? []) { @@ -1965,6 +1966,7 @@ export class SchemaPublisher { fullSchemaSdl, schemas, contracts, + versionId, }); } }, @@ -2244,6 +2246,7 @@ export class SchemaPublisher { fullSchemaSdl, schemas, contracts, + versionId, }: { target: Target; project: Project; @@ -2251,6 +2254,7 @@ export class SchemaPublisher { fullSchemaSdl: string; schemas: readonly Schema[]; contracts: null | Array<{ name: string; supergraph: string; sdl: string }>; + versionId: string; }) { const publishMetadata = async () => { const metadata: Array> = []; @@ -2268,6 +2272,7 @@ export class SchemaPublisher { artifact: project.type === ProjectType.SINGLE ? metadata[0] : metadata, artifactType: 'metadata', contractName: null, + versionId, }); } }; @@ -2285,12 +2290,14 @@ export class SchemaPublisher { url: s.service_url, })), contractName: null, + versionId, }), this.artifactStorageWriter.writeArtifact({ targetId: target.id, artifactType: 'sdl', artifact: fullSchemaSdl, contractName: null, + versionId, }), ]); }; @@ -2301,6 +2308,7 @@ export class SchemaPublisher { artifactType: 'sdl', artifact: fullSchemaSdl, contractName: null, + versionId, }); }; @@ -2319,6 +2327,7 @@ export class SchemaPublisher { artifactType: 'supergraph', artifact: supergraph, contractName: null, + versionId, }), ); } @@ -2333,12 +2342,14 @@ export class SchemaPublisher { artifactType: 'sdl', artifact: contract.sdl, contractName: contract.name, + versionId, }), this.artifactStorageWriter.writeArtifact({ targetId: target.id, artifactType: 'supergraph', artifact: contract.supergraph, contractName: contract.name, + versionId, }), ); } diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 31eacb8016d..cc797a072c6 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -410,7 +410,7 @@ export interface Storage { _: { serviceName: string; composable: boolean; - actionFn(): Promise; + actionFn(versionId: string): Promise; changes: Array | null; diffSchemaVersionId: string | null; conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata; @@ -451,7 +451,7 @@ export interface Storage { commit: string; logIds: string[]; base_schema: string | null; - actionFn(): Promise; + actionFn(versionId: string): Promise; changes: Array; previousSchemaVersion: null | string; diffSchemaVersionId: null | string; diff --git a/packages/services/cdn-worker/src/analytics.ts b/packages/services/cdn-worker/src/analytics.ts index ba21f6e343b..e638b4ef27f 100644 --- a/packages/services/cdn-worker/src/analytics.ts +++ b/packages/services/cdn-worker/src/analytics.ts @@ -7,7 +7,7 @@ export type Analytics = ReturnType; type Event = | { type: 'artifact'; - version: 'v0' | 'v1'; + version: 'v0' | 'v1' | 'v1-versioned'; value: | 'schema' | 'supergraph' diff --git a/packages/services/cdn-worker/src/artifact-handler.ts b/packages/services/cdn-worker/src/artifact-handler.ts index 1eeaa6aadf3..81e6f363f25 100644 --- a/packages/services/cdn-worker/src/artifact-handler.ts +++ b/packages/services/cdn-worker/src/artifact-handler.ts @@ -52,6 +52,23 @@ const ParamsModel = zod.object({ .transform(value => value ?? null), }); +const VersionedParamsModel = zod.object({ + targetId: zod.string(), + versionId: zod.string().uuid(), + artifactType: zod.union([ + zod.literal('metadata'), + zod.literal('sdl'), + zod.literal('sdl.graphql'), + zod.literal('sdl.graphqls'), + zod.literal('services'), + zod.literal('supergraph'), + ]), + contractName: zod + .string() + .optional() + .transform(value => value ?? null), +}); + const PersistedOperationParamsModel = zod.object({ targetId: zod.string(), appName: zod.string(), @@ -218,6 +235,130 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { } } + async function handlerV1Versioned(request: itty.IRequest & Request) { + const parseResult = VersionedParamsModel.safeParse(request.params); + + if (parseResult.success === false) { + analytics.track( + { type: 'error', value: ['invalid-params'] }, + request.params?.targetId ?? 'unknown', + ); + return createResponse( + analytics, + 'Not found.', + { + status: 404, + }, + request.params?.targetId ?? 'unknown', + request, + ); + } + + const params = parseResult.data; + + breadcrumb( + `Artifact v1 versioned handler (type=${params.artifactType}, targetId=${params.targetId}, versionId=${params.versionId}, contractName=${params.contractName})`, + ); + + const maybeResponse = await authenticate(request, params.targetId); + + if (maybeResponse !== null) { + return maybeResponse; + } + + analytics.track( + { type: 'artifact', value: params.artifactType, version: 'v1-versioned' }, + params.targetId, + ); + + const eTag = request.headers.get('if-none-match'); + + const result = await deps.artifactStorageReader.readArtifact( + params.targetId, + params.contractName, + params.artifactType, + eTag, + params.versionId, + ); + + if (result.type === 'notModified') { + return createResponse( + analytics, + null, + { + status: 304, + }, + params.targetId, + request, + ); + } + + if (result.type === 'notFound') { + return createResponse(analytics, 'Not found.', { status: 404 }, params.targetId, request); + } + + if (result.type === 'response') { + const etag = result.headers.get('etag'); + const text = result.body; + + if (params.artifactType === 'metadata') { + // Legacy handling for SINGLE project metadata (same as handlerV1) + // Metadata in SINGLE projects is only Mesh's Metadata, and it always defines _schema + const isMeshArtifact = text.includes(`"#/definitions/_schema"`); + const hasTopLevelArray = text.startsWith('[') && text.endsWith(']'); + + // Mesh's Metadata shared by Mesh is always an object. + // The top-level array was caused #3291 and fixed now, but we still need to handle the old data. + if (isMeshArtifact && hasTopLevelArray) { + return createResponse( + analytics, + text.substring(1, text.length - 1), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=31536000, immutable', + ...(etag ? { etag } : {}), + }, + }, + params.targetId, + request, + ); + } + } + + return createResponse( + analytics, + text, + { + status: 200, + headers: { + 'Content-Type': + params.artifactType === 'metadata' || params.artifactType === 'services' + ? 'application/json' + : 'text/plain', + // Versioned artifacts are immutable - aggressive caching + 'Cache-Control': 'public, max-age=31536000, immutable', + ...(etag ? { etag } : {}), + }, + }, + params.targetId, + request, + ); + } + + // Exhaustive check - should never reach here + result satisfies never; + } + + // Versioned artifact routes (must be before non-versioned routes) + router.get( + '/artifacts/v1/:targetId/version/:versionId/contracts/:contractName/:artifactType', + handlerV1Versioned, + ); + router.get('/artifacts/v1/:targetId/version/:versionId/:artifactType', handlerV1Versioned); + + // Non-versioned artifact routes (latest) router.get('/artifacts/v1/:targetId/contracts/:contractName/:artifactType', handlerV1); router.get('/artifacts/v1/:targetId/:artifactType', handlerV1); router.get( diff --git a/packages/services/cdn-worker/src/artifact-storage-reader.ts b/packages/services/cdn-worker/src/artifact-storage-reader.ts index ddb14f0a42e..51bfcb64df6 100644 --- a/packages/services/cdn-worker/src/artifact-storage-reader.ts +++ b/packages/services/cdn-worker/src/artifact-storage-reader.ts @@ -7,8 +7,12 @@ export function buildArtifactStorageKey( targetId: string, artifactType: string, contractName: null | string, + versionId?: string | null, ) { const parts = ['artifact', targetId]; + if (versionId) { + parts.push('version', versionId); + } if (contractName) { parts.push('contracts', contractName); } @@ -232,16 +236,17 @@ export class ArtifactStorageReader { contractName: string | null, artifactType: ArtifactsType, etagValue: string | null, + versionId?: string | null, ) { if (artifactType.startsWith('sdl')) { artifactType = 'sdl'; } this.breadcrumb( - `Reading artifact (targetId=${targetId}, artifactType=${artifactType}, contractName=${contractName})`, + `Reading artifact (targetId=${targetId}, artifactType=${artifactType}, contractName=${contractName}${versionId ? `, versionId=${versionId}` : ''})`, ); - const key = buildArtifactStorageKey(targetId, artifactType, contractName); + const key = buildArtifactStorageKey(targetId, artifactType, contractName, versionId); this.breadcrumb(`Reading artifact from S3 key: ${key}`); diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index a71a38fcd90..dc2a5aeb3c1 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -2509,7 +2509,7 @@ export async function createStorage( }); } - await args.actionFn(); + await args.actionFn(newVersion.id); return { kind: 'composite', @@ -2616,7 +2616,7 @@ export async function createStorage( }); } - await input.actionFn(); + await input.actionFn(version.id); return { version, From a0b2172f9d2647fd5f6acefc48498037fba716f3 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Tue, 2 Dec 2025 21:19:05 +0100 Subject: [PATCH 2/3] include schema version id as a header for responses --- .../tests/api/artifacts-cdn.spec.ts | 449 ++++++++++++++++++ .../providers/artifact-storage-writer.ts | 14 +- .../cdn-worker/src/artifact-handler.ts | 10 + 3 files changed, 472 insertions(+), 1 deletion(-) diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index c3d88777ce2..0150b2848c0 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -78,6 +78,31 @@ function buildVersionedEndpointUrl( return `${baseUrl}${targetId}/version/${versionId}/${resourceType}`; } +function buildVersionedContractEndpointUrl( + baseUrl: string, + targetId: string, + versionId: string, + contractName: string, + resourceType: 'sdl' | 'supergraph', +) { + return `${baseUrl}${targetId}/version/${versionId}/contracts/${contractName}/${resourceType}`; +} + +const CreateContractMutation = graphql(` + mutation CreateContractMutationCDN($input: CreateContractInput!) { + createContract(input: $input) { + ok { + createdContract { + id + } + } + error { + message + } + } + } +`); + function generateLegacyToken(targetId: string) { const encoder = new TextEncoder(); return ( @@ -710,6 +735,69 @@ function runArtifactsCDNTests( ); }); + test.concurrent('access versioned federation metadata artifact', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema with metadata + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + metadata: JSON.stringify({ version: '1.0' }), + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned metadata endpoint + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'metadata', + ); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + expect(versionedResponse.headers.get('content-type')).toContain('application/json'); + const metadataBody = await versionedResponse.text(); + // Federation metadata contains the metadata we published + expect(metadataBody).toContain('version'); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/metadata`, + ); + expect(versionedArtifact.body).toBe(metadataBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(versionedResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }); + test.concurrent('versioned artifact access without credentials', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject } = await createOrg(); @@ -781,6 +869,367 @@ function runArtifactsCDNTests( 'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ', }); }); + + test.concurrent('CDN response includes x-hive-schema-version-id header', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test latest endpoint returns x-hive-schema-version-id header + const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const latestResponse = await fetch(latestUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + expect(latestResponse.status).toBe(200); + expect(latestResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test versioned endpoint also returns x-hive-schema-version-id header + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + expect(versionedResponse.status).toBe(200); + expect(versionedResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }); + + test.concurrent('versioned artifact with if-none-match returns 304', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // First request to get ETag + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + const firstResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(firstResponse.status).toBe(200); + const etag = firstResponse.headers.get('etag'); + expect(etag).toBeDefined(); + + // Second request with If-None-Match should return 304 + const secondResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + 'if-none-match': etag!, + }, + }); + + expect(secondResponse.status).toBe(304); + }); + + test.concurrent( + 'versioned artifact remains immutable after new schema publish', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish V1 schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v1', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const v1Version = await writeToken.fetchLatestValidSchema(); + const v1VersionId = v1Version.latestValidVersion?.id; + expect(v1VersionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Fetch V1 content + const v1Url = buildVersionedEndpointUrl(endpointBaseUrl, target.id, v1VersionId!, 'sdl'); + const v1Response = await fetch(v1Url, { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(v1Response.status).toBe(200); + const v1Content = await v1Response.text(); + expect(v1Content).toContain('ping'); + + // Publish V2 schema with different content + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v2', + sdl: `type Query { ping: String, pong: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const v2Version = await writeToken.fetchLatestValidSchema(); + const v2VersionId = v2Version.latestValidVersion?.id; + expect(v2VersionId).toBeDefined(); + expect(v2VersionId).not.toBe(v1VersionId); + + // Verify V1 versioned endpoint still returns original content + const v1ResponseAfterV2 = await fetch(v1Url, { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(v1ResponseAfterV2.status).toBe(200); + const v1ContentAfterV2 = await v1ResponseAfterV2.text(); + expect(v1ContentAfterV2).toBe(v1Content); + expect(v1ContentAfterV2).not.toContain('pong'); + + // Verify latest endpoint returns V2 content + const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const latestResponse = await fetch(latestUrl, { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(latestResponse.status).toBe(200); + const latestContent = await latestResponse.text(); + expect(latestContent).toContain('pong'); + + // Verify headers point to correct versions + expect(v1ResponseAfterV2.headers.get('x-hive-schema-version-id')).toBe(v1VersionId); + expect(latestResponse.headers.get('x-hive-schema-version-id')).toBe(v2VersionId); + }, + ); + + test.concurrent('x-hive-schema-version-id header on all artifact types', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Federation schema with metadata (required for metadata artifact) + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + metadata: JSON.stringify({ version: '1.0' }), + }) + .then(r => r.expectNoGraphQLErrors()); + + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test SDL artifact + const sdlResponse = await fetch(buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'), { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(sdlResponse.status).toBe(200); + expect(sdlResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test services artifact + const servicesResponse = await fetch( + buildEndpointUrl(endpointBaseUrl, target.id, 'services'), + { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }, + ); + expect(servicesResponse.status).toBe(200); + expect(servicesResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test supergraph artifact + const supergraphResponse = await fetch( + buildEndpointUrl(endpointBaseUrl, target.id, 'supergraph'), + { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }, + ); + expect(supergraphResponse.status).toBe(200); + expect(supergraphResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test metadata artifact + const metadataResponse = await fetch( + buildEndpointUrl(endpointBaseUrl, target.id, 'metadata'), + { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }, + ); + expect(metadataResponse.status).toBe(200); + expect(metadataResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }); + + test.concurrent( + 'access versioned contract artifact with valid credentials', + async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target, setNativeFederation } = + await createProject(ProjectType.Federation); + await setFeatureFlag('compareToPreviousComposableVersion', true); + await setNativeFederation(true); + + const writeToken = await createTargetAccessToken({}); + + // Publish initial schema with @tag directive + await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type Query { + hello: String + helloHidden: String @tag(name: "internal") + } + `, + service: 'hello', + url: 'http://hello.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Create a contract + const createContractResult = await execute({ + document: CreateContractMutation, + variables: { + input: { + target: { byId: target.id }, + contractName: 'my-contract', + removeUnreachableTypesFromPublicApiSchema: true, + includeTags: ['internal'], + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + expect(createContractResult.createContract.error).toBeNull(); + + // Publish schema again to generate contract artifacts + await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type Query { + hello: String + helloHidden: String @tag(name: "internal") + } + `, + service: 'hello', + url: 'http://hello.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned contract SDL endpoint + const versionedContractSdlUrl = buildVersionedContractEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'my-contract', + 'sdl', + ); + const contractSdlResponse = await fetch(versionedContractSdlUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(contractSdlResponse.status).toBe(200); + const contractSdlBody = await contractSdlResponse.text(); + expect(contractSdlBody).toContain('helloHidden'); + expect(contractSdlResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(contractSdlResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test versioned contract supergraph endpoint + const versionedContractSupergraphUrl = buildVersionedContractEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'my-contract', + 'supergraph', + ); + const contractSupergraphResponse = await fetch(versionedContractSupergraphUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(contractSupergraphResponse.status).toBe(200); + expect(contractSupergraphResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(contractSupergraphResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }, + ); }); } diff --git a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts index 2ce3f4b67b1..bddd9c2fb5f 100644 --- a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts +++ b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts @@ -71,13 +71,15 @@ export class ArtifactStorageWriter { // Write versioned key first (if versionId provided) // This order ensures that if versioned write fails, "latest" still points to the previous version - if (versionedKey) { + if (versionedKey && args.versionId) { const versionedResult = await s3.client.fetch( [s3.endpoint, s3.bucket, versionedKey].join('/'), { method: 'PUT', headers: { 'content-type': meta.contentType, + // Store version ID as S3 object metadata for CDN response headers + 'x-amz-meta-x-hive-schema-version-id': args.versionId, }, body, aws: { @@ -87,6 +89,14 @@ export class ArtifactStorageWriter { ); if (versionedResult.statusCode !== 200) { + this.logger.error( + 'Failed to write versioned artifact (targetId=%s, artifactType=%s, versionId=%s, key=%s, statusCode=%s)', + args.targetId, + args.artifactType, + args.versionId, + versionedKey, + versionedResult.statusCode, + ); throw new Error( `Unexpected status code ${versionedResult.statusCode} when writing versioned artifact (targetId=${args.targetId}, artifactType=${args.artifactType}, versionId=${args.versionId}, key=${versionedKey})`, ); @@ -98,6 +108,8 @@ export class ArtifactStorageWriter { method: 'PUT', headers: { 'content-type': meta.contentType, + // Store version ID as S3 object metadata for CDN response headers + ...(args.versionId ? { 'x-amz-meta-x-hive-schema-version-id': args.versionId } : {}), }, body, aws: { diff --git a/packages/services/cdn-worker/src/artifact-handler.ts b/packages/services/cdn-worker/src/artifact-handler.ts index 81e6f363f25..5e22940ef72 100644 --- a/packages/services/cdn-worker/src/artifact-handler.ts +++ b/packages/services/cdn-worker/src/artifact-handler.ts @@ -181,6 +181,8 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { if (result.type === 'response') { const etag = result.headers.get('etag'); + // S3/R2 returns custom metadata with x-amz-meta- prefix + const schemaVersionId = result.headers.get('x-amz-meta-x-hive-schema-version-id'); const text = result.body; if (params.artifactType === 'metadata') { @@ -208,6 +210,7 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { headers: { 'Content-Type': 'application/json', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, @@ -227,12 +230,15 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { ? 'application/json' : 'text/plain', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, request, ); } + + result satisfies never; } async function handlerV1Versioned(request: itty.IRequest & Request) { @@ -299,6 +305,8 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { if (result.type === 'response') { const etag = result.headers.get('etag'); + // S3/R2 returns custom metadata with x-amz-meta- prefix + const schemaVersionId = result.headers.get('x-amz-meta-x-hive-schema-version-id'); const text = result.body; if (params.artifactType === 'metadata') { @@ -319,6 +327,7 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=31536000, immutable', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, @@ -340,6 +349,7 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { // Versioned artifacts are immutable - aggressive caching 'Cache-Control': 'public, max-age=31536000, immutable', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, From 59ec6e10f5bcf548c7a448f22a479ae5ce79b621 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Tue, 9 Dec 2025 22:19:50 +0100 Subject: [PATCH 3/3] add header change to changeset --- .changeset/two-baboons-tie.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/two-baboons-tie.md b/.changeset/two-baboons-tie.md index 10794b712cc..546a2aa3e24 100644 --- a/.changeset/two-baboons-tie.md +++ b/.changeset/two-baboons-tie.md @@ -9,3 +9,5 @@ New CDN endpoints allow fetching schema artifacts for a specific version: - `/artifacts/v1/:targetId/version/:versionId/contracts/:contractName/:artifactType` Artifacts are now written to both the latest path and a versioned path during schema publish, enabling retrieval of historical versions. + +CDN artifact responses now include the `x-hive-schema-version-id` header, providing the version ID of the schema being served.