aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/activity/index.ts3
-rw-r--r--src/index.ts6
-rw-r--r--src/server/index.ts199
-rw-r--r--src/storage/index.ts1
4 files changed, 179 insertions, 30 deletions
diff --git a/src/activity/index.ts b/src/activity/index.ts
index c3ee821..e14507d 100644
--- a/src/activity/index.ts
+++ b/src/activity/index.ts
@@ -381,12 +381,11 @@ export class WebhookActivityImpl implements IWebhookActivity {
}
const baseName = `${storedRequest.timestamp}_${storedRequest.uuid}`;
- const storedPath = `${baseName}/request.json`;
return tReq.move(
Either.right<PenguenoError, WebhookResult>({
success: true,
- stored: storedPath,
+ stored: baseName,
redirect,
}),
);
diff --git a/src/index.ts b/src/index.ts
index cbc946a..0ece985 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,12 +7,13 @@ import { TokenSigner } from './token/index.js';
const main = async (_argv = process.argv.slice(2)): Promise<IEither<Error, void>> => {
const argsResult = argv(
- ['--port', '--host', '--data-dir', '--token-secret'],
+ ['--port', '--host', '--data-dir', '--token-secret', '--cors-origins'],
{
'--port': { absent: 9000, present: (port) => parseInt(port) },
'--host': { absent: '0.0.0.0', present: (host) => host },
'--data-dir': { absent: './data', present: (dir) => dir },
'--token-secret': { absent: undefined, present: (secret) => secret },
+ '--cors-origins': { absent: '*', present: (origins) => origins },
},
_argv,
);
@@ -23,6 +24,7 @@ const main = async (_argv = process.argv.slice(2)): Promise<IEither<Error, void>
host: args['--host'],
dataDir: args['--data-dir'],
tokenSecret: args['--token-secret'],
+ corsOrigins: args['--cors-origins'],
}))
.flatMapAsync(async (config) => {
// Initialize storage
@@ -47,7 +49,7 @@ const main = async (_argv = process.argv.slice(2)): Promise<IEither<Error, void>
console.log(`Starting server on ${config.host}:${config.port}`);
// Create and start server
- const server = new PosthookServer(storage, signer);
+ const server = new PosthookServer(storage, signer, config.corsOrigins);
const hono = new HonoProxy(server);
return hono.serve(config.port, config.host);
diff --git a/src/server/index.ts b/src/server/index.ts
index 0747680..a38b47a 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -8,6 +8,7 @@ import {
type IHealthCheckActivity,
type ITraceable,
PenguenoRequest,
+ PenguenoResponse,
Server,
type ServerTrace,
} from '@emprespresso/pengueno';
@@ -29,10 +30,63 @@ const defaultHealthCheck: HealthChecker = async (input) => {
return Either.right(HealthCheckOutput.YAASSSLAYQUEEN);
};
+type CorsOriginRule =
+ | { kind: 'origin'; origin: string }
+ | { kind: 'host'; host: string; port?: string }
+ | { kind: 'wildcard-host'; suffix: string };
+
+type AllowedCorsOrigins = '*' | CorsOriginRule[];
+
+const parseAllowedCorsOrigins = (raw: string): AllowedCorsOrigins => {
+ const trimmed = raw.trim();
+ if (trimmed === '' || trimmed === '*') {
+ return '*';
+ }
+
+ const rules = trimmed
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ .flatMap<CorsOriginRule>((entry) => {
+ // Full origin (scheme + host [+ port])
+ if (entry.includes('://')) {
+ try {
+ const parsed = new URL(entry);
+ return [{ kind: 'origin', origin: parsed.origin }];
+ } catch {
+ return [];
+ }
+ }
+
+ // Wildcard hostname (matches any subdomain, not apex)
+ if (entry.startsWith('*.')) {
+ const suffix = entry.slice(2).toLowerCase();
+ return suffix.length > 0 ? [{ kind: 'wildcard-host', suffix }] : [];
+ }
+
+ // Hostname (optionally with :port)
+ const hostPortMatch = entry.match(/^(.+?)(?::(\d+))?$/);
+ if (!hostPortMatch) {
+ return [];
+ }
+
+ const host = hostPortMatch[1]!.toLowerCase();
+ const port = hostPortMatch[2];
+ if (host.length === 0) {
+ return [];
+ }
+
+ return [{ kind: 'host', host, port }];
+ });
+
+ return rules.length === 0 ? '*' : rules;
+};
+
export class PosthookServer implements Server {
constructor(
storage: Storage,
signer: TokenSigner,
+ corsOriginsRaw: string = '*',
healthCheck: HealthChecker = defaultHealthCheck,
private readonly healthCheckActivity: IHealthCheckActivity = new HealthCheckActivityImpl(healthCheck),
private readonly registerRouteActivity: IRegisterRouteActivity = new RegisterRouteActivityImpl(storage),
@@ -40,45 +94,140 @@ export class PosthookServer implements Server {
private readonly tokenGenerateActivity: ITokenGenerateActivity = new TokenGenerateActivityImpl(storage, signer),
private readonly listRoutesActivity: IListRoutesActivity = new ListRoutesActivityImpl(storage),
private readonly fourOhFourActivity: IFourOhFourActivity = new FourOhFourActivityImpl(),
+ private readonly corsOrigins: AllowedCorsOrigins = parseAllowedCorsOrigins(corsOriginsRaw),
) {}
- public serve(req: ITraceable<PenguenoRequest, ServerTrace>) {
- const url = new URL(req.get().req.url);
- const { pathname } = url;
+ private corsHeaders(origin: string | undefined): Record<string, string> {
+ if (origin === undefined) {
+ return {};
+ }
- // === Public Routes (/) ===
+ // Special case: allow everything (including http origins).
+ if (this.corsOrigins === '*') {
+ return {
+ 'Access-Control-Allow-Origin': '*',
+ };
+ }
- // Health check endpoint
- if (pathname === '/health') {
- return this.healthCheckActivity.checkHealth(req);
+ let parsed: URL;
+ try {
+ parsed = new URL(origin);
+ } catch {
+ return {};
}
- // Token generation endpoint - /hook/{routeName}/token
- const tokenMatch = pathname.match(/^\/hook\/([^/]+)\/token$/);
- if (tokenMatch && req.get().req.method === 'GET') {
- const routeName = tokenMatch[1];
- return this.tokenGenerateActivity.generateToken(routeName)(req);
+ // If we're restricting origins, only allow https.
+ if (parsed.protocol !== 'https:') {
+ return {};
}
- // Dynamic webhook endpoints - /hook/{routeName}
- const hookMatch = pathname.match(/^\/hook\/([^/]+)$/);
- if (hookMatch && req.get().req.method === 'POST') {
- const routeName = hookMatch[1];
- return this.webhookActivity.processWebhook(routeName)(req);
+ const candidateOrigin = parsed.origin;
+ const hostname = parsed.hostname.toLowerCase();
+ const port = parsed.port;
+
+ const allowed = this.corsOrigins.some((rule) => {
+ if (rule.kind === 'origin') {
+ return candidateOrigin === rule.origin;
+ }
+
+ if (rule.kind === 'host') {
+ if (hostname !== rule.host) {
+ return false;
+ }
+
+ return rule.port === undefined ? true : rule.port === port;
+ }
+
+ // wildcard-host
+ if (hostname === rule.suffix) {
+ return false;
+ }
+
+ return hostname.endsWith(`.${rule.suffix}`);
+ });
+
+ if (!allowed) {
+ return {};
+ }
+
+ return {
+ 'Access-Control-Allow-Origin': candidateOrigin,
+ };
+ }
+
+ private corsPreflightHeaders(requestedHeaders: string | undefined): Record<string, string> {
+ const allowHeaders =
+ requestedHeaders?.trim() ?? 'Content-Type, X-CSRF-Token, H-Captcha-Response, Authorization, Accept, Origin';
+
+ return {
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': allowHeaders,
+ 'Access-Control-Max-Age': '86400',
+ };
+ }
+
+ private applyCorsHeaders(res: PenguenoResponse, headers: Record<string, string>) {
+ for (const [key, value] of Object.entries(headers)) {
+ res.headers[key] = value;
}
+ }
- // === Admin Routes (/admin) - Put behind OAuth proxy ===
+ public async serve(req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse> {
+ const penguenoReq = req.get().req;
+ const method = penguenoReq.method;
+ const requestHeaders = penguenoReq.header();
+ const origin = requestHeaders['origin'];
+ const corsHeaders = this.corsHeaders(origin);
- // Admin endpoints for route management
- if (pathname === '/admin/routes' && req.get().req.method === 'POST') {
- return this.registerRouteActivity.registerRoute(req);
+ // Handle CORS preflight.
+ if (method === 'OPTIONS' && origin !== undefined && Object.keys(corsHeaders).length > 0) {
+ return new PenguenoResponse(req, '', {
+ status: 204,
+ statusText: 'No Content',
+ headers: {
+ ...corsHeaders,
+ ...this.corsPreflightHeaders(requestHeaders['access-control-request-headers']),
+ },
+ });
+ }
+
+ const url = new URL(penguenoReq.url);
+ const { pathname } = url;
+
+ let result: Promise<PenguenoResponse>;
+
+ // === Public Routes (/) ===
+ if (pathname === '/health') {
+ result = this.healthCheckActivity.checkHealth(req);
+ } else {
+ // Token generation endpoint - /hook/{routeName}/token
+ const tokenMatch = pathname.match(/^\/hook\/([^/]+)\/token$/);
+ if (tokenMatch && method === 'GET') {
+ const routeName = tokenMatch[1];
+ result = this.tokenGenerateActivity.generateToken(routeName)(req);
+ } else {
+ // Dynamic webhook endpoints - /hook/{routeName}
+ const hookMatch = pathname.match(/^\/hook\/([^/]+)$/);
+ if (hookMatch && method === 'POST') {
+ const routeName = hookMatch[1];
+ result = this.webhookActivity.processWebhook(routeName)(req);
+ } else if (pathname === '/admin/routes' && method === 'POST') {
+ // === Admin Routes (/admin) - Put behind OAuth proxy ===
+ result = this.registerRouteActivity.registerRoute(req);
+ } else if (pathname === '/admin/routes' && method === 'GET') {
+ result = this.listRoutesActivity.listRoutes(req);
+ } else {
+ // 404 for everything else
+ result = this.fourOhFourActivity.fourOhFour(req);
+ }
+ }
}
- if (pathname === '/admin/routes' && req.get().req.method === 'GET') {
- return this.listRoutesActivity.listRoutes(req);
+ const response = await result;
+ if (Object.keys(corsHeaders).length > 0) {
+ this.applyCorsHeaders(response, corsHeaders);
}
- // 404 for everything else
- return this.fourOhFourActivity.fourOhFour(req);
+ return response;
}
}
diff --git a/src/storage/index.ts b/src/storage/index.ts
index 631fc2e..c3c97d8 100644
--- a/src/storage/index.ts
+++ b/src/storage/index.ts
@@ -152,7 +152,6 @@ export class Storage {
};
await writeFile(join(requestDir, 'request.json'), JSON.stringify(stored, null, 2));
- await writeFile(join(requestDir, 'body.json'), JSON.stringify(body, null, 2));
return Either.right(stored);
} catch (err) {
return Either.left(err instanceof Error ? err : new Error(String(err)));