Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2ddf667
feat: implement subgraph check extensions
wilsonrivera Oct 9, 2025
56798a1
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Oct 10, 2025
1a35949
chore: fix issues found during demo
wilsonrivera Oct 13, 2025
004b305
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Oct 13, 2025
d324cba
feat: improve stability
wilsonrivera Oct 16, 2025
4d3a54f
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Oct 16, 2025
21f9e8c
chore: linting
wilsonrivera Oct 16, 2025
dfdb657
chore: linting
wilsonrivera Oct 16, 2025
f73ac60
chore: fix and add tests
wilsonrivera Oct 16, 2025
6c83fb4
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Oct 21, 2025
cf377c2
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Oct 27, 2025
9313b37
chore: add validation for check extension response
wilsonrivera Oct 27, 2025
2e612a6
Merge branch 'refs/heads/main' into wilson/eng-6214-subgraph-check-ex…
wilsonrivera Oct 31, 2025
924f496
chore: update generated code
wilsonrivera Nov 1, 2025
d9f2b63
chore: linting
wilsonrivera Nov 1, 2025
b03665e
chore: improve presentation
wilsonrivera Nov 4, 2025
c5b4024
chore: add response validation
wilsonrivera Nov 4, 2025
0137536
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 21, 2025
cd72fa8
chore: add response limit
wilsonrivera Nov 21, 2025
21e5ebe
chore: avoid providing an empty file
wilsonrivera Nov 25, 2025
41f84e2
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 25, 2025
2cbae39
chore: update database migrations
wilsonrivera Nov 25, 2025
833335d
chore: improve validation, wording and some small fixes
wilsonrivera Nov 26, 2025
c3eadda
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 26, 2025
c0e0ab9
chore: generate files
wilsonrivera Nov 26, 2025
df29209
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 26, 2025
b7a4a57
chore: rename state variable
wilsonrivera Nov 26, 2025
fa4b3c1
chore: add tests for CDN handler
wilsonrivera Nov 26, 2025
f891eb4
chore: improve validation
wilsonrivera Nov 26, 2025
9e9d360
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 26, 2025
f184957
chore: fix cdn tests
wilsonrivera Nov 26, 2025
b6784b5
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 26, 2025
5388ef3
chore: fix some spelling mistakes
wilsonrivera Nov 26, 2025
265d2de
chore: update documentation link
wilsonrivera Nov 26, 2025
83b06ae
chore: update documentation link
wilsonrivera Nov 26, 2025
15cb00a
Merge branch 'main' into wilson/eng-6214-subgraph-check-extensions
wilsonrivera Nov 28, 2025
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
47 changes: 45 additions & 2 deletions cdn-server/cdn/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ const jwtMiddleware = (secret: string | ((c: Context) => string)) => {

const organizationId = result.payload.organization_id;
const federatedGraphId = result.payload.federated_graph_id;
if (!organizationId || !federatedGraphId) {
if (!organizationId) {
return c.text('Unauthorized - Malformed token', 403);
}
c.set('authenticatedOrganizationId', organizationId as string);
c.set('authenticatedFederatedGraphId', federatedGraphId as string);

if (federatedGraphId) {
// Only assign the federated graph id when it was provided
c.set('authenticatedFederatedGraphId', federatedGraphId as string);
}

await next();
};
Expand Down Expand Up @@ -258,6 +262,40 @@ const cacheOperations = (storage: BlobStorage) => {
};
};

const subgraphChecks = (storage: BlobStorage) => {
return async (c: Context) => {
const organizationId = c.get('authenticatedOrganizationId');

if (organizationId !== c.req.param('organization_id')) {
return c.text('Bad Request', 400);
}

const uniqueId = c.req.param('uniqueid');
if (!uniqueId.endsWith('.json')) {
return c.notFound();
}

const key = `${organizationId}/subgraph_checks/${uniqueId}`;
let blobObject: BlobObject;

try {
blobObject = await storage.getObject({ context: c, key, cacheControl: 'no-cache' });
} catch (e: any) {
if (e instanceof BlobNotFoundError) {
return c.notFound();
}
throw e;
}

c.header('Content-Type', 'application/json; charset=UTF-8');
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');

return stream(c, async (stream) => {
await stream.pipe(blobObject.stream);
});
};
};

// eslint-disable-next-line @typescript-eslint/ban-types
export const cdn = <E extends Env, S extends Schema = {}, BasePath extends string = '/'>(
hono: Hono<E, S, BasePath>,
Expand Down Expand Up @@ -286,4 +324,9 @@ export const cdn = <E extends Env, S extends Schema = {}, BasePath extends strin
hono
.use(cacheOperationsPath, jwtMiddleware(opts.authJwtSecret))
.get(cacheOperationsPath, cacheOperations(opts.blobStorage));

const subgraphChecksPath = '/:organization_id/subgraph_checks/:uniqueid{.+\\.json$}';
hono
.use(subgraphChecksPath, jwtMiddleware(opts.authJwtSecret))
.get(subgraphChecksPath, subgraphChecks(opts.blobStorage));
};
101 changes: 96 additions & 5 deletions cdn-server/cdn/test/cdn.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { SignJWT } from 'jose';
import { describe, test, expect } from 'vitest';
import { Context, Hono } from 'hono';
Expand All @@ -6,7 +7,7 @@ import { BlobStorage, BlobNotFoundError, cdn, BlobObject, signatureSha256Header
const secretKey = 'hunter2';
const secretAdmissionKey = 'hunter3';

const generateToken = async (organizationId: string, federatedGraphId: string, secret: string) => {
const generateToken = async (organizationId: string, federatedGraphId: string | undefined, secret: string) => {
const secretKey = new TextEncoder().encode(secret);
return await new SignJWT({ organization_id: organizationId, federated_graph_id: federatedGraphId })
.setProtectedHeader({ alg: 'HS256' })
Expand Down Expand Up @@ -85,7 +86,7 @@ describe('CDN handlers', () => {
expect(res.status).toBe(404);
});

test('it returns a 401 if the graph or organization ids does not match with the JWT payload', async () => {
test('it returns a 400 if the graph or organization ids does not match with the JWT payload', async () => {
Comment thread
wilsonrivera marked this conversation as resolved.
const res = await app.request(`/foo/bar/operations/clientName/operation.json`, {
method: 'GET',
headers: {
Expand Down Expand Up @@ -146,7 +147,7 @@ describe('CDN handlers', () => {
expect(res.status).toBe(401);
});

test('it returns a 401 if the graph or organization ids does not match with the JWT payload', async () => {
test('it returns a 400 if the graph or organization ids does not match with the JWT payload', async () => {
const res = await app.request(`/foo/bar/operations/routerconfigs/latest.json`, {
method: 'GET',
headers: {
Expand Down Expand Up @@ -207,7 +208,7 @@ describe('CDN handlers', () => {
expect(res.status).toBe(401);
});

test('it returns a 401 if the graph or organization ids does not match with the JWT payload', async () => {
test('it returns a 400 if the graph or organization ids does not match with the JWT payload', async () => {
const res = await app.request(`/foo/bar/operations/routerconfigs/draft.json`, {
method: 'GET',
headers: {
Expand Down Expand Up @@ -488,7 +489,7 @@ describe('CDN handlers', () => {
expect(res.status).toBe(401);
});

test('it returns a 401 if the graph or organization ids does not match with the JWT payload', async () => {
test('it returns a 400 if the graph or organization ids does not match with the JWT payload', async () => {
const res = await app.request(`/foo/bar/operations/cache_warmup/operations.json`, {
method: 'GET',
headers: {
Expand Down Expand Up @@ -552,4 +553,94 @@ describe('CDN handlers', () => {
expect(res.status).toBe(404);
});
});

describe('schema check extensions handler', async () => {
const organizationId = 'organizationId';
const checkId = randomUUID();
const token = await generateToken(organizationId, undefined, secretKey);
const blobStorage = new InMemoryBlobStorage();
const requestPath = `${organizationId}/subgraph_checks/${checkId}.json`;

const app = new Hono();

cdn(app, {
authJwtSecret: secretKey,
authAdmissionJwtSecret: secretAdmissionKey,
blobStorage,
});

test('it returns a 401 if no Authorization header is provided', async () => {
const res = await app.request(requestPath, {
method: 'GET',
});
expect(res.status).toBe(401);
});

test('it returns a 401 if an invalid Authorization header is provided', async () => {
const res = await app.request(requestPath, {
method: 'GET',
headers: {
Authorization: `Bearer ${token.slice(0, -1)}}`,
},
});
expect(res.status).toBe(401);
});

test('it returns a 400 if the organization id does not match with the JWT payload', async () => {
const res = await app.request(`/foo/subgraph_checks/operations.json`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(res.status).toBe(400);
});

test('it returns a 401 if the token has expired', async () => {
const token = await new SignJWT({
organization_id: organizationId,
federated_graph_id: undefined,
exp: Math.floor(Date.now() / 1000) - 60,
})
.setProtectedHeader({ alg: 'HS256' })
.sign(new TextEncoder().encode(secretKey));
const res = await app.request(`/foo/subgraph_checks/operations.json`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(res.status).toBe(401);
});
Comment thread
wilsonrivera marked this conversation as resolved.

test('it returns the schema check extension file content', async () => {
const operationContents = JSON.stringify({
subgraphs: [{ id: '123', name: 'test' }],
compositions: [],
});

blobStorage.objects.set(requestPath, {
buffer: Buffer.from(operationContents),
});

const res = await app.request(requestPath, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(res.status).toBe(200);
expect(await res.text()).toBe(operationContents);
});

test('it returns a 404 if the schema check extension does not exist', async () => {
const res = await app.request(`${organizationId}/subgraph_checks/does_not_exist.json`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(res.status).toBe(404);
});
});
});
8 changes: 7 additions & 1 deletion cli/src/handle-check-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export const handleCheckResult = (resp: CheckSubgraphSchemaResponse) => {
resp.lintErrors.length === 0 &&
resp.lintWarnings.length === 0 &&
resp.graphPruneErrors.length === 0 &&
resp.graphPruneWarnings.length === 0
resp.graphPruneWarnings.length === 0 &&
(resp.isCheckExtensionSkipped ?? true)
) {
console.log(
`\nDetected no changes.\nDetected no lint issues.\nDetected no graph pruning issues.\n\n${studioCheckDestination}\n`,
Expand Down Expand Up @@ -243,6 +244,11 @@ export const handleCheckResult = (resp: CheckSubgraphSchemaResponse) => {
success = false;
}

if (resp.checkExtensionErrorMessage) {
success = false;
finalStatement += `\n${logSymbols.error} Subgraph extension check failed with message: ${resp.checkExtensionErrorMessage}`;
}

if (success) {
console.log(
'\n' +
Expand Down
Loading
Loading