Skip to content
Closed
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
5 changes: 3 additions & 2 deletions ops/env/mainnet/backend/secrets.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions ops/mainnet/staging/backend/config.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
6 changes: 6 additions & 0 deletions ops/mainnet/staging/backend/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,9 @@ variable "cartographer_handler_heartbeat" {
sensitive = true
default = ""
}

variable "cartographer_admin_token" {
type = string
sensitive = true
default = ""
}
8 changes: 6 additions & 2 deletions packages/agents/cartographer/handler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,10 +80,14 @@ async function startServer(): Promise<void> {

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);
Expand Down
2 changes: 2 additions & 0 deletions packages/agents/cartographer/handler/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
export type HandlerConfig = CartographerConfig & {
goldskyWebhookSecret: string;
handlerPort: number;
adminToken: string;
};

export const getHandlerConfig = async (): Promise<HandlerConfig> => {
Expand All @@ -21,6 +22,7 @@ export const getHandlerConfig = async (): Promise<HandlerConfig> => {
...baseConfig,
goldskyWebhookSecret: process.env.GOLDSKY_WEBHOOK_SECRET || '',
handlerPort: parseInt(process.env.PORT || '3000', 10),
adminToken: process.env.CARTOGRAPHER_ADMIN_TOKEN || '',
};
};

Expand Down
41 changes: 35 additions & 6 deletions packages/agents/cartographer/handler/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 });
Expand Down Expand Up @@ -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' });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
62 changes: 54 additions & 8 deletions packages/agents/cartographer/handler/test/server.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +17,7 @@ describe('server', () => {
appContext: createAppContext(),
isPaused: false,
webhookSecret: 'test-secret',
adminToken: ADMIN_TOKEN,
};
server = createServer(state, createStubInstance(Logger));
await server.ready();
Expand All @@ -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<Database>;
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<Database>;
expect(db.saveCheckPoint.calledWith(PAUSE_CHECKPOINT_KEY, 0)).to.be.true;
});
});

Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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;
});
});

Expand Down
Loading