diff options
| -rw-r--r-- | README.md | 161 | ||||
| -rw-r--r-- | package-lock.json | 15 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | routes.toml.example | 47 | ||||
| -rw-r--r-- | src/activity/index.ts | 123 | ||||
| -rw-r--r-- | src/index.ts | 6 | ||||
| -rw-r--r-- | src/server/index.ts | 11 | ||||
| -rw-r--r-- | src/storage/index.ts | 95 | ||||
| -rw-r--r-- | test/storage.test.ts | 67 |
9 files changed, 231 insertions, 297 deletions
@@ -4,8 +4,8 @@ A simple API service for receiving and storing webhook requests with dynamic rou ## Features -- Dynamic route/topic registration -- Multiple content type support (JSON, form, text, raw) +- File-based route configuration with hot-reload +- Multiple content type support (JSON, form, text, raw, multipart) - CSRF token protection with HMAC-signed stateless tokens - hCaptcha protection per route - ntfy notification alerts when webhooks are received @@ -16,83 +16,70 @@ A simple API service for receiving and storing webhook requests with dynamic rou - Comprehensive metrics and tracing - Single Dockerfile deployment -## API Endpoints - -### Route Prefixes - -- **`/`** - Public routes (webhooks, token generation, health) -- **`/admin`** - Admin routes (route management) - Put behind OAuth proxy - -### Admin Endpoints - -**⚠️ Recommendation**: Put `/admin/*` behind an OAuth proxy (e.g., OAuth2 Proxy, Pomerium) for authentication. - -#### Register a Route - -```bash -POST /admin/routes -Content-Type: application/json - -{ - "name": "my-webhook", - "contentType": "json", - "hcaptchaProtected": false -} -``` - -With hCaptcha protection: - -```bash -POST /admin/routes -Content-Type: application/json - -{ - "name": "protected-webhook", - "contentType": "json", - "hcaptchaProtected": true, - "hcaptchaSecret": "your-hcaptcha-secret" -} -``` - -With ntfy notifications: - -```bash -POST /admin/routes -Content-Type: application/json +## Configuration -{ - "name": "notified-webhook", - "contentType": "json", - "hcaptchaProtected": false, - "ntfy": { - "enabled": true, - "server": "https://ntfy.sh", - "topic": "my-webhook-alerts" - } -} +Routes are configured via a `routes.toml` file that Posthook watches for changes. When you edit the file, Posthook automatically reloads the configuration. + +### Example Configuration + +Create a `routes.toml` file: + +```toml +# Simple JSON webhook +[[route]] +name = "github-webhook" +contentType = "json" +hcaptchaProtected = false +requireToken = false + +# Form with hCaptcha protection +[[route]] +name = "contact-form" +contentType = "form" +hcaptchaProtected = true +hcaptchaSecret = "0x0000000000000000000000000000000000000000" +requireToken = false + +# JSON webhook with ntfy notifications +[[route]] +name = "alerts" +contentType = "json" +hcaptchaProtected = false +requireToken = false + +[route.ntfy] +enabled = true +server = "https://ntfy.sh" +topic = "my-alerts" + +# Multipart file upload with token protection +[[route]] +name = "file-upload" +contentType = "multipart" +hcaptchaProtected = false +requireToken = true ``` -With CSRF token protection: +### Configuration Fields -```bash -POST /admin/routes -Content-Type: application/json - -{ - "name": "secure-form", - "contentType": "form", - "hcaptchaProtected": false, - "requireToken": true -} -``` +- `name` - Route identifier (alphanumeric, dash, underscore only) +- `contentType` - One of: `json`, `form`, `multipart`, `text`, `raw` +- `hcaptchaProtected` - Enable hCaptcha verification (default: false) +- `hcaptchaSecret` - hCaptcha secret key (required if hcaptchaProtected is true) +- `requireToken` - Enable CSRF token protection (default: false) +- `ntfy` - Optional ntfy notification configuration: + - `enabled` - Enable notifications (required) + - `server` - ntfy server URL (required if enabled) + - `topic` - ntfy topic name (required if enabled) -#### List Routes +### Hot Reload -```bash -GET /admin/routes -``` +Posthook watches `routes.toml` for changes. When you save the file: +- Valid changes are applied immediately +- Invalid configuration causes the process to exit (fail-fast) +- No need to restart the server for route updates -### Public Webhook Endpoints +## API Endpoints #### Get CSRF Token (for routes with requireToken: true) @@ -347,37 +334,23 @@ docker run -p 9000:9000 \ posthook ``` -### With OAuth Proxy (Recommended for Production) - -Protect admin routes with an OAuth proxy: +### With Custom Config Location ```bash -# Run posthook -docker run -p 9000:9000 posthook - -# Run OAuth2 Proxy in front of /admin routes -docker run -p 4180:4180 \ - quay.io/oauth2-proxy/oauth2-proxy:latest \ - --upstream=http://localhost:9000/admin \ - --http-address=0.0.0.0:4180 \ - --provider=google \ - --client-id=your-client-id \ - --client-secret=your-client-secret \ - --cookie-secret=random-secret-here \ - --email-domain=yourdomain.com +docker run -p 9000:9000 \ + -v $(pwd)/my-routes.toml:/app/routes.toml \ + -v $(pwd)/data:/app/data \ + -e POSTHOOK_TOKEN_SECRET=your-secret-here \ + posthook -- --config /app/routes.toml ``` -Then configure your reverse proxy: - -- `/admin/*` → OAuth2 Proxy on port 4180 -- `/` → Posthook on port 9000 - -## Configuration +## Runtime Configuration ### Command Line Arguments: - `--port` - Server port (default: 9000) - `--host` - Server host (default: 0.0.0.0) +- `--config` - Path to routes.toml configuration file (default: ./routes.toml) - `--data-dir` - Data storage directory (default: ./data) - `--token-secret` - HMAC secret for token signing (optional, generates random if not provided) - `--cors-origins` - Allowed CORS origins (default: `*`). Supports `*`, exact origins (`https://a.com`), and host patterns (`liz.coffee`, `*.liz.coffee`). When not `*`, only `https` origins are allowed. Responses only set `Access-Control-Allow-Origin` (preflight uses the standard allow-\* headers). diff --git a/package-lock.json b/package-lock.json index f5fa199..c4b7c94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@emprespresso/pengueno": "^0.0.18", "@hono/node-server": "^1.14.0", - "hono": "^4.8.9" + "hono": "^4.8.9", + "smol-toml": "^1.5.2" }, "devDependencies": { "@types/node": "^24.0.3", @@ -3267,6 +3268,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 7265fe2..6b09911 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "dependencies": { "@emprespresso/pengueno": "^0.0.18", "@hono/node-server": "^1.14.0", - "hono": "^4.8.9" + "hono": "^4.8.9", + "smol-toml": "^1.5.2" }, "devDependencies": { "@types/node": "^24.0.3", diff --git a/routes.toml.example b/routes.toml.example new file mode 100644 index 0000000..ac9a493 --- /dev/null +++ b/routes.toml.example @@ -0,0 +1,47 @@ +# Posthook Routes Configuration +# +# This file defines webhook routes that Posthook will handle. +# Edit this file and Posthook will automatically reload the configuration. +# +# Each [[route]] section defines a webhook endpoint available at /hook/{name} + +# Example: Simple JSON webhook +[[route]] +name = "github-webhook" +contentType = "json" +hcaptchaProtected = false +requireToken = false + +# Example: Form-encoded webhook with hCaptcha protection +[[route]] +name = "contact-form" +contentType = "form" +hcaptchaProtected = true +hcaptchaSecret = "0x0000000000000000000000000000000000000000" +requireToken = false + +# Example: JSON webhook with ntfy notifications +[[route]] +name = "alerts" +contentType = "json" +hcaptchaProtected = false +requireToken = false + +[route.ntfy] +enabled = true +server = "https://ntfy.sh" +topic = "my-alerts" + +# Example: Multipart file upload with token protection +[[route]] +name = "file-upload" +contentType = "multipart" +hcaptchaProtected = false +requireToken = true + +# Content Types: +# - json: application/json +# - form: application/x-www-form-urlencoded +# - multipart: multipart/form-data +# - text: text/plain +# - raw: any content type (stored as-is) diff --git a/src/activity/index.ts b/src/activity/index.ts index e14507d..d3537b4 100644 --- a/src/activity/index.ts +++ b/src/activity/index.ts @@ -4,7 +4,6 @@ import { type IActivity, type IEither, type ITraceable, - jsonModel, JsonResponse, LogLevel, LogMetricTraceSupplier, @@ -16,83 +15,14 @@ import { 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 { 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; } @@ -443,57 +373,6 @@ export class WebhookActivityImpl implements IWebhookActivity { } } -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; } diff --git a/src/index.ts b/src/index.ts index 0ece985..2985d36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,12 @@ 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', '--cors-origins'], + ['--port', '--host', '--data-dir', '--config', '--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 }, + '--config': { absent: './routes.toml', present: (path) => path }, '--token-secret': { absent: undefined, present: (secret) => secret }, '--cors-origins': { absent: '*', present: (origins) => origins }, }, @@ -23,12 +24,13 @@ const main = async (_argv = process.argv.slice(2)): Promise<IEither<Error, void> port: args['--port'], host: args['--host'], dataDir: args['--data-dir'], + configPath: args['--config'], tokenSecret: args['--token-secret'], corsOrigins: args['--cors-origins'], })) .flatMapAsync(async (config) => { // Initialize storage - const storage = new Storage(config.dataDir); + const storage = new Storage(config.dataDir, config.configPath); const initResult = await storage.init(); if (initResult.left().present()) { diff --git a/src/server/index.ts b/src/server/index.ts index a38b47a..95358d4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,12 +14,8 @@ import { } 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'; @@ -89,10 +85,8 @@ export class PosthookServer implements Server { corsOriginsRaw: string = '*', 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(), private readonly corsOrigins: AllowedCorsOrigins = parseAllowedCorsOrigins(corsOriginsRaw), ) {} @@ -211,11 +205,6 @@ export class PosthookServer implements Server { 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); diff --git a/src/storage/index.ts b/src/storage/index.ts index c3c97d8..8d7debd 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'crypto'; import { mkdir, writeFile, readFile } from 'fs/promises'; +import { watch } from 'fs'; import { basename, join } from 'path'; -import { isSafeRouteName, type RouteConfig, type StoredRequest } from '../types/index.js'; +import { parse as parseToml } from 'smol-toml'; +import { isSafeRouteName, isRouteConfig, type RouteConfig, type StoredRequest } from '../types/index.js'; import { Either, type IEither } from '@emprespresso/pengueno'; type IncomingUpload = { @@ -20,13 +22,20 @@ function sanitizeFilename(filename: string): string { export class Storage { private routes: Map<string, RouteConfig> = new Map(); + private configPath: string; - constructor(private readonly dataDir: string = './data') {} + constructor( + private readonly dataDir: string = './data', + configPath: string = './routes.toml', + ) { + this.configPath = configPath; + } async init(): Promise<IEither<Error, void>> { try { await mkdir(this.dataDir, { recursive: true }); await this.loadRoutes(); + this.watchConfig(); return Either.right(<void>undefined); } catch (err) { return Either.left(err instanceof Error ? err : new Error(String(err))); @@ -35,44 +44,60 @@ export class Storage { 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[]; + const data = await readFile(this.configPath, 'utf-8'); + const parsed = parseToml(data); + + if (!parsed || typeof parsed !== 'object' || !('route' in parsed)) { + console.error('Invalid routes.toml: missing [[route]] sections'); + process.exit(1); + } + + const routes = parsed.route; + if (!Array.isArray(routes)) { + console.error('Invalid routes.toml: "route" must be an array of tables'); + process.exit(1); + } + + const newRoutes = new Map<string, RouteConfig>(); for (const route of routes) { - if (!isSafeRouteName(route.name)) { - continue; + if (!isRouteConfig(route)) { + console.error('Invalid route configuration:', route); + process.exit(1); + } + if (newRoutes.has(route.name)) { + console.error(`Duplicate route name: ${route.name}`); + process.exit(1); } - this.routes.set(route.name, route); + newRoutes.set(route.name, route); + + // Ensure route directory exists + const routeDir = join(this.dataDir, route.name); + await mkdir(routeDir, { recursive: true }); } - } 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); + this.routes = newRoutes; + console.log(`Loaded ${this.routes.size} route(s) from ${this.configPath}`); } catch (err) { - return Either.left(err instanceof Error ? err : new Error(String(err))); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + console.log(`No ${this.configPath} found, starting with empty routes`); + return; + } + console.error(`Failed to load routes from ${this.configPath}:`, err); + process.exit(1); } } - async registerRoute(config: RouteConfig): Promise<IEither<Error, void>> { - if (!isSafeRouteName(config.name)) { - return Either.left(new Error('Invalid route name')); - } + private watchConfig(): void { + const watcher = watch(this.configPath, async (eventType) => { + if (eventType === 'change') { + console.log(`${this.configPath} changed, reloading...`); + await this.loadRoutes(); + } + }); - 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(); + watcher.on('error', (err) => { + console.error(`Error watching ${this.configPath}:`, err); + }); } getRoute(name: string): RouteConfig | undefined { @@ -84,14 +109,6 @@ export class Storage { 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, diff --git a/test/storage.test.ts b/test/storage.test.ts index 200c81a..985ba48 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdtemp, readFile, rm } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -9,56 +9,69 @@ import { ContentType, type RouteConfig } from '../src/types/index.js'; describe('Storage', () => { let dataDir: string; + let configPath: string; beforeEach(async () => { dataDir = await mkdtemp(join(tmpdir(), 'posthook-test-')); + configPath = join(dataDir, 'routes.toml'); }); afterEach(async () => { await rm(dataDir, { recursive: true, force: true }); }); - it('persists routes to routes.json and loads them on init', async () => { + it('loads routes from routes.toml on init', async () => { const route: RouteConfig = { name: 'route1', contentType: ContentType.JSON, hcaptchaProtected: false, }; - const storage1 = new Storage(dataDir); - expect((await storage1.init()).left().present()).toBe(false); - expect((await storage1.registerRoute(route)).left().present()).toBe(false); + // Create a TOML config file + const toml = ` +[[route]] +name = "route1" +contentType = "json" +hcaptchaProtected = false +`; + await writeFile(configPath, toml); - const storage2 = new Storage(dataDir); - expect((await storage2.init()).left().present()).toBe(false); - expect(storage2.getRoute('route1')).toEqual(route); - expect(storage2.listRoutes()).toEqual([route]); + const storage = new Storage(dataDir, configPath); + expect((await storage.init()).left().present()).toBe(false); + expect(storage.getRoute('route1')).toEqual(route); + expect(storage.listRoutes()).toEqual([route]); }); - it('rejects unsafe route names', async () => { - const storage = new Storage(dataDir); - expect((await storage.init()).left().present()).toBe(false); + it('returns undefined for unsafe route names', async () => { + const toml = ` +[[route]] +name = "safe-route" +contentType = "json" +hcaptchaProtected = false +`; + await writeFile(configPath, toml); - const result = await storage.registerRoute({ - name: '../bad', - contentType: ContentType.JSON, - hcaptchaProtected: false, - }); + const storage = new Storage(dataDir, configPath); + expect((await storage.init()).left().present()).toBe(false); - expect(result.left().present()).toBe(true); - expect(result.left().get().message).toBe('Invalid route name'); + // getRoute should reject unsafe names + expect(storage.getRoute('../bad')).toBeUndefined(); + expect(storage.getRoute('.')).toBeUndefined(); + expect(storage.getRoute('..')).toBeUndefined(); + expect(storage.getRoute('path/with/slash')).toBeUndefined(); }); it('stores a request and sanitizes uploaded filenames', async () => { - const route: RouteConfig = { - name: 'route1', - contentType: ContentType.JSON, - hcaptchaProtected: false, - }; - - const storage = new Storage(dataDir); + const toml = ` +[[route]] +name = "route1" +contentType = "json" +hcaptchaProtected = false +`; + await writeFile(configPath, toml); + + const storage = new Storage(dataDir, configPath); expect((await storage.init()).left().present()).toBe(false); - expect((await storage.registerRoute(route)).left().present()).toBe(false); const upload = { fieldName: 'file', |
