aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/activity/index.ts438
-rw-r--r--src/index.ts64
-rw-r--r--src/integrations/hcaptcha.ts32
-rw-r--r--src/integrations/ntfy.ts40
-rw-r--r--src/server/index.ts84
-rw-r--r--src/storage/index.ts114
-rw-r--r--src/token/index.ts76
-rw-r--r--src/types/index.ts78
8 files changed, 926 insertions, 0 deletions
diff --git a/src/activity/index.ts b/src/activity/index.ts
new file mode 100644
index 0000000..20d123c
--- /dev/null
+++ b/src/activity/index.ts
@@ -0,0 +1,438 @@
+import {
+ Either,
+ ErrorSource,
+ type IActivity,
+ type IEither,
+ type ITraceable,
+ jsonModel,
+ JsonResponse,
+ LogLevel,
+ LogMetricTraceSupplier,
+ Metric,
+ PenguenoError,
+ PenguenoResponse,
+ type PenguenoRequest,
+ type ServerTrace,
+ TraceUtil,
+} from '@emprespresso/pengueno';
+import type { Storage } from '../storage/index.js';
+import type { RouteConfig } from '../types/index.js';
+import { isRouteConfig, ContentType } from '../types/index.js';
+import { verifyHCaptcha } from '../integrations/hcaptcha.js';
+import { sendNtfyNotification } from '../integrations/ntfy.js';
+import { TokenSigner } from '../token/index.js';
+
+const routeConfigMetric = Metric.fromName('Route.Config').asResult();
+const webhookRequestMetric = Metric.fromName('Webhook.Process').asResult();
+const listRoutesMetric = Metric.fromName('Routes.List').asResult();
+const tokenGenerateMetric = Metric.fromName('Token.Generate').asResult();
+
+export interface IRegisterRouteActivity {
+ registerRoute: IActivity;
+}
+
+export class RegisterRouteActivityImpl implements IRegisterRouteActivity {
+ constructor(private readonly storage: Storage) {}
+
+ private trace(r: ITraceable<PenguenoRequest, ServerTrace>) {
+ return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(routeConfigMetric));
+ }
+
+ public registerRoute(r: ITraceable<PenguenoRequest, ServerTrace>) {
+ const routeConfigTransformer = (j: ITraceable<unknown, ServerTrace>): IEither<PenguenoError, RouteConfig> => {
+ const config = j.get();
+ if (!isRouteConfig(config)) {
+ const err = 'Invalid route configuration';
+ j.trace.traceScope(LogLevel.WARN).trace(err);
+ return Either.left(new PenguenoError(err, 400));
+ }
+ return Either.right(config);
+ };
+
+ return this.trace(r)
+ .map(jsonModel(routeConfigTransformer))
+ .map(async (tEitherConfig) => {
+ const eitherConfig = await tEitherConfig.get();
+ return eitherConfig.flatMapAsync(async (config) => {
+ const eitherStored = await this.storage.registerRoute(config);
+ return eitherStored.mapLeft((e) => new PenguenoError(e.message, 500));
+ });
+ })
+ .flatMapAsync(
+ TraceUtil.promiseify((tEitherStored) => {
+ const errorSource = tEitherStored
+ .get()
+ .left()
+ .map(({ source }) => source)
+ .orSome(() => ErrorSource.SYSTEM)
+ .get();
+ const shouldWarn = errorSource === ErrorSource.USER;
+ return TraceUtil.traceResultingEither<PenguenoError, void, LogMetricTraceSupplier>(
+ routeConfigMetric,
+ shouldWarn,
+ )(tEitherStored);
+ }),
+ )
+ .peek(
+ TraceUtil.promiseify((tResult) =>
+ tResult.get().mapRight(() => tResult.trace.trace('Route registered successfully')),
+ ),
+ )
+ .map(
+ TraceUtil.promiseify((tEitherResult) => {
+ const result = tEitherResult.get().mapRight(() => ({ success: true }));
+ return new JsonResponse(r, result, {
+ status: result.fold(
+ ({ status }) => status,
+ () => 200,
+ ),
+ });
+ }),
+ )
+ .get();
+ }
+}
+
+export interface IWebhookActivity {
+ processWebhook: (routeName: string) => IActivity;
+}
+
+export class WebhookActivityImpl implements IWebhookActivity {
+ constructor(
+ private readonly storage: Storage,
+ private readonly signer: TokenSigner,
+ ) {}
+
+ private trace(r: ITraceable<PenguenoRequest, ServerTrace>) {
+ return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(webhookRequestMetric));
+ }
+
+ private async parseBody(
+ req: PenguenoRequest,
+ contentType: ContentType,
+ ): Promise<IEither<PenguenoError, { body: unknown; redirect: string | undefined; token: string | undefined }>> {
+ try {
+ const rawBody = await req.req.text();
+
+ type ParsedBody = { body: unknown; redirect: string | undefined; token: string | undefined };
+
+ switch (contentType) {
+ case ContentType.JSON:
+ try {
+ return Either.right(<ParsedBody>{
+ body: JSON.parse(rawBody),
+ redirect: undefined,
+ token: undefined,
+ });
+ } catch {
+ return Either.left(new PenguenoError('Invalid JSON', 400));
+ }
+
+ case ContentType.FORM:
+ try {
+ const formData = new URLSearchParams(rawBody);
+ const obj: Record<string, string> = {};
+ let redirect: string | undefined;
+ let token: string | undefined;
+
+ for (const [key, value] of formData.entries()) {
+ if (key === '_redirect') {
+ redirect = value;
+ } else if (key === '_token') {
+ token = value;
+ } else {
+ obj[key] = value;
+ }
+ }
+ return Either.right(<ParsedBody>{ body: obj, redirect, token });
+ } catch {
+ return Either.left(new PenguenoError('Invalid form data', 400));
+ }
+
+ case ContentType.TEXT:
+ return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined });
+
+ case ContentType.RAW:
+ return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined });
+
+ case ContentType.MULTIPART:
+ return Either.left(new PenguenoError('Multipart not yet implemented', 501));
+
+ default:
+ return Either.right(<ParsedBody>{ body: rawBody, redirect: undefined, token: undefined });
+ }
+ } catch (err) {
+ return Either.left(new PenguenoError(err instanceof Error ? err.message : String(err), 500));
+ }
+ }
+
+ public processWebhook(routeName: string) {
+ return (r: ITraceable<PenguenoRequest, ServerTrace>) => {
+ type WebhookResult = { success: true; stored: string; redirect: string | undefined };
+
+ return this.trace(r)
+ .flatMapAsync(async (tReq) => {
+ const route = this.storage.getRoute(routeName);
+ if (!route) {
+ tReq.trace.traceScope(LogLevel.WARN).trace(`Route not found: ${routeName}`);
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(new PenguenoError('Route not found', 404)),
+ );
+ }
+
+ const req = tReq.get().req;
+ const headers = req.header();
+ const query = req.query();
+
+ // Extract hCaptcha token if route is protected
+ if (route.hcaptchaProtected) {
+ const hCaptchaToken = headers['h-captcha-response'] ?? query['h-captcha-response'];
+ if (!hCaptchaToken) {
+ tReq.trace.traceScope(LogLevel.WARN).trace('Missing hCaptcha token');
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(
+ new PenguenoError('Missing hCaptcha token', 400),
+ ),
+ );
+ }
+
+ if (!route.hcaptchaSecret) {
+ tReq.trace.traceScope(LogLevel.ERROR).trace('hCaptcha secret not configured');
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(
+ new PenguenoError('Server misconfiguration', 500),
+ ),
+ );
+ }
+
+ const verifyResult = await verifyHCaptcha(hCaptchaToken, route.hcaptchaSecret);
+ const isValid = verifyResult.fold(
+ () => false,
+ (success) => success,
+ );
+
+ if (!isValid) {
+ tReq.trace.traceScope(LogLevel.WARN).trace('hCaptcha verification failed');
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(
+ new PenguenoError('hCaptcha verification failed', 403),
+ ),
+ );
+ }
+ }
+
+ // Parse body based on content type
+ const bodyResult = await this.parseBody(tReq.get(), route.contentType);
+ if (bodyResult.left().present()) {
+ return tReq.move(Either.left<PenguenoError, WebhookResult>(bodyResult.left().get()));
+ }
+
+ const { body, redirect, token: bodyToken } = bodyResult.right().get();
+
+ // Validate token if required
+ if (route.requireToken) {
+ const csrfToken = bodyToken ?? headers['x-csrf-token'];
+ if (!csrfToken) {
+ tReq.trace.traceScope(LogLevel.WARN).trace('Missing CSRF token');
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(new PenguenoError('Missing CSRF token', 400)),
+ );
+ }
+
+ const validationResult = this.signer.validate(csrfToken, routeName);
+ if (validationResult.left().present()) {
+ const error = validationResult.left().get();
+ tReq.trace.traceScope(LogLevel.WARN).trace(`Token validation failed: ${error.message}`);
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(
+ new PenguenoError('Invalid or expired token', 403),
+ ),
+ );
+ }
+ }
+
+ // Store the request
+ const storeResult = await this.storage.storeRequest(routeName, req.method, headers, body);
+ if (storeResult.left().present()) {
+ return tReq.move(
+ Either.left<PenguenoError, WebhookResult>(
+ new PenguenoError(storeResult.left().get().message, 500),
+ ),
+ );
+ }
+
+ const storedRequest = storeResult.right().get();
+
+ // Send ntfy notification if configured
+ if (route.ntfy?.enabled) {
+ const ntfyResult = await sendNtfyNotification(route.ntfy, storedRequest);
+ if (ntfyResult.left().present()) {
+ const err = ntfyResult.left().get();
+ tReq.trace.traceScope(LogLevel.WARN).trace(`ntfy notification failed: ${err.message}`);
+ } else {
+ tReq.trace.trace('ntfy notification sent');
+ }
+ }
+
+ const filename = `${storedRequest.timestamp}_${storedRequest.uuid}.json`;
+ return tReq.move(
+ Either.right<PenguenoError, WebhookResult>({
+ success: true,
+ stored: filename,
+ redirect,
+ }),
+ );
+ })
+ .flatMapAsync(
+ TraceUtil.promiseify((tEitherResult) => {
+ const errorSource = tEitherResult
+ .get()
+ .left()
+ .map(({ source }) => source)
+ .orSome(() => ErrorSource.SYSTEM)
+ .get();
+ const shouldWarn = errorSource === ErrorSource.USER;
+ return TraceUtil.traceResultingEither<PenguenoError, WebhookResult, LogMetricTraceSupplier>(
+ webhookRequestMetric,
+ shouldWarn,
+ )(tEitherResult);
+ }),
+ )
+ .peek(
+ TraceUtil.promiseify((tResult) =>
+ tResult.get().mapRight(() => tResult.trace.trace('Webhook request processed successfully')),
+ ),
+ )
+ .map(
+ TraceUtil.promiseify((tEitherResult) => {
+ const result = tEitherResult.get();
+
+ // Check if we should redirect
+ const shouldRedirect = result.fold(
+ () => false,
+ (data) => data.redirect !== undefined,
+ );
+
+ if (shouldRedirect) {
+ const redirectUrl = result.right().get().redirect!;
+ return new PenguenoResponse(r, '', {
+ status: 303,
+ statusText: 'See Other',
+ headers: { Location: redirectUrl },
+ });
+ }
+
+ // Return JSON response for non-redirect cases
+ return new JsonResponse(r, result, {
+ status: result.fold(
+ ({ status }) => status,
+ () => 200,
+ ),
+ });
+ }),
+ )
+ .get();
+ };
+ }
+}
+
+export interface IListRoutesActivity {
+ listRoutes: IActivity;
+}
+
+export class ListRoutesActivityImpl implements IListRoutesActivity {
+ constructor(private readonly storage: Storage) {}
+
+ private trace(r: ITraceable<PenguenoRequest, ServerTrace>) {
+ return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(listRoutesMetric));
+ }
+
+ public listRoutes(r: ITraceable<PenguenoRequest, ServerTrace>) {
+ type ListRoutesResult = {
+ routes: Array<{
+ name: string;
+ contentType: ContentType;
+ hcaptchaProtected: boolean;
+ ntfyEnabled: boolean;
+ requireToken: boolean;
+ }>;
+ };
+
+ return this.trace(r)
+ .map((tReq) => {
+ void tReq.get();
+
+ const routes = this.storage.listRoutes();
+ const sanitized = routes.map(({ name, contentType, hcaptchaProtected, ntfy, requireToken }) => ({
+ name,
+ contentType,
+ hcaptchaProtected,
+ ntfyEnabled: ntfy?.enabled || false,
+ requireToken: requireToken || false,
+ }));
+ return Either.right<PenguenoError, ListRoutesResult>({ routes: sanitized });
+ })
+ .peek(
+ TraceUtil.traceResultingEither<PenguenoError, ListRoutesResult, LogMetricTraceSupplier>(
+ listRoutesMetric,
+ ),
+ )
+ .map(
+ async (tEitherResult) =>
+ new JsonResponse(r, tEitherResult.get(), {
+ status: 200,
+ }),
+ )
+ .get();
+ }
+}
+
+export interface ITokenGenerateActivity {
+ generateToken: (routeName: string) => IActivity;
+}
+
+export class TokenGenerateActivityImpl implements ITokenGenerateActivity {
+ constructor(
+ private readonly storage: Storage,
+ private readonly signer: TokenSigner,
+ ) {}
+
+ private trace(r: ITraceable<PenguenoRequest, ServerTrace>) {
+ return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(tokenGenerateMetric));
+ }
+
+ public generateToken(routeName: string) {
+ return (r: ITraceable<PenguenoRequest, ServerTrace>) => {
+ type TokenResult = { token: string; expiresAt: number };
+
+ return this.trace(r)
+ .map((tReq) => {
+ const route = this.storage.getRoute(routeName);
+ if (!route) {
+ tReq.trace.traceScope(LogLevel.WARN).trace(`Route not found: ${routeName}`);
+ return Either.left<PenguenoError, TokenResult>(new PenguenoError('Route not found', 404));
+ }
+
+ const token = this.signer.generate(routeName);
+ const expiresAt = Date.now() + 30 * 1000; // 30 seconds
+
+ return Either.right<PenguenoError, TokenResult>({ token, expiresAt });
+ })
+ .peek(
+ TraceUtil.traceResultingEither<PenguenoError, TokenResult, LogMetricTraceSupplier>(
+ tokenGenerateMetric,
+ ),
+ )
+ .map(
+ async (tEitherResult) =>
+ new JsonResponse(r, tEitherResult.get(), {
+ status: tEitherResult.get().fold(
+ ({ status }) => status,
+ () => 200,
+ ),
+ }),
+ )
+ .get();
+ };
+ }
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..cbc946a
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+
+import { argv, Either, getEnv, type IEither, HonoProxy } from '@emprespresso/pengueno';
+import { PosthookServer } from './server/index.js';
+import { Storage } from './storage/index.js';
+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': { 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 },
+ },
+ _argv,
+ );
+
+ return argsResult
+ .mapRight((args) => ({
+ port: args['--port'],
+ host: args['--host'],
+ dataDir: args['--data-dir'],
+ tokenSecret: args['--token-secret'],
+ }))
+ .flatMapAsync(async (config) => {
+ // Initialize storage
+ const storage = new Storage(config.dataDir);
+ const initResult = await storage.init();
+
+ if (initResult.left().present()) {
+ return Either.left(initResult.left().get());
+ }
+
+ // Initialize token signer (use env var or command line arg or generate random)
+ const envSecret = getEnv('POSTHOOK_TOKEN_SECRET');
+ const secret = config.tokenSecret ?? (envSecret.present() ? envSecret.get() : undefined);
+ const signer = new TokenSigner(secret);
+
+ if (config.tokenSecret === undefined && !envSecret.present()) {
+ console.log('No token secret provided; generated a random one (will not persist across restarts).');
+ console.log('Set POSTHOOK_TOKEN_SECRET or pass --token-secret to use a persistent secret.');
+ }
+
+ console.log(`Storage initialized at: ${config.dataDir}`);
+ console.log(`Starting server on ${config.host}:${config.port}`);
+
+ // Create and start server
+ const server = new PosthookServer(storage, signer);
+ const hono = new HonoProxy(server);
+
+ return hono.serve(config.port, config.host);
+ });
+};
+
+if (process.argv[1] === import.meta.filename) {
+ await main().then((eitherDone) =>
+ eitherDone.mapLeft((err) => {
+ console.error('error:', err);
+ process.exit(1);
+ }),
+ );
+}
diff --git a/src/integrations/hcaptcha.ts b/src/integrations/hcaptcha.ts
new file mode 100644
index 0000000..78ff356
--- /dev/null
+++ b/src/integrations/hcaptcha.ts
@@ -0,0 +1,32 @@
+import { Either, type IEither } from '@emprespresso/pengueno';
+
+export interface HCaptchaResponse {
+ success: boolean;
+ challenge_ts?: string;
+ hostname?: string;
+ 'error-codes'?: string[];
+}
+
+export async function verifyHCaptcha(token: string, secret: string): Promise<IEither<Error, boolean>> {
+ try {
+ const response = await fetch('https://hcaptcha.com/siteverify', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ secret,
+ response: token,
+ }),
+ });
+
+ if (!response.ok) {
+ return Either.left(new Error(`hCaptcha verification failed: ${response.statusText}`));
+ }
+
+ const result = (await response.json()) as HCaptchaResponse;
+ return Either.right(result.success);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+}
diff --git a/src/integrations/ntfy.ts b/src/integrations/ntfy.ts
new file mode 100644
index 0000000..cf815ed
--- /dev/null
+++ b/src/integrations/ntfy.ts
@@ -0,0 +1,40 @@
+import { Either, type IEither } from '@emprespresso/pengueno';
+import type { NtfyConfig, StoredRequest } from '../types/index.js';
+
+export interface NtfyNotification {
+ topic: string;
+ title: string;
+ message: string;
+ tags?: string[];
+ priority?: number;
+}
+
+export async function sendNtfyNotification(config: NtfyConfig, request: StoredRequest): Promise<IEither<Error, void>> {
+ if (!config.enabled || !config.server || !config.topic) {
+ return Either.right(<void>undefined);
+ }
+
+ try {
+ const url = `${config.server}/${config.topic}`;
+ const title = `Webhook received: ${request.routeName}`;
+ const message = `Method: ${request.method}\nTimestamp: ${new Date(request.timestamp).toISOString()}\nUUID: ${request.uuid}`;
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Title: title,
+ Tags: 'webhook,posthook',
+ Priority: '3',
+ },
+ body: message,
+ });
+
+ if (!response.ok) {
+ return Either.left(new Error(`ntfy notification failed: ${response.statusText}`));
+ }
+
+ return Either.right(<void>undefined);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+}
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..0747680
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,84 @@
+import {
+ Either,
+ FourOhFourActivityImpl,
+ HealthCheckActivityImpl,
+ type HealthChecker,
+ HealthCheckOutput,
+ type IFourOhFourActivity,
+ type IHealthCheckActivity,
+ type ITraceable,
+ PenguenoRequest,
+ Server,
+ type ServerTrace,
+} from '@emprespresso/pengueno';
+import { Storage } from '../storage/index.js';
+import {
+ ListRoutesActivityImpl,
+ RegisterRouteActivityImpl,
+ TokenGenerateActivityImpl,
+ WebhookActivityImpl,
+ type IListRoutesActivity,
+ type IRegisterRouteActivity,
+ type ITokenGenerateActivity,
+ type IWebhookActivity,
+} from '../activity/index.js';
+import { TokenSigner } from '../token/index.js';
+
+const defaultHealthCheck: HealthChecker = async (input) => {
+ void input.get();
+ return Either.right(HealthCheckOutput.YAASSSLAYQUEEN);
+};
+
+export class PosthookServer implements Server {
+ constructor(
+ storage: Storage,
+ signer: TokenSigner,
+ healthCheck: HealthChecker = defaultHealthCheck,
+ private readonly healthCheckActivity: IHealthCheckActivity = new HealthCheckActivityImpl(healthCheck),
+ private readonly registerRouteActivity: IRegisterRouteActivity = new RegisterRouteActivityImpl(storage),
+ private readonly webhookActivity: IWebhookActivity = new WebhookActivityImpl(storage, signer),
+ private readonly tokenGenerateActivity: ITokenGenerateActivity = new TokenGenerateActivityImpl(storage, signer),
+ private readonly listRoutesActivity: IListRoutesActivity = new ListRoutesActivityImpl(storage),
+ private readonly fourOhFourActivity: IFourOhFourActivity = new FourOhFourActivityImpl(),
+ ) {}
+
+ public serve(req: ITraceable<PenguenoRequest, ServerTrace>) {
+ const url = new URL(req.get().req.url);
+ const { pathname } = url;
+
+ // === Public Routes (/) ===
+
+ // Health check endpoint
+ if (pathname === '/health') {
+ return this.healthCheckActivity.checkHealth(req);
+ }
+
+ // 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);
+ }
+
+ // 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);
+ }
+
+ // === Admin Routes (/admin) - Put behind OAuth proxy ===
+
+ // Admin endpoints for route management
+ if (pathname === '/admin/routes' && req.get().req.method === 'POST') {
+ return this.registerRouteActivity.registerRoute(req);
+ }
+
+ if (pathname === '/admin/routes' && req.get().req.method === 'GET') {
+ return this.listRoutesActivity.listRoutes(req);
+ }
+
+ // 404 for everything else
+ return this.fourOhFourActivity.fourOhFour(req);
+ }
+}
diff --git a/src/storage/index.ts b/src/storage/index.ts
new file mode 100644
index 0000000..2c8ffb2
--- /dev/null
+++ b/src/storage/index.ts
@@ -0,0 +1,114 @@
+import { randomUUID } from 'crypto';
+import { mkdir, writeFile, readFile } from 'fs/promises';
+import { join } from 'path';
+import { isSafeRouteName, type RouteConfig, type StoredRequest } from '../types/index.js';
+import { Either, type IEither } from '@emprespresso/pengueno';
+
+export class Storage {
+ private routes: Map<string, RouteConfig> = new Map();
+
+ constructor(private readonly dataDir: string = './data') {}
+
+ async init(): Promise<IEither<Error, void>> {
+ try {
+ await mkdir(this.dataDir, { recursive: true });
+ await this.loadRoutes();
+ return Either.right(<void>undefined);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+
+ private async loadRoutes(): Promise<void> {
+ try {
+ const routesPath = join(this.dataDir, 'routes.json');
+ const data = await readFile(routesPath, 'utf-8');
+ const routes = JSON.parse(data) as RouteConfig[];
+ for (const route of routes) {
+ if (!isSafeRouteName(route.name)) {
+ continue;
+ }
+ this.routes.set(route.name, route);
+ }
+ } catch {
+ // routes file doesn't exist yet, that's ok
+ }
+ }
+
+ private async saveRoutes(): Promise<IEither<Error, void>> {
+ try {
+ const routesPath = join(this.dataDir, 'routes.json');
+ const routes = Array.from(this.routes.values());
+ await writeFile(routesPath, JSON.stringify(routes, null, 2));
+ return Either.right(<void>undefined);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+
+ async registerRoute(config: RouteConfig): Promise<IEither<Error, void>> {
+ if (!isSafeRouteName(config.name)) {
+ return Either.left(new Error('Invalid route name'));
+ }
+
+ this.routes.set(config.name, config);
+ const routeDir = join(this.dataDir, config.name);
+ try {
+ await mkdir(routeDir, { recursive: true });
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+ return this.saveRoutes();
+ }
+
+ getRoute(name: string): RouteConfig | undefined {
+ if (!isSafeRouteName(name)) return undefined;
+ return this.routes.get(name);
+ }
+
+ listRoutes(): RouteConfig[] {
+ return Array.from(this.routes.values());
+ }
+
+ async deleteRoute(name: string): Promise<IEither<Error, void>> {
+ if (!isSafeRouteName(name)) {
+ return Either.left(new Error('Invalid route name'));
+ }
+ this.routes.delete(name);
+ return this.saveRoutes();
+ }
+
+ async storeRequest(
+ routeName: string,
+ method: string,
+ headers: Record<string, string>,
+ body: unknown,
+ files?: StoredRequest['files'],
+ ): Promise<IEither<Error, StoredRequest>> {
+ if (!isSafeRouteName(routeName)) {
+ return Either.left(new Error('Invalid route name'));
+ }
+
+ const timestamp = Date.now();
+ const uuid = randomUUID();
+ const filename = `${timestamp}_${uuid}.json`;
+
+ const stored: StoredRequest = {
+ timestamp,
+ uuid,
+ routeName,
+ method,
+ headers,
+ body,
+ files,
+ };
+
+ const filepath = join(this.dataDir, routeName, filename);
+ try {
+ await writeFile(filepath, JSON.stringify(stored, null, 2));
+ return Either.right(stored);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+}
diff --git a/src/token/index.ts b/src/token/index.ts
new file mode 100644
index 0000000..7251714
--- /dev/null
+++ b/src/token/index.ts
@@ -0,0 +1,76 @@
+import { createHmac, randomBytes } from 'crypto';
+import { Either, type IEither } from '@emprespresso/pengueno';
+
+export interface TokenPayload {
+ routeName: string;
+ timestamp: number;
+}
+
+export class TokenSigner {
+ private readonly secret: string;
+ private readonly ttlSeconds: number;
+
+ constructor(secret?: string, ttlSeconds: number = 30) {
+ this.secret = secret || randomBytes(32).toString('hex');
+ this.ttlSeconds = ttlSeconds;
+ }
+
+ generate(routeName: string): string {
+ const timestamp = Date.now();
+ const payload = JSON.stringify({ routeName, timestamp });
+ const signature = this.sign(payload);
+ const token = Buffer.from(`${payload}.${signature}`).toString('base64url');
+ return token;
+ }
+
+ validate(token: string, expectedRoute: string): IEither<Error, TokenPayload> {
+ try {
+ const decoded = Buffer.from(token, 'base64url').toString('utf-8');
+ const lastDotIndex = decoded.lastIndexOf('.');
+
+ if (lastDotIndex === -1) {
+ return Either.left(new Error('Invalid token format'));
+ }
+
+ const payload = decoded.substring(0, lastDotIndex);
+ const signature = decoded.substring(lastDotIndex + 1);
+
+ // Verify signature
+ const expectedSignature = this.sign(payload);
+ if (signature !== expectedSignature) {
+ return Either.left(new Error('Invalid token signature'));
+ }
+
+ // Parse payload
+ const parsed: TokenPayload = JSON.parse(payload);
+
+ // Check route name
+ if (parsed.routeName !== expectedRoute) {
+ return Either.left(new Error('Token route mismatch'));
+ }
+
+ // Check expiration
+ const now = Date.now();
+ const age = (now - parsed.timestamp) / 1000;
+ if (age > this.ttlSeconds) {
+ return Either.left(new Error('Token expired'));
+ }
+
+ if (age < 0) {
+ return Either.left(new Error('Token from future'));
+ }
+
+ return Either.right(parsed);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+
+ private sign(payload: string): string {
+ return createHmac('sha256', this.secret).update(payload).digest('hex');
+ }
+
+ getSecret(): string {
+ return this.secret;
+ }
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..fbfc70d
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,78 @@
+export enum ContentType {
+ JSON = 'json',
+ FORM = 'form',
+ MULTIPART = 'multipart',
+ TEXT = 'text',
+ RAW = 'raw',
+}
+
+export interface NtfyConfig {
+ enabled: boolean;
+ server?: string;
+ topic?: string;
+}
+
+export interface RouteConfig {
+ name: string;
+ contentType: ContentType;
+ hcaptchaProtected: boolean;
+ hcaptchaSecret?: string;
+ ntfy?: NtfyConfig;
+ requireToken?: boolean;
+}
+
+export interface StoredRequest {
+ timestamp: number;
+ uuid: string;
+ routeName: string;
+ method: string;
+ headers: Record<string, string>;
+ body: unknown;
+ files?: Array<{
+ filename: string;
+ contentType: string;
+ size: number;
+ path: string;
+ }>;
+}
+
+const ROUTE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
+
+export function isSafeRouteName(name: unknown): name is string {
+ if (typeof name !== 'string') return false;
+ if (name !== name.trim()) return false;
+ if (name === '.' || name === '..') return false;
+ if (name.includes('/') || name.includes('\\')) return false;
+ return ROUTE_NAME_PATTERN.test(name);
+}
+
+export function isRouteConfig(obj: unknown): obj is RouteConfig {
+ if (typeof obj !== 'object' || obj === null) return false;
+ const r = obj as Record<string, unknown>;
+
+ const validBasic =
+ isSafeRouteName(r.name) &&
+ typeof r.contentType === 'string' &&
+ Object.values(ContentType).includes(r.contentType as ContentType) &&
+ typeof r.hcaptchaProtected === 'boolean' &&
+ (r.hcaptchaProtected === false || typeof r.hcaptchaSecret === 'string');
+
+ if (!validBasic) return false;
+
+ // Validate ntfy config if present
+ if (r.ntfy !== undefined) {
+ if (typeof r.ntfy !== 'object' || r.ntfy === null) return false;
+ const ntfy = r.ntfy as Record<string, unknown>;
+ if (typeof ntfy.enabled !== 'boolean') return false;
+ if (ntfy.enabled && (typeof ntfy.server !== 'string' || typeof ntfy.topic !== 'string')) {
+ return false;
+ }
+ }
+
+ // Validate requireToken if present
+ if (r.requireToken !== undefined && typeof r.requireToken !== 'boolean') {
+ return false;
+ }
+
+ return true;
+}