-
Notifications
You must be signed in to change notification settings - Fork 110
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add support for persisted operations (#249)
Add support to upload persisted operations using the CLI to the controlplane, saving them in S3 storage. From the router, add support for retrieving persisted operations from the Cosmo CDN when the client sends a request indicating a PO should be used. Persisted operations are for now scoped to the org/federated graph/client-id and can never be deleted. Additionally, persisted operation keys are always derived using the sha256 hash of the operation body. This allow us to use a content-addressable URL scheme that can be cached forever. Co-authored-by: JivusAyrus <suvijsurya76@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com>
- Loading branch information
1 parent
e50d00f
commit a9ad47f
Showing
79 changed files
with
6,764 additions
and
168 deletions.
There are no files selected for viewing
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
name: CDN CI | ||
on: | ||
pull_request: | ||
paths: | ||
- cdn-server/**/* | ||
- .github/workflows/cdn-ci.yaml | ||
|
||
concurrency: | ||
group: ${{github.workflow}}-${{github.head_ref}} | ||
cancel-in-progress: true | ||
|
||
env: | ||
CI: true | ||
|
||
jobs: | ||
build_test: | ||
timeout-minutes: 10 | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v3 | ||
with: | ||
fetch-depth: 0 | ||
|
||
- uses: ./.github/actions/node | ||
|
||
- name: Install dependencies | ||
run: pnpm install --frozen-lockfile | ||
|
||
- name: Build | ||
run: pnpm run --filter ./cdn-server --filter ./cdn-server/cdn build | ||
|
||
- name: Lint | ||
run: pnpm run --filter ./cdn-server --filter ./cdn-server/cdn lint | ||
|
||
- name: Test | ||
run: pnpm run --filter ./cdn-server/cdn test |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
{ | ||
"extends": ["eslint-config-unjs"], | ||
"ignorePatterns": ["dist"], | ||
"rules": { | ||
"space-before-function-paren": 0, | ||
"arrow-parens": 0, | ||
"comma-dangle": 0, | ||
"semi": 0, | ||
"unicorn/prevent-abbreviations": 0, | ||
"quotes": 0, | ||
"keyword-spacing": 0, | ||
"no-undef": 0, | ||
"indent": 0, | ||
"import/named": 0, | ||
"unicorn/catch-error-name": 0, | ||
"unicorn/no-null": 0, | ||
"unicorn/no-useless-undefined": 0, | ||
"unicorn/no-await-expression-member": 0, | ||
"unicorn/no-array-push-push": 0, | ||
"unicorn/filename-case": 0, | ||
"@typescript-eslint/no-unused-vars": 0, | ||
"@typescript-eslint/no-non-null-assertion": 0, | ||
"unicorn/expiring-todo-comments": 0, | ||
"no-unexpected-multiline": 0, | ||
"no-useless-constructor": 0, | ||
"unicorn/prefer-ternary": 0 | ||
} | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
/node_modules | ||
/dist | ||
/.eslintcache |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
{ | ||
"extends": ["eslint-config-unjs"], | ||
"ignorePatterns": ["dist"], | ||
"rules": { | ||
"space-before-function-paren": 0, | ||
"arrow-parens": 0, | ||
"comma-dangle": 0, | ||
"semi": 0, | ||
"unicorn/prevent-abbreviations": 0, | ||
"quotes": 0, | ||
"keyword-spacing": 0, | ||
"no-undef": 0, | ||
"indent": 0, | ||
"import/named": 0, | ||
"unicorn/catch-error-name": 0, | ||
"unicorn/no-null": 0, | ||
"unicorn/no-useless-undefined": 0, | ||
"unicorn/no-await-expression-member": 0, | ||
"unicorn/no-array-push-push": 0, | ||
"unicorn/filename-case": 0, | ||
"@typescript-eslint/no-unused-vars": 0, | ||
"@typescript-eslint/no-non-null-assertion": 0, | ||
"unicorn/expiring-todo-comments": 0, | ||
"no-unexpected-multiline": 0, | ||
"no-useless-constructor": 0, | ||
"unicorn/prefer-ternary": 0 | ||
} | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
/node_modules | ||
/dist | ||
/.eslintcache |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
{ | ||
"name": "@wundergraph/cosmo-cdn", | ||
"version": "1.0.0", | ||
"author": { | ||
"name": "WunderGraph Maintainers", | ||
"email": "info@wundergraph.com" | ||
}, | ||
"license": "Apache-2.0", | ||
"scripts": { | ||
"dev": "tsc --watch", | ||
"build": "del dist && tsc", | ||
"test:watch": "vitest test", | ||
"test": "vitest run", | ||
"lint": "eslint --cache && prettier -c src test", | ||
"format:fix": "prettier --write -c src test" | ||
}, | ||
"main": "./dist/index.js", | ||
"types": "./dist/index.d.ts", | ||
"files": [ | ||
"dist" | ||
], | ||
"dependencies": { | ||
"hono": "^3.10.0", | ||
"jose": "^4.15.2" | ||
}, | ||
"devDependencies": { | ||
"eslint": "^8.53.0", | ||
"eslint-config-unjs": "^0.2.1", | ||
"vitest": "^0.34.6" | ||
} | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import { JWTVerifyResult, jwtVerify } from 'jose'; | ||
import { Context, Env, Hono, Next, Schema } from 'hono'; | ||
|
||
export interface BlobStorage { | ||
getObject(context: Context, key: string): Promise<ReadableStream>; | ||
} | ||
|
||
export class BlobNotFoundError extends Error { | ||
constructor(message: string, cause?: Error) { | ||
super(message, cause); | ||
Object.setPrototypeOf(this, BlobNotFoundError.prototype); | ||
} | ||
} | ||
|
||
interface CdnOptions { | ||
authJwtSecret: string | ((c: Context) => string); | ||
blobStorage: BlobStorage; | ||
} | ||
|
||
declare module 'hono' { | ||
interface ContextVariableMap { | ||
authenticatedOrganizationId: string; | ||
authenticatedFederatedGraphId: string; | ||
} | ||
} | ||
|
||
const jwtMiddleware = (secret: string | ((c: Context) => string)) => { | ||
return async (c: Context, next: Next) => { | ||
const authHeader = c.req.header('Authorization'); | ||
if (!authHeader) { | ||
return c.text('Unauthorized', 401); | ||
} | ||
const [type, token] = authHeader.split(' '); | ||
if (type !== 'Bearer' || !token) { | ||
return c.text('Unauthorized', 401); | ||
} | ||
let result: JWTVerifyResult; | ||
const secretKey = new TextEncoder().encode(typeof secret === 'function' ? secret(c) : secret); | ||
try { | ||
result = await jwtVerify(token, secretKey); | ||
} catch (e: any) { | ||
if (e instanceof Error && (e.name === 'JWSSignatureVerificationFailed' || e.name === 'JWSInvalid')) { | ||
return c.text('Forbidden', 403); | ||
} | ||
throw e; | ||
} | ||
const organizationId = result.payload.organization_id; | ||
const federatedGraphId = result.payload.federated_graph_id; | ||
if (!organizationId || !federatedGraphId) { | ||
return c.text('Forbidden', 403); | ||
} | ||
c.set('authenticatedOrganizationId', organizationId); | ||
c.set('authenticatedFederatedGraphId', federatedGraphId); | ||
await next(); | ||
}; | ||
}; | ||
|
||
const persistedOperation = (storage: BlobStorage) => { | ||
return async (c: Context) => { | ||
const organizationId = c.req.param('organization_id'); | ||
const federatedGraphId = c.req.param('federated_graph_id'); | ||
// Check authentication | ||
if ( | ||
organizationId !== c.get('authenticatedOrganizationId') || | ||
federatedGraphId !== c.get('authenticatedFederatedGraphId') | ||
) { | ||
return c.text('Forbidden', 403); | ||
} | ||
const clientId = c.req.param('client_id'); | ||
const operation = c.req.param('operation'); | ||
if (!operation.endsWith('.json')) { | ||
return c.notFound(); | ||
} | ||
const key = `${organizationId}/${federatedGraphId}/operations/${clientId}/${operation}`; | ||
let operationStream: ReadableStream; | ||
try { | ||
operationStream = await storage.getObject(c, key); | ||
} catch (e: any) { | ||
if (e instanceof Error && e.constructor.name === 'BlobNotFoundError') { | ||
return c.notFound(); | ||
} | ||
throw e; | ||
} | ||
return c.stream(async (stream) => { | ||
await stream.pipe(operationStream); | ||
await stream.close(); | ||
}); | ||
}; | ||
}; | ||
|
||
// 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>, | ||
opts: CdnOptions, | ||
) => { | ||
const operations = '/:organization_id/:federated_graph_id/operations/:client_id/:operation{.+\\.json$}'; | ||
hono.use(operations, jwtMiddleware(opts.authJwtSecret)); | ||
|
||
hono.get(operations, persistedOperation(opts.blobStorage)); | ||
}; |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { SignJWT } from 'jose'; | ||
import { describe, test, expect } from 'vitest'; | ||
import { Context, Hono } from 'hono'; | ||
import { BlobStorage, BlobNotFoundError, cdn } from '../dist'; | ||
|
||
const secretKey = 'hunter2'; | ||
|
||
const generateToken = async (organizationId: string, federatedGraphId: string, secret: string) => { | ||
const secretKey = new TextEncoder().encode(secret); | ||
return await new SignJWT({ organization_id: organizationId, federated_graph_id: federatedGraphId }) | ||
.setProtectedHeader({ alg: 'HS256' }) | ||
.sign(secretKey); | ||
}; | ||
|
||
class InMemoryBlobStorage implements BlobStorage { | ||
objects: Map<string, Buffer> = new Map(); | ||
getObject(context: Context, key: string): Promise<ReadableStream> { | ||
const obj = this.objects.get(key); | ||
if (!obj) { | ||
return Promise.reject(new BlobNotFoundError(`Object with key ${key} not found`)); | ||
} | ||
const stream = new ReadableStream({ | ||
start(controller) { | ||
controller.enqueue(obj); | ||
controller.close(); | ||
}, | ||
}); | ||
return Promise.resolve(stream); | ||
} | ||
} | ||
|
||
describe('Test JWT authentication', async () => { | ||
const federatedGraphId = 'federatedGraphId'; | ||
const organizationId = 'organizationId'; | ||
const token = await generateToken(organizationId, federatedGraphId, secretKey); | ||
const blobStorage = new InMemoryBlobStorage(); | ||
|
||
const requestPath = `/${organizationId}/${federatedGraphId}/operations/clientName/operation.json`; | ||
|
||
const app = new Hono(); | ||
|
||
cdn(app, { | ||
authJwtSecret: secretKey, | ||
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 403 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(403); | ||
}); | ||
|
||
test('it authenticates the request when a valid Authorization header is provided', async () => { | ||
const res = await app.request(requestPath, { | ||
method: 'GET', | ||
headers: { | ||
Authorization: `Bearer ${token}`, | ||
}, | ||
}); | ||
expect(res.status).toBe(404); | ||
}); | ||
}); | ||
|
||
describe('Test persisted operations handler', async () => { | ||
const federatedGraphId = 'federatedGraphId'; | ||
const organizationId = 'organizationId'; | ||
const token = await generateToken(organizationId, federatedGraphId, secretKey); | ||
const blobStorage = new InMemoryBlobStorage(); | ||
const clientName = 'clientName'; | ||
const operationHash = 'operationHash'; | ||
const operationContents = JSON.stringify({ version: 1, body: 'query { hello }' }); | ||
|
||
blobStorage.objects.set( | ||
`${organizationId}/${federatedGraphId}/operations/${clientName}/${operationHash}.json`, | ||
Buffer.from(operationContents), | ||
); | ||
|
||
const app = new Hono(); | ||
|
||
cdn(app, { | ||
authJwtSecret: secretKey, | ||
blobStorage, | ||
}); | ||
|
||
test('it returns a persisted operation', async () => { | ||
const res = await app.request( | ||
`/${organizationId}/${federatedGraphId}/operations/${clientName}/${operationHash}.json`, | ||
{ | ||
method: 'GET', | ||
headers: { | ||
Authorization: `Bearer ${token}`, | ||
}, | ||
}, | ||
); | ||
expect(res.status).toBe(200); | ||
expect(await res.text()).toBe(operationContents); | ||
}); | ||
|
||
test('it returns a 404 if the persisted operation does not exist', async () => { | ||
const res = await app.request( | ||
`/${organizationId}/${federatedGraphId}/operations/${clientName}/does_not_exist.json`, | ||
{ | ||
method: 'GET', | ||
headers: { | ||
Authorization: `Bearer ${token}`, | ||
}, | ||
}, | ||
); | ||
expect(res.status).toBe(404); | ||
}); | ||
}); |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"extends": "../../tsconfig.base.json", | ||
"compilerOptions": { | ||
"declaration": true, | ||
"outDir": "./dist", | ||
"module": "commonjs", | ||
}, | ||
"include": ["src/**/*"], | ||
"exclude": ["node_modules"] | ||
} | ||
|
Oops, something went wrong.