Skip to content

Commit

Permalink
feat: add support for persisted operations (#249)
Browse files Browse the repository at this point in the history
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
3 people authored Nov 22, 2023
1 parent e50d00f commit a9ad47f
Show file tree
Hide file tree
Showing 79 changed files with 6,764 additions and 168 deletions.
36 changes: 36 additions & 0 deletions .github/workflows/cdn-ci.yaml
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
28 changes: 28 additions & 0 deletions cdn-server/.eslintrc
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
}
}
3 changes: 3 additions & 0 deletions cdn-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/node_modules
/dist
/.eslintcache
28 changes: 28 additions & 0 deletions cdn-server/cdn/.eslintrc
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
}
}
3 changes: 3 additions & 0 deletions cdn-server/cdn/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/node_modules
/dist
/.eslintcache
31 changes: 31 additions & 0 deletions cdn-server/cdn/package.json
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"
}
}
100 changes: 100 additions & 0 deletions cdn-server/cdn/src/index.ts
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));
};
122 changes: 122 additions & 0 deletions cdn-server/cdn/test/cdn.test.ts
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);
});
});
11 changes: 11 additions & 0 deletions cdn-server/cdn/tsconfig.json
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"]
}

Loading

0 comments on commit a9ad47f

Please sign in to comment.