Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,21 @@ PUBLISHER_RATE_LIMIT_RPM_DEFAULT=60
PUBLISHER_RATE_LIMIT_MAX_RPM=10000
PUBLISHER_RATE_LIMIT_WINDOW_MS=60000

# ── Admin panel ─────────────────────────────────────────────────────────────
# [optional] Secret key for admin routes (X-Admin-Key header). When unset,
# admin routes return 401 and a boot warning is logged (#49).
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# ADMIN_API_KEY=

# ── Publisher webhooks ──────────────────────────────────────────────────────
# Publishers configure their own URL/secret via PATCH /publishers/me/webhooks.
# [optional] Webhook delivery tuning
WEBHOOK_TIMEOUT_MS=10000
WEBHOOK_MAX_ATTEMPTS=3
# [optional] AES-256 key used to encrypt webhook secrets at rest (#47).
# When unset webhook secrets are stored in plain text (boot warning logged).
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WEBHOOK_ENCRYPTION_KEY=

# ── Request signing ─────────────────────────────────────────────────────────
# [optional] When true, publisher mutations need X-Timestamp + X-Signature.
Expand Down
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ dist/
coverage/
last-processed-ledger.json

# Test snapshots and vitest cache
# Test snapshots, vitest cache, and coverage artefacts
**/__snapshots__/
.vitest-cache/
vitest.d.ts
*.snap

# TypeScript build info
*.tsbuildinfo
Expand All @@ -23,3 +24,10 @@ vitest.d.ts

# OS artefacts
Thumbs.db

# Temp / debug files
*.tmp
*.bak
npm-debug.log*
yarn-debug.log*
yarn-error.log*
22 changes: 22 additions & 0 deletions drizzle/0015_webhook_outbox.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Webhook outbox: persists pending/in-flight deliveries so they survive crashes
-- and restarts (#48). A background worker picks them up and retries with
-- exponential backoff.
CREATE TABLE IF NOT EXISTS "webhook_outbox" (
"id" text PRIMARY KEY NOT NULL,
"publisher_id" text NOT NULL REFERENCES "publishers"("id"),
"event" text NOT NULL,
"payload" jsonb NOT NULL,
"status" text NOT NULL DEFAULT 'pending', -- pending | delivered | failed
"attempts" integer NOT NULL DEFAULT 0,
"max_attempts" integer NOT NULL DEFAULT 3,
"last_error" text,
"delivery_id" text,
"next_attempt_at" timestamp NOT NULL DEFAULT now(),
"delivered_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now()
);

-- Worker polls for pending rows ordered by next_attempt_at.
CREATE INDEX IF NOT EXISTS "idx_webhook_outbox_status_next" ON "webhook_outbox" ("status", "next_attempt_at");
-- Publisher delivery-history endpoint queries by publisher_id.
CREATE INDEX IF NOT EXISTS "idx_webhook_outbox_publisher_id" ON "webhook_outbox" ("publisher_id", "created_at");
9 changes: 9 additions & 0 deletions drizzle/0016_encrypt_webhook_secrets.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Idempotent marker: existing rows with plaintext webhook secrets cannot be
-- automatically re-encrypted here because the encryption key is not available
-- in SQL migrations. The application layer handles encryption on write and
-- decryption on read. Rows already encrypted (base64 AES-GCM blobs) are
-- detected by the application; rows still in plain text continue to work
-- until the publisher updates their secret.
-- This migration is intentionally a no-op so it can be run multiple times
-- safely — the real change is in the application code (#47).
SELECT 1;
36 changes: 36 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ const envSchema = z.object({
// Required when REQUIRE_REQUEST_SIGNATURE is true.
SIGNING_SECRET_KEY: z.string().min(16).optional(),

// AES-256 key used to encrypt webhook secrets at rest (#47).
// Optional — when unset webhook secrets are NOT encrypted (logged as warning).
WEBHOOK_ENCRYPTION_KEY: z.string().min(16).optional(),

// Admin panel protection key (#49). Optional — when unset admin routes are
// disabled (return 401) and a warning is logged at boot.
ADMIN_API_KEY: z.string().min(1).optional(),

// HMAC-SHA256 request signatures for publisher mutations.
// Defaults to true in production; false elsewhere. Override with env var.
REQUIRE_REQUEST_SIGNATURE: envFlag(
Expand Down Expand Up @@ -372,4 +380,32 @@ if (parsed.data.REQUIRE_REQUEST_SIGNATURE && !parsed.data.SIGNING_SECRET_KEY) {
"[config] SIGNING_SECRET_KEY: required when REQUIRE_REQUEST_SIGNATURE=true (#42)",
);
process.exit(1);
}

// ── Boot-time security warnings (#49) ────────────────────────────────────────
if (!parsed.data.ADMIN_API_KEY) {
rootLogger.warn(
{ event: "config_admin_key_missing" },
"[security] ADMIN_API_KEY is not set — admin routes (/admin/stats, /admin/audit, " +
"/admin/delist) are disabled and will return 401. Set ADMIN_API_KEY to a strong " +
"random secret to enable them (#49).",
);
console.warn(
"[config] WARNING: ADMIN_API_KEY is not set. Admin routes are disabled. " +
"Set ADMIN_API_KEY to enable them (#49).",
);
}

// ── Boot-time security warnings (#47) ────────────────────────────────────────
if (!parsed.data.WEBHOOK_ENCRYPTION_KEY) {
rootLogger.warn(
{ event: "config_webhook_encryption_key_missing" },
"[security] WEBHOOK_ENCRYPTION_KEY is not set — webhook secrets will be stored " +
"as plain text in the database. Set WEBHOOK_ENCRYPTION_KEY to a strong random " +
"secret to encrypt them at rest (#47).",
);
console.warn(
"[config] WARNING: WEBHOOK_ENCRYPTION_KEY is not set. " +
"Webhook secrets are stored in plain text. Set WEBHOOK_ENCRYPTION_KEY to fix this (#47).",
);
}
39 changes: 39 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,42 @@ export const idempotencyRecords = pgTable("idempotency_records", {
record: jsonb("record").notNull(),
expiresAt: timestamp("expires_at").notNull(),
});

// Webhook outbox — persists pending deliveries so they survive restarts (#48).
// A background worker picks them up and retries with exponential backoff.
export const webhookOutboxStatusEnum = pgEnum("webhook_outbox_status", [
"pending",
"delivered",
"failed",
]);

export const webhookOutbox = pgTable(
"webhook_outbox",
{
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
publisherId: text("publisher_id")
.notNull()
.references(() => publishers.id),
event: text("event").notNull(),
payload: jsonb("payload").notNull(),
// pending → being retried, delivered → success, failed → exhausted attempts
status: text("status").notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
maxAttempts: integer("max_attempts").notNull().default(3),
lastError: text("last_error"),
// Stable delivery ID echoed in X-SynapsVault-Id (#45).
deliveryId: text("delivery_id"),
// Worker polls for rows where next_attempt_at <= now() and status = 'pending'.
nextAttemptAt: timestamp("next_attempt_at").defaultNow().notNull(),
deliveredAt: timestamp("delivered_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => ({
// Worker polls pending rows ordered by next_attempt_at.
statusNextIdx: index("idx_webhook_outbox_status_next").on(table.status, table.nextAttemptAt),
// Publisher delivery-history endpoint queries by publisher_id + created_at.
publisherIdIdx: index("idx_webhook_outbox_publisher_id").on(table.publisherId, table.createdAt),
}),
);
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { pgClient } from "./db/client.js";
import { startPoolMetrics, stopPoolMetrics } from "./db/client.js";
import { startRetryPendingWorker, stopRetryPendingWorker } from "./workers/retryPendingWorker.js";
import { startEventListener, stopEventListener } from "./workers/eventListener.js";
import { startWebhookOutboxWorker, stopWebhookOutboxWorker } from "./workers/webhookOutboxWorker.js";
import { closeRateLimitStore } from "./lib/rateLimit/index.js";
import { useSharedIdempotencyStore } from "./lib/idempotency.js";
import {
Expand Down Expand Up @@ -110,6 +111,7 @@ async function startWorkersIfLeader(): Promise<void> {
rootLogger.info({ event: "workers_started_as_leader" }, "acquired worker leader lock; starting background workers");
startRetryPendingWorker();
startEventListener();
startWebhookOutboxWorker();
}

const server: Server = app.listen(config.PORT, () => {
Expand All @@ -136,6 +138,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
if (workersStarted) {
stopRetryPendingWorker();
stopEventListener();
stopWebhookOutboxWorker();
}
stopPoolMetrics();

Expand Down
Loading