Skip to content

Commit

Permalink
feat: CORS origin configuration in backend.yaml
Browse files Browse the repository at this point in the history
  • Loading branch information
Blckbrry-Pi committed May 22, 2024
1 parent b10d158 commit 51c94a6
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 74 deletions.
2 changes: 1 addition & 1 deletion artifacts/project_schema.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"type":"object","properties":{"registries":{"type":"object","additionalProperties":{"$ref":"#/definitions/RegistryConfig"}},"modules":{"type":"object","additionalProperties":{"$ref":"#/definitions/ProjectModuleConfig"}}},"additionalProperties":false,"required":["modules","registries"],"definitions":{"RegistryConfig":{"anyOf":[{"type":"object","properties":{"local":{"$ref":"#/definitions/RegistryConfigLocal"}},"additionalProperties":false,"required":["local"]},{"type":"object","properties":{"git":{"$ref":"#/definitions/RegistryConfigGit"}},"additionalProperties":false,"required":["git"]}]},"RegistryConfigLocal":{"type":"object","properties":{"directory":{"type":"string"},"isExternal":{"description":"If true, this will be treated like an external registry. This is\nimportant if multiple projects are using the same registry locally.\n\n Modules from this directory will not be tested, formatted, linted, and\n generate Prisma migrations.","type":"boolean"}},"additionalProperties":false,"required":["directory"]},"RegistryConfigGit":{"anyOf":[{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"branch":{"type":"string"}},"required":["branch","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"tag":{"type":"string"}},"required":["tag","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"rev":{"type":"string"}},"required":["rev","url"]}]},"RegistryConfigGitUrl":{"description":"The URL to the git repository.\n\nIf both HTTPS and SSH URL are provided, they will both be tried and use the\none that works.","anyOf":[{"type":"object","properties":{"https":{"type":"string"},"ssh":{"type":"string"}},"additionalProperties":false},{"type":"string"}]},"ProjectModuleConfig":{"type":"object","properties":{"registry":{"description":"The name of the registry to fetch the module from.","type":"string"},"module":{"description":"Overrides the name of the module to fetch inside the registry.","type":"string"},"config":{"description":"The config that configures how this module is ran at runtime."}},"additionalProperties":false}},"$schema":"http://json-schema.org/draft-07/schema#"}
{"type":"object","properties":{"registries":{"type":"object","additionalProperties":{"$ref":"#/definitions/RegistryConfig"}},"modules":{"type":"object","additionalProperties":{"$ref":"#/definitions/ProjectModuleConfig"}},"runtime":{"$ref":"#/definitions/RuntimeConfig"}},"additionalProperties":false,"required":["modules","registries"],"definitions":{"RegistryConfig":{"anyOf":[{"type":"object","properties":{"local":{"$ref":"#/definitions/RegistryConfigLocal"}},"additionalProperties":false,"required":["local"]},{"type":"object","properties":{"git":{"$ref":"#/definitions/RegistryConfigGit"}},"additionalProperties":false,"required":["git"]}]},"RegistryConfigLocal":{"type":"object","properties":{"directory":{"type":"string"},"isExternal":{"description":"If true, this will be treated like an external registry. This is\nimportant if multiple projects are using the same registry locally.\n\n Modules from this directory will not be tested, formatted, linted, and\n generate Prisma migrations.","type":"boolean"}},"additionalProperties":false,"required":["directory"]},"RegistryConfigGit":{"anyOf":[{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"branch":{"type":"string"}},"required":["branch","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"tag":{"type":"string"}},"required":["tag","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"rev":{"type":"string"}},"required":["rev","url"]}]},"RegistryConfigGitUrl":{"description":"The URL to the git repository.\n\nIf both HTTPS and SSH URL are provided, they will both be tried and use the\none that works.","anyOf":[{"type":"object","properties":{"https":{"type":"string"},"ssh":{"type":"string"}},"additionalProperties":false},{"type":"string"}]},"ProjectModuleConfig":{"type":"object","properties":{"registry":{"description":"The name of the registry to fetch the module from.","type":"string"},"module":{"description":"Overrides the name of the module to fetch inside the registry.","type":"string"},"config":{"description":"The config that configures how this module is ran at runtime."}},"additionalProperties":false},"RuntimeConfig":{"type":"object","properties":{"cors":{"description":"If this is null, only requests made from the same origin will be accepted","$ref":"#/definitions/CorsConfig"}},"additionalProperties":false},"CorsConfig":{"type":"object","properties":{"origins":{"description":"The origins that are allowed to make requests to the server.","type":"array","items":{"type":"string"}}},"additionalProperties":false,"required":["origins"]}},"$schema":"http://json-schema.org/draft-07/schema#"}
2 changes: 1 addition & 1 deletion artifacts/runtime_archive.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/build/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export async function generateEntrypoint(project: Project, opts: BuildOpts) {
`;
}

let corsSource = "";
if (project.config.runtime?.cors) {
corsSource = `
cors: {
origins: new Set(${JSON.stringify(project.config.runtime.cors.origins)}),
},
`;
}

// Generate config.ts
const configSource = `
${autoGenHeader()}
Expand All @@ -49,6 +58,7 @@ export async function generateEntrypoint(project: Project, opts: BuildOpts) {
export default {
modules: ${modConfig},
${corsSource}
} as Config;
`;

Expand Down
15 changes: 15 additions & 0 deletions src/config/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InternalError } from "../error/mod.ts";
export interface ProjectConfig extends Record<string, unknown> {
registries: { [name: string]: RegistryConfig };
modules: { [name: string]: ProjectModuleConfig };
runtime?: RuntimeConfig;
}

export type RegistryConfig = { local: RegistryConfigLocal } | { git: RegistryConfigGit };
Expand Down Expand Up @@ -52,6 +53,20 @@ export interface ProjectModuleConfig extends Record<string, unknown> {
config?: any;
}

export interface RuntimeConfig {
/**
* If this is null, only requests made from the same origin will be accepted
*/
cors?: CorsConfig;
}

export interface CorsConfig {
/**
* The origins that are allowed to make requests to the server.
*/
origins: string[];
}

// export async function readConfig(path: string): Promise<ProjectConfig> {
// const configRaw = await Deno.readTextFile(path);
// return parse(configRaw) as ProjectConfig;
Expand Down
66 changes: 66 additions & 0 deletions src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { RegistryCallMap } from "./proxy.ts";

export interface Config {
modules: Record<string, Module>;
cors?: CorsConfig;
}

export interface Module {
Expand All @@ -22,6 +23,10 @@ export interface Module {
userConfig: unknown;
}

export interface CorsConfig {
origins: Set<string>;
}

interface CreatePrismaOutput {
prisma: PrismaClientDummy;
pgPool?: any;
Expand Down Expand Up @@ -127,4 +132,65 @@ export class Runtime<DependenciesSnakeT, DependenciesCamelT> {
},
});
}

/**
* Only runs on a CORS preflight request— returns a response with the
* appropriate CORS headers & status.
*
* @param req The preflight OPTIONS request
* @returns The full response to the preflight request
*/
public corsPreflight(req: Request): Response {
const origin = req.headers.get("Origin");
if (origin) {
const normalizedOrigin = new URL(origin).origin;
if (this.config.cors) {
if (this.config.cors.origins.has(normalizedOrigin)) {
return new Response(undefined, {
status: 204,
headers: {
...this.corsHeaders(req),
"Vary": "Origin",
},
});
}
}
}

// Origin is not allowed/no origin header on preflight
return new Response(undefined, {
status: 403,
headers: {
"Vary": "Origin",
"See": "https://opengb.dev/docs/cors",
},
});
}

public corsHeaders(req: Request): Record<string, string> {
const origin = req.headers.get("Origin");

// Don't set CORS headers if there's no origin (e.g. a server-side
// request)
if (!origin) return {};

// If the origin is allowed, return the appropriate headers.
// Otherwise, return a non-matching cors header (empty object).
if (this.config.cors?.origins.has(origin)) {
return {
"Access-Control-Allow-Origin": new URL(origin).origin,
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
};
} else {
return {};
}
}

public corsAllowed(req: Request): boolean {
const origin = req.headers.get("Origin");

if (!origin) return true;
return this.config.cors?.origins.has(origin) ?? false;
}
}
181 changes: 109 additions & 72 deletions src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,93 +11,130 @@ export function serverHandler<DependenciesSnakeT, DependenciesCamelT>(
): Promise<Response> => {
const url = new URL(req.url);

const matches = MODULE_CALL.exec(url.pathname);
if (req.method == "POST" && matches?.groups) {
// Lookup script
const moduleName = matches.groups.module;
const scriptName = matches.groups.script;
const script = runtime.config.modules[moduleName]?.scripts[scriptName];

if (script?.public) {
// Create context
const ctx = runtime.createRootContext({
httpRequest: {
method: req.method,
path: url.pathname,
remoteAddress: info.remoteAddr.hostname,
headers: Object.fromEntries(req.headers.entries()),
},
});
// Handle CORS preflight
if (req.method === "OPTIONS") {
return runtime.corsPreflight(req);
}

// Parse body
let body;
try {
body = await req.json();
} catch {
const output = {
message: "Request must have a valid JSON body.",
};
return new Response(JSON.stringify(output), {
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// Disallow even simple requests if CORS is not allowed
if (!runtime.corsAllowed(req)) {
return new Response(undefined, {
status: 403,
headers: {
"Vary": "Origin",
...runtime.corsHeaders(req),
},
});
}

try {
// Call module
const output = await ctx.call(
moduleName as any,
scriptName as any,
body,
);
// Only allow POST requests
if (req.method !== "POST") return new Response(undefined, {
status: 405,
headers: {
"Allow": "POST",
...runtime.corsHeaders(req),
},
});

if (output.__tempPleaseSeeOGBE3_NoData) {
return new Response(undefined, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
},
});
}
// Get module and script name
const matches = MODULE_CALL.exec(url.pathname);
if (!matches?.groups) return new Response(
JSON.stringify({
"message": "Route not found. Make sure the URL and method are correct.",
}),
{
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
status: 404,
},
);

return new Response(JSON.stringify(output), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (e) {
// Error response
const output = {
message: e.message,
};

return new Response(JSON.stringify(output), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
}
}
// Lookup script
const moduleName = matches.groups.module;
const scriptName = matches.groups.script;
const script = runtime.config.modules[moduleName]?.scripts[scriptName];

// Not found response
return new Response(
// Confirm script exists and is public
if (!script || !script.public) return new Response(
JSON.stringify({
"message": "Route not found. Make sure the URL and method are correct.",
}),
{
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
status: 404,
},
);

// Create context
const ctx = runtime.createRootContext({
httpRequest: {
method: req.method,
path: url.pathname,
remoteAddress: info.remoteAddr.hostname,
headers: Object.fromEntries(req.headers.entries()),
},
});

// Parse body
let body;
try {
body = await req.json();
} catch {
const output = {
message: "Request must have a valid JSON body.",
};
return new Response(JSON.stringify(output), {
status: 400,
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
});
}

try {
// Call module
const output = await ctx.call(
moduleName as any,
scriptName as any,
body,
);

if (output.__tempPleaseSeeOGBE3_NoData) {
return new Response(undefined, {
status: 204,
headers: {
...runtime.corsHeaders(req),
},
});
}

return new Response(JSON.stringify(output), {
status: 200,
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
});
} catch (e) {
// Error response
const output = {
message: e.message,
};

return new Response(JSON.stringify(output), {
status: 500,
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
});
}
};
}

0 comments on commit 51c94a6

Please sign in to comment.