diff --git a/ops/env/mainnet/backend/secrets.staging.json b/ops/env/mainnet/backend/secrets.staging.json index 2cf28bc0..16fa2ae5 100644 --- a/ops/env/mainnet/backend/secrets.staging.json +++ b/ops/env/mainnet/backend/secrets.staging.json @@ -16,6 +16,7 @@ "trongrid_api_key": "ENC[AES256_GCM,data:MYOQYoVgEzryYRfcRno2JwyzLptis28ndaHTXXyz888udENq,iv:mvB9HZg5jo4uE3WmFVBVI/8HW42M6UQ1IOL9wYbD/xM=,tag:c6khMKpNEHt0RLjC5jpl+g==,type:str]", "postgrest_jwt_secret": "ENC[AES256_GCM,data:Vcn6ii1pTMBE7sZRJlKnVopp2Di6yObtV0F92SOVqIkJxamCRFe3Y7o0Yg==,iv:2RjhtrarSt871h8mIfp2Uf8c4R7QeB+JMgm58GKj/pM=,tag:GgCC9kpNwMQ3t24NB/BE+w==,type:str]", "cartographer_goldsky_webhook_secret": "ENC[AES256_GCM,data:pNsLWvC2wFNDeVybOU3cijUwytMg1L6fiLordmGO,iv:d+cha50Ju8r37ZRLIzo/LzA85Fz8bZphU41X0sDzoto=,tag:VU5HOeYhrkrgGXPgJdltMw==,type:str]", + "cartographer_admin_token": "ENC[AES256_GCM,data:KHt9jIS0nP6ZjH//Dw94cBO64l7sTS60JdjhEmav,iv:GLVmvm7/rEyn7wVKUb4xJoWEgTXc4J062qQJ6UCZOyc=,tag:lUBw0WrRomAR464frNXTpA==,type:str]", "sops": { "kms": [ { @@ -29,8 +30,8 @@ "azure_kv": null, "hc_vault": null, "age": null, - "lastmodified": "2026-03-09T23:44:30Z", - "mac": "ENC[AES256_GCM,data:m2uq+GzAhWYJgKA4xrOgyD2S/8EUVoIxdHlOzUZLfGbYcPmH70j4vhlEgtD+SB0olSYMPvX+vbWKimGAurH7gydW1MQsA/wIRmR/EfEyzJ1Oc606eynr5U/yL2KtqzyhSwY50uVd1wSzAnhLjbgb5WAuSBS2R3KHLEERUVTxo4M=,iv:0zni6APefEh18VQYjBsrdvhnf8ZgLcuEi+2InmFyaaA=,tag:6muuFU7T0UI62JmuiYy7Qw==,type:str]", + "lastmodified": "2026-03-10T18:05:04Z", + "mac": "ENC[AES256_GCM,data:+DNi24q63IMQ8o0D4cV9jDhpP3WT5R6A0gzlgpiN6wQyKnX/n1OoZaQWhhZZ7nAMxpG5yvhz+cPH8i1Mn4Z45CMCRkWW+4UWSXuEEt97faM9766Ox/aYYPvvrJ8m+q6aUGc6DWBb/HfZ1801jVIL0HdRGAGz6oSswZmEmEjadng=,iv:SSwtAwJfpvHZ8SP67DyAUb4TWqtJxcn3qYX6DAhq1fY=,tag:r4/jAFBGyMszNw0FgUKYHw==,type:str]", "pgp": null, "unencrypted_suffix": "_unencrypted", "version": "3.9.1" diff --git a/ops/mainnet/staging/backend/config.tf b/ops/mainnet/staging/backend/config.tf index d2f937e6..657b92dc 100644 --- a/ops/mainnet/staging/backend/config.tf +++ b/ops/mainnet/staging/backend/config.tf @@ -46,6 +46,7 @@ locals { { name = "DD_ENV", value = "${var.environment}-${var.stage}" }, { name = "DD_LOGS_ENABLED", value = "true" }, { name = "DD_API_KEY", value = var.dd_api_key }, + { name = "CARTOGRAPHER_ADMIN_TOKEN", value = var.cartographer_admin_token }, ] local_cartographer_config_obj = { diff --git a/ops/mainnet/staging/backend/variables.tf b/ops/mainnet/staging/backend/variables.tf index 3e245315..64207dee 100755 --- a/ops/mainnet/staging/backend/variables.tf +++ b/ops/mainnet/staging/backend/variables.tf @@ -151,3 +151,9 @@ variable "cartographer_handler_heartbeat" { sensitive = true default = "" } + +variable "cartographer_admin_token" { + type = string + sensitive = true + default = "" +} diff --git a/packages/agents/cartographer/handler/src/index.ts b/packages/agents/cartographer/handler/src/index.ts index aeb91568..8079eb1a 100644 --- a/packages/agents/cartographer/handler/src/index.ts +++ b/packages/agents/cartographer/handler/src/index.ts @@ -3,7 +3,7 @@ import { Logger, jsonifyError, createLoggingContext } from '@chimera-monorepo/ut import { AppContext } from '@chimera-monorepo/cartographer-core'; import { getHandlerConfig, initializeContext, HandlerConfig } from './init'; -import { createServer, ServerState } from './server'; +import { createServer, ServerState, PAUSE_CHECKPOINT_KEY } from './server'; import { runBackfill } from './maintenance/backfill'; let server: FastifyInstance | null = null; @@ -80,10 +80,14 @@ async function startServer(): Promise { appContext = await initializeContext(handlerConfig, logger); + const pauseCheckpoint = await appContext.adapters.database.getCheckPoint(PAUSE_CHECKPOINT_KEY); + const isPaused = pauseCheckpoint === 1; + const state: ServerState = { appContext, - isPaused: false, + isPaused, webhookSecret: handlerConfig.goldskyWebhookSecret, + adminToken: handlerConfig.adminToken, }; server = createServer(state, logger); diff --git a/packages/agents/cartographer/handler/src/init.ts b/packages/agents/cartographer/handler/src/init.ts index 81cc6105..e0e514da 100644 --- a/packages/agents/cartographer/handler/src/init.ts +++ b/packages/agents/cartographer/handler/src/init.ts @@ -12,6 +12,7 @@ import { export type HandlerConfig = CartographerConfig & { goldskyWebhookSecret: string; handlerPort: number; + adminToken: string; }; export const getHandlerConfig = async (): Promise => { @@ -21,6 +22,7 @@ export const getHandlerConfig = async (): Promise => { ...baseConfig, goldskyWebhookSecret: process.env.GOLDSKY_WEBHOOK_SECRET || '', handlerPort: parseInt(process.env.PORT || '3000', 10), + adminToken: process.env.CARTOGRAPHER_ADMIN_TOKEN || '', }; }; diff --git a/packages/agents/cartographer/handler/src/server.ts b/packages/agents/cartographer/handler/src/server.ts index cd9fb87a..0b0327c3 100644 --- a/packages/agents/cartographer/handler/src/server.ts +++ b/packages/agents/cartographer/handler/src/server.ts @@ -2,12 +2,22 @@ import fastify, { FastifyInstance } from 'fastify'; import { Logger, jsonifyError } from '@chimera-monorepo/utils'; import { AppContext } from '@chimera-monorepo/cartographer-core'; -import { verifyWebhookSecret, routeWebhook } from './webhooks/webhookHandler'; +import { verifySecret, routeWebhook } from './webhooks/webhookHandler'; + +export const PAUSE_CHECKPOINT_KEY = 'cartographer_handler_paused'; + +function verifyAdminToken(authHeader: string | string[] | undefined, expectedToken: string): boolean { + const header = Array.isArray(authHeader) ? authHeader[0] : authHeader; + if (!header) return false; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + return verifySecret(token, expectedToken); +} export interface ServerState { appContext: AppContext | null; isPaused: boolean; webhookSecret: string; + adminToken: string; } export function createServer(state: ServerState, logger: Logger): FastifyInstance { @@ -23,14 +33,28 @@ export function createServer(state: ServerState, logger: Logger): FastifyInstanc }); // Pause webhook processing (useful during pipeline backfill) - server.post('/pause', async (_, res) => { + server.post('/pause', async (req, res) => { + if (!verifyAdminToken(req.headers.authorization, state.adminToken)) { + return res.status(401).send({ error: 'Unauthorized' }); + } + if (!state.appContext) { + return res.status(503).send({ error: 'Handler not initialized' }); + } + await state.appContext.adapters.database.saveCheckPoint(PAUSE_CHECKPOINT_KEY, 1); state.isPaused = true; logger.info('Webhook processing paused'); return res.status(200).send({ message: 'Webhook processing paused', paused: true }); }); // Resume webhook processing - server.post('/resume', async (_, res) => { + server.post('/resume', async (req, res) => { + if (!verifyAdminToken(req.headers.authorization, state.adminToken)) { + return res.status(401).send({ error: 'Unauthorized' }); + } + if (!state.appContext) { + return res.status(503).send({ error: 'Handler not initialized' }); + } + await state.appContext.adapters.database.saveCheckPoint(PAUSE_CHECKPOINT_KEY, 0); state.isPaused = false; logger.info('Webhook processing resumed'); return res.status(200).send({ message: 'Webhook processing resumed', paused: false }); @@ -84,20 +108,25 @@ export function createServer(state: ServerState, logger: Logger): FastifyInstanc }, }, async (req, res) => { + const webhookName = req.params.webhookName; + const domain = req.query.domain; if (state.isPaused) { + logger.info('Webhook processing is paused, skipping', undefined, undefined, { + webhookName, + domain, + }); return res.status(200).send({ message: 'Server paused, skipping webhook', processed: false, webhookId: '' }); } if (!state.appContext) { + logger.error('Cannot process webhook: handler not initialized'); return res.status(503).send({ error: 'Handler not initialized' }); } - const webhookName = req.params.webhookName; - const domain = req.query.domain; const webhookSecretHeader = (req.headers['goldsky-webhook-secret'] as string) || (req.headers['Goldsky-Webhook-Secret'] as string); - if (!verifyWebhookSecret(webhookSecretHeader, state.webhookSecret)) { + if (!verifySecret(webhookSecretHeader, state.webhookSecret)) { return res.status(401).send({ error: 'Invalid webhook secret' }); } diff --git a/packages/agents/cartographer/handler/src/webhooks/webhookHandler.ts b/packages/agents/cartographer/handler/src/webhooks/webhookHandler.ts index d66399af..abd6b3d4 100644 --- a/packages/agents/cartographer/handler/src/webhooks/webhookHandler.ts +++ b/packages/agents/cartographer/handler/src/webhooks/webhookHandler.ts @@ -38,9 +38,9 @@ export function base64ToHex(b64: string): string { } /** - * Verify webhook secret using timing-safe comparison. + * Verify a secret using timing-safe comparison. */ -export function verifyWebhookSecret(webhookSecretHeader: string | undefined, expectedSecret: string): boolean { +export function verifySecret(webhookSecretHeader: string | undefined, expectedSecret: string): boolean { if (!webhookSecretHeader) return false; try { @@ -77,7 +77,7 @@ export async function routeWebhook( adapters: { database }, } = context; - logger.debug('Routing webhook', undefined, undefined, { webhookName, webhookId, domain }); + logger.debug('Routing webhook', undefined, undefined, { webhookName, webhookId, domain, payload }); try { switch (webhookName) { diff --git a/packages/agents/cartographer/handler/test/server.spec.ts b/packages/agents/cartographer/handler/test/server.spec.ts index dbdfdfc6..11c2de6b 100644 --- a/packages/agents/cartographer/handler/test/server.spec.ts +++ b/packages/agents/cartographer/handler/test/server.spec.ts @@ -1,10 +1,13 @@ -import { expect, Logger, mkBytes32 } from '@chimera-monorepo/utils'; -import { createStubInstance } from 'sinon'; +import { expect, Logger } from '@chimera-monorepo/utils'; +import { createStubInstance, SinonStubbedInstance } from 'sinon'; import { FastifyInstance } from 'fastify'; +import { Database } from '@chimera-monorepo/database'; -import { createServer, ServerState } from '../src/server'; +import { createServer, ServerState, PAUSE_CHECKPOINT_KEY } from '../src/server'; import { createAppContext } from './mock'; +const ADMIN_TOKEN = 'test-admin-token'; + describe('server', () => { let server: FastifyInstance; let state: ServerState; @@ -14,6 +17,7 @@ describe('server', () => { appContext: createAppContext(), isPaused: false, webhookSecret: 'test-secret', + adminToken: ADMIN_TOKEN, }; server = createServer(state, createStubInstance(Logger)); await server.ready(); @@ -40,23 +44,63 @@ describe('server', () => { }); describe('POST /pause', () => { - it('should set isPaused to true', async () => { + it('should return 401 without auth token', async () => { const res = await server.inject({ method: 'POST', url: '/pause' }); + expect(res.statusCode).to.equal(401); + }); + + it('should return 401 with invalid auth token', async () => { + const res = await server.inject({ + method: 'POST', + url: '/pause', + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(res.statusCode).to.equal(401); + }); + + it('should set isPaused to true and persist to database', async () => { + const res = await server.inject({ + method: 'POST', + url: '/pause', + headers: { authorization: `Bearer ${ADMIN_TOKEN}` }, + }); expect(res.statusCode).to.equal(200); const body = JSON.parse(res.payload); expect(body.paused).to.equal(true); expect(state.isPaused).to.equal(true); + const db = state.appContext!.adapters.database as unknown as SinonStubbedInstance; + expect(db.saveCheckPoint.calledOnceWith(PAUSE_CHECKPOINT_KEY, 1)).to.be.true; }); }); describe('POST /resume', () => { - it('should set isPaused to false', async () => { - state.isPaused = true; + it('should return 401 without auth token', async () => { const res = await server.inject({ method: 'POST', url: '/resume' }); + expect(res.statusCode).to.equal(401); + }); + + it('should return 401 with invalid auth token', async () => { + const res = await server.inject({ + method: 'POST', + url: '/resume', + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(res.statusCode).to.equal(401); + }); + + it('should set isPaused to false and persist to database', async () => { + state.isPaused = true; + const res = await server.inject({ + method: 'POST', + url: '/resume', + headers: { authorization: `Bearer ${ADMIN_TOKEN}` }, + }); expect(res.statusCode).to.equal(200); const body = JSON.parse(res.payload); expect(body.paused).to.equal(false); expect(state.isPaused).to.equal(false); + const db = state.appContext!.adapters.database as unknown as SinonStubbedInstance; + expect(db.saveCheckPoint.calledWith(PAUSE_CHECKPOINT_KEY, 0)).to.be.true; }); }); @@ -88,8 +132,10 @@ describe('server', () => { }); it('should resume processing after pause/resume cycle', async () => { + const authHeaders = { authorization: `Bearer ${ADMIN_TOKEN}` }; + // Pause - await server.inject({ method: 'POST', url: '/pause' }); + await server.inject({ method: 'POST', url: '/pause', headers: authHeaders }); // Webhook should be skipped const skipped = await server.inject({ @@ -101,7 +147,7 @@ describe('server', () => { expect(JSON.parse(skipped.payload).processed).to.equal(false); // Resume - await server.inject({ method: 'POST', url: '/resume' }); + await server.inject({ method: 'POST', url: '/resume', headers: authHeaders }); // Webhook should be processed const processed = await server.inject({ diff --git a/packages/agents/cartographer/handler/test/webhooks/webhookHandler.spec.ts b/packages/agents/cartographer/handler/test/webhooks/webhookHandler.spec.ts index 1f12a59b..a0d60b14 100644 --- a/packages/agents/cartographer/handler/test/webhooks/webhookHandler.spec.ts +++ b/packages/agents/cartographer/handler/test/webhooks/webhookHandler.spec.ts @@ -3,7 +3,7 @@ import { SinonStubbedInstance } from 'sinon'; import { Database } from '@chimera-monorepo/database'; import { AppContext } from '@chimera-monorepo/cartographer-core'; -import { base64ToHex, verifyWebhookSecret, routeWebhook } from '../../src/webhooks/webhookHandler'; +import { base64ToHex, verifySecret, routeWebhook } from '../../src/webhooks/webhookHandler'; import { createAppContext } from '../mock'; describe('webhookHandler', () => { @@ -19,27 +19,27 @@ describe('webhookHandler', () => { }); }); - describe('#verifyWebhookSecret', () => { + describe('#verifySecret', () => { const secret = 'my-webhook-secret-123'; it('should return true for matching secret', () => { - expect(verifyWebhookSecret(secret, secret)).to.be.true; + expect(verifySecret(secret, secret)).to.be.true; }); it('should return false for mismatched secret', () => { - expect(verifyWebhookSecret('wrong-secret', secret)).to.be.false; + expect(verifySecret('wrong-secret', secret)).to.be.false; }); it('should return false for undefined header', () => { - expect(verifyWebhookSecret(undefined, secret)).to.be.false; + expect(verifySecret(undefined, secret)).to.be.false; }); it('should return false for empty header', () => { - expect(verifyWebhookSecret('', secret)).to.be.false; + expect(verifySecret('', secret)).to.be.false; }); it('should return false for different length secrets', () => { - expect(verifyWebhookSecret('short', secret)).to.be.false; + expect(verifySecret('short', secret)).to.be.false; }); });