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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ STELLAR_NETWORK=testnet
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
STELLAR_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Webhook settings
WEBHOOK_MAX_PER_CREATOR=5
WEBHOOK_RETRY_MAX_ATTEMPTS=3
WEBHOOK_RETRY_BASE_DELAY_MS=1000

# Ownership snapshot cleanup job
OWNERSHIP_SNAPSHOT_TABLE_NAME=creator_ownership_snapshots
OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN=true
Expand Down
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ module.exports = {
transform: {
...tsJestTransformCfg,
},
moduleNameMapper: {
"^chalk$": "<rootDir>/src/__mocks__/chalk.ts",
},
roots: ["<rootDir>/src"],
setupFiles: ["./jest.setup.ts"],
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dependencies": {
"@json2csv/node": "^7.0.6",
"@prisma/client": "^6.19.1",
"@stellar/stellar-base": "^15.0.0",
"@types/js-yaml": "^4.0.9",
"@types/pdfkit": "^0.17.3",
"@types/puppeteer": "^5.4.7",
Expand Down
200 changes: 176 additions & 24 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions prisma/schema/migrations/20260618000000_add_webhooks/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "WebhookEventStatus" AS ENUM ('PENDING', 'DELIVERED', 'FAILED');

-- CreateEnum
CREATE TYPE "WebhookEventType" AS ENUM ('BUY', 'SELL');

-- CreateTable
CREATE TABLE "Webhook" (
"id" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"callbackUrl" TEXT NOT NULL,
"events" "WebhookEventType"[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isFailing" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "WebhookEvent" (
"id" TEXT NOT NULL,
"webhookId" TEXT NOT NULL,
"eventType" "WebhookEventType" NOT NULL,
"payload" JSONB NOT NULL,
"status" "WebhookEventStatus" NOT NULL DEFAULT 'PENDING',
"retryCount" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Webhook_creatorId_idx" ON "Webhook"("creatorId");

-- CreateIndex
CREATE INDEX "Webhook_isActive_idx" ON "Webhook"("isActive");

-- CreateIndex
CREATE INDEX "WebhookEvent_webhookId_idx" ON "WebhookEvent"("webhookId");

-- CreateIndex
CREATE INDEX "WebhookEvent_status_idx" ON "WebhookEvent"("status");

-- AddForeignKey
ALTER TABLE "WebhookEvent" ADD CONSTRAINT "WebhookEvent_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
45 changes: 45 additions & 0 deletions prisma/schema/webhook.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// prisma/schema/webhook.prisma

enum WebhookEventStatus {
PENDING
DELIVERED
FAILED
}

enum WebhookEventType {
BUY
SELL
}

model Webhook {
id String @id @default(cuid())
creatorId String
callbackUrl String
events WebhookEventType[]
isActive Boolean @default(true)
isFailing Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

events_dispatched WebhookEvent[]

@@index([creatorId])
@@index([isActive])
}

model WebhookEvent {
id String @id @default(cuid())
webhookId String
eventType WebhookEventType
payload Json
status WebhookEventStatus @default(PENDING)
retryCount Int @default(0)
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)

@@index([webhookId])
@@index([status])
}
12 changes: 12 additions & 0 deletions src/__mocks__/chalk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const chalk = new Proxy(
(s: string) => s,
{
get(_target, prop) {
if (prop === 'default' || prop === 'chalk') return chalk;
if (typeof prop === 'string') return chalk;
return chalk;
},
}
);

export default chalk;
13 changes: 9 additions & 4 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,15 @@ export const envSchema = z
.int()
.positive()
.default(300000),
INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce
.number()
.positive()
.default(300000),
INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce
.number()
.positive()
.default(300000),

// Webhook settings
WEBHOOK_MAX_PER_CREATOR: z.coerce.number().int().positive().default(5),
WEBHOOK_RETRY_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3),
WEBHOOK_RETRY_BASE_DELAY_MS: z.coerce.number().int().positive().default(1000),

// Indexer feature flags
ENABLE_INDEXER_DEDUPE: booleanCoerce.default(true),
Expand Down
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ledgerRouter from './ledger/ledger.routes';
import adminRouter from './admin/admin.routes';
import activityRouter from './activity/activity.routes';
import ownershipRouter from './ownership/ownership.routes';
import webhookRouter from './webhooks/webhook.router';
import { BASE as CREATORS_BASE } from '../constants/creator.constants';

const router = Router();
Expand All @@ -23,5 +24,6 @@ router.use('/ledger', ledgerRouter);
router.use('/admin', adminRouter);
router.use('/activity', activityRouter);
router.use('/ownership', ownershipRouter);
router.use(CREATORS_BASE, webhookRouter);

export default router;
3 changes: 3 additions & 0 deletions src/modules/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as webhookRouter } from './webhook.router';
export * from './webhook.types';
export { dispatchWebhookEvent } from './webhook.service';
122 changes: 122 additions & 0 deletions src/modules/webhooks/webhook-signature.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Request, Response, NextFunction } from 'express';
import { Keypair } from '@stellar/stellar-base';
import { StellarAddressSchema } from '../wallet/wallet.schemas';
import { sendError } from '../../utils/api-response.utils';
import { ErrorCode } from '../../constants/error.constants';
import { prisma } from '../../utils/prisma.utils';
import { logger } from '../../utils/logger.utils';
import { createHash } from 'crypto';

export interface WalletSignedRequest extends Request {
walletAddress?: string;
creatorId?: string;
}

const SIGNATURE_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;

function readHeader(req: Request, name: string): string | undefined {
const raw = req.headers[name];
if (Array.isArray(raw)) return raw[0]?.trim() || undefined;
return typeof raw === 'string' ? raw.trim() || undefined : undefined;
}

function buildMessage(
method: string,
path: string,
creatorId: string,
timestamp: string
): Buffer {
const payload = `${method.toUpperCase()}:${path}:${creatorId}:${timestamp}`;
return createHash('sha256').update(payload, 'utf8').digest();
}

export function requireWalletSignature() {
return async (
req: WalletSignedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const address = readHeader(req, 'x-wallet-address');
const signature = readHeader(req, 'x-signature');
const timestamp = readHeader(req, 'x-timestamp');

if (!address || !signature || !timestamp) {
sendError(
res,
401,
ErrorCode.UNAUTHORIZED,
'Missing required headers: x-wallet-address, x-signature, x-timestamp'
);
return;
}

const addressValidation = StellarAddressSchema.safeParse(address);
if (!addressValidation.success) {
sendError(
res,
400,
ErrorCode.BAD_REQUEST,
'Invalid wallet address format'
);
return;
}

const ts = parseInt(timestamp, 10);
if (isNaN(ts) || Date.now() - ts > SIGNATURE_TIMESTAMP_TOLERANCE_MS) {
sendError(
res,
401,
ErrorCode.UNAUTHORIZED,
'Signature timestamp is invalid or expired'
);
return;
}

const rawCreatorId = req.params.id;
const creatorId = Array.isArray(rawCreatorId) ? rawCreatorId[0] : rawCreatorId;
if (!creatorId) {
sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing creator ID in path');
return;
}

try {
const creatorProfile = await prisma.creatorProfile.findUnique({
where: { id: creatorId },
select: { id: true },
});

if (!creatorProfile) {
sendError(res, 404, ErrorCode.NOT_FOUND, 'Creator not found');
return;
}

const message = buildMessage(req.method, req.originalUrl, creatorId, timestamp);
const signatureBuffer = Buffer.from(signature, 'base64');
const keypair = Keypair.fromPublicKey(address);
const verified = keypair.verify(message, signatureBuffer);

if (!verified) {
sendError(
res,
403,
ErrorCode.FORBIDDEN,
'Invalid signature — wallet does not own this creator'
);
return;
}
} catch (error) {
logger.error({ error, address, creatorId }, 'Signature verification failed');
sendError(
res,
403,
ErrorCode.FORBIDDEN,
'Signature verification failed'
);
return;
}

req.walletAddress = address;
req.creatorId = creatorId;
next();
};
}
Loading
Loading