diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 2921daa..c2becbc 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -15,4 +15,17 @@ model CreatorProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) priceSnapshot CreatorPriceSnapshot? + posts CreatorPost[] +} + +model CreatorPost { + id String @id @default(cuid()) + content String + creatorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + @@index([creatorId, createdAt]) } diff --git a/prisma/schema/migrations/20260729090000_add_creator_posts/migration.sql b/prisma/schema/migrations/20260729090000_add_creator_posts/migration.sql new file mode 100644 index 0000000..8b1e960 --- /dev/null +++ b/prisma/schema/migrations/20260729090000_add_creator_posts/migration.sql @@ -0,0 +1,14 @@ +CREATE TABLE "CreatorPost" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "CreatorPost_pkey" PRIMARY KEY ("id"), + CONSTRAINT "CreatorPost_creatorId_fkey" + FOREIGN KEY ("creatorId") REFERENCES "CreatorProfile"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "CreatorPost_creatorId_createdAt_idx" +ON "CreatorPost"("creatorId", "createdAt"); diff --git a/src/config.schema.ts b/src/config.schema.ts index f4e4678..cdeecc5 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -148,6 +148,7 @@ export const envSchema = z 'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)' ) .default('https://soroban-testnet.stellar.org'), + STELLAR_AUTH_SECRET: optionalNonEmptyString, // Ownership snapshot cleanup job OWNERSHIP_SNAPSHOT_TABLE_NAME: z diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index c546ac2..c207198 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -13,6 +13,8 @@ export const ErrorCode = { RATE_LIMIT: 'RATE_LIMIT', PRISMA_ERROR: 'DATABASE_ERROR', JWT_ERROR: 'TOKEN_ERROR', + INSUFFICIENT_BALANCE: 'insufficient_balance', + NOT_A_CREATOR: 'not_a_creator', } as const; export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; diff --git a/src/jobs/key-balance-sync.worker.test.ts b/src/jobs/key-balance-sync.worker.test.ts new file mode 100644 index 0000000..9e9e7c5 --- /dev/null +++ b/src/jobs/key-balance-sync.worker.test.ts @@ -0,0 +1,100 @@ +import { + runKeyBalanceSync, + type BalanceRecord, + type KeyBalanceSyncDependencies, +} from './key-balance-sync.worker'; + +function createDependencies( + records: BalanceRecord[], + chainBalances: Record +) { + const updateBalance = jest.fn().mockResolvedValue(undefined); + const logCorrection = jest.fn(); + const dependencies: KeyBalanceSyncDependencies = { + listBalances: jest.fn().mockResolvedValue(records), + getOnChainBalance: jest.fn((owner, creator) => + Promise.resolve(chainBalances[`${owner}:${creator}`] ?? null) + ), + updateBalance, + logCorrection, + }; + return { dependencies, updateBalance, logCorrection }; +} + +describe('key balance sync worker', () => { + it('updates stale balances and logs old and new values', async () => { + const { dependencies, updateBalance, logCorrection } = createDependencies( + [ + { + id: '1', + ownerAddress: 'wallet-a', + creatorId: 'creator-a', + balance: 5, + }, + ], + { 'wallet-a:creator-a': 8 } + ); + + await expect(runKeyBalanceSync(dependencies)).resolves.toBe(1); + expect(updateBalance).toHaveBeenCalledWith('1', 8); + expect(logCorrection).toHaveBeenCalledWith( + expect.objectContaining({ oldBalance: 5, newBalance: 8 }) + ); + }); + + it('does not write when the database already matches the chain', async () => { + const { dependencies, updateBalance, logCorrection } = createDependencies( + [ + { + id: '1', + ownerAddress: 'wallet-a', + creatorId: 'creator-a', + balance: 5, + }, + ], + { 'wallet-a:creator-a': 5 } + ); + await expect(runKeyBalanceSync(dependencies)).resolves.toBe(0); + expect(updateBalance).not.toHaveBeenCalled(); + expect(logCorrection).not.toHaveBeenCalled(); + }); + + it('sets a missing on-chain balance to zero', async () => { + const { dependencies, updateBalance } = createDependencies( + [ + { + id: '1', + ownerAddress: 'wallet-a', + creatorId: 'creator-a', + balance: 5, + }, + ], + { 'wallet-a:creator-a': null } + ); + await runKeyBalanceSync(dependencies); + expect(updateBalance).toHaveBeenCalledWith('1', 0); + }); + + it('processes multiple wallets independently', async () => { + const { dependencies, updateBalance } = createDependencies( + [ + { + id: '1', + ownerAddress: 'wallet-a', + creatorId: 'creator-a', + balance: 1, + }, + { + id: '2', + ownerAddress: 'wallet-b', + creatorId: 'creator-b', + balance: 2, + }, + ], + { 'wallet-a:creator-a': 3, 'wallet-b:creator-b': 4 } + ); + await expect(runKeyBalanceSync(dependencies)).resolves.toBe(2); + expect(updateBalance).toHaveBeenNthCalledWith(1, '1', 3); + expect(updateBalance).toHaveBeenNthCalledWith(2, '2', 4); + }); +}); diff --git a/src/jobs/key-balance-sync.worker.ts b/src/jobs/key-balance-sync.worker.ts new file mode 100644 index 0000000..5be7d89 --- /dev/null +++ b/src/jobs/key-balance-sync.worker.ts @@ -0,0 +1,75 @@ +import { prisma } from '../utils/prisma.utils'; +import { logger } from '../utils/logger.utils'; + +export interface BalanceRecord { + id: string; + ownerAddress: string; + creatorId: string; + balance: { toString(): string } | number; +} + +export interface KeyBalanceSyncDependencies { + listBalances(): Promise; + getOnChainBalance( + ownerAddress: string, + creatorId: string + ): Promise; + updateBalance(id: string, balance: number): Promise; + logCorrection(fields: { + ownerAddress: string; + creatorId: string; + oldBalance: number; + newBalance: number; + }): void; +} + +export async function runKeyBalanceSync( + dependencies: KeyBalanceSyncDependencies +): Promise { + const records = await dependencies.listBalances(); + let corrected = 0; + + for (const record of records) { + const oldBalance = Number(record.balance.toString()); + const newBalance = + (await dependencies.getOnChainBalance( + record.ownerAddress, + record.creatorId + )) ?? 0; + if (oldBalance === newBalance) continue; + + await dependencies.updateBalance(record.id, newBalance); + dependencies.logCorrection({ + ownerAddress: record.ownerAddress, + creatorId: record.creatorId, + oldBalance, + newBalance, + }); + corrected += 1; + } + + return corrected; +} + +export interface OnChainKeyBalanceReader { + getBalance(ownerAddress: string, creatorId: string): Promise; +} + +export async function syncKeyBalances( + reader: OnChainKeyBalanceReader +): Promise { + return runKeyBalanceSync({ + listBalances: () => prisma.keyOwnership.findMany(), + getOnChainBalance: (ownerAddress, creatorId) => + reader.getBalance(ownerAddress, creatorId), + updateBalance: async (id, balance) => { + await prisma.keyOwnership.update({ + where: { id }, + data: { balance }, + }); + }, + logCorrection: fields => { + logger.info(fields, 'Corrected stale key balance from on-chain state'); + }, + }); +} diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index 3e434e7..e78c58f 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -4,9 +4,11 @@ import { httpRegisterUserWithPassword, httpRefreshToken, } from './auth.controllers'; +import { httpStellarChallenge } from './stellar-challenge.controller'; const authRouter = Router(); +authRouter.post('/challenge', httpStellarChallenge); authRouter.post('/login', httpLogin); authRouter.post('/register', httpRegisterUserWithPassword); authRouter.post('/refresh', httpRefreshToken); diff --git a/src/modules/auth/stellar-challenge.controller.ts b/src/modules/auth/stellar-challenge.controller.ts new file mode 100644 index 0000000..0d5a26a --- /dev/null +++ b/src/modules/auth/stellar-challenge.controller.ts @@ -0,0 +1,66 @@ +import type { Request, Response } from 'express'; +import { + Account, + Keypair, + Memo, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-base'; +import { randomBytes } from 'crypto'; +import { envConfig } from '../../config'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { ErrorCode } from '../../constants/error.constants'; +import { sendError, sendSuccess } from '../../utils/api-response.utils'; + +const serverKeypair = envConfig.STELLAR_AUTH_SECRET + ? Keypair.fromSecret(envConfig.STELLAR_AUTH_SECRET) + : Keypair.random(); + +export function getChallengeServerPublicKey(): string { + return serverKeypair.publicKey(); +} + +export async function httpStellarChallenge( + req: Request, + res: Response +): Promise { + const walletAddress = req.body?.wallet_address; + if (!StellarAddressSchema.safeParse(walletAddress).success) { + sendError( + res, + 422, + ErrorCode.VALIDATION_ERROR, + 'A valid Stellar wallet_address is required' + ); + return; + } + + const nonce = randomBytes(12).toString('hex'); + const networkPassphrase = + envConfig.STELLAR_NETWORK === 'mainnet' + ? Networks.PUBLIC + : Networks.TESTNET; + const webAuthDomain = new URL(envConfig.BACKEND_URL).host; + const transaction = new TransactionBuilder( + new Account(walletAddress, '-1'), + { fee: '0', networkPassphrase } + ) + .addOperation( + Operation.manageData({ + name: 'web_auth_domain', + value: webAuthDomain, + source: serverKeypair.publicKey(), + }) + ) + .addMemo(Memo.text(nonce)) + .setTimeout(300) + .build(); + transaction.sign(serverKeypair); + + sendSuccess(res, { + transaction: transaction.toXDR(), + server_public_key: serverKeypair.publicKey(), + network_passphrase: networkPassphrase, + }); +} diff --git a/src/modules/auth/stellar-challenge.integration.test.ts b/src/modules/auth/stellar-challenge.integration.test.ts new file mode 100644 index 0000000..7df264a --- /dev/null +++ b/src/modules/auth/stellar-challenge.integration.test.ts @@ -0,0 +1,65 @@ +import express from 'express'; +import request from 'supertest'; +import { + Keypair, + Networks, + Transaction, + TransactionBuilder, +} from '@stellar/stellar-base'; +import { + getChallengeServerPublicKey, + httpStellarChallenge, +} from './stellar-challenge.controller'; + +const app = express(); +app.use(express.json()); +app.post('/api/v1/auth/challenge', httpStellarChallenge); + +describe('wallet authentication challenge', () => { + it('returns a signed XDR with web_auth_domain and a unique nonce memo', async () => { + const walletAddress = Keypair.random().publicKey(); + const first = await request(app) + .post('/api/v1/auth/challenge') + .send({ wallet_address: walletAddress }); + const second = await request(app) + .post('/api/v1/auth/challenge') + .send({ wallet_address: walletAddress }); + + expect(first.status).toBe(200); + const transaction = TransactionBuilder.fromXDR( + first.body.data.transaction, + Networks.TESTNET + ) as Transaction; + expect(transaction.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'manageData', + name: 'web_auth_domain', + }), + ]) + ); + expect(transaction.memo.type).toBe('text'); + expect(transaction.memo.value).toBeTruthy(); + const secondTransaction = TransactionBuilder.fromXDR( + second.body.data.transaction, + Networks.TESTNET + ) as Transaction; + expect(secondTransaction.memo.value).not.toBe(transaction.memo.value); + + expect(transaction.signatures).toHaveLength(1); + const server = Keypair.fromPublicKey(getChallengeServerPublicKey()); + expect( + server.verify( + transaction.hash(), + transaction.signatures[0].signature() + ) + ).toBe(true); + }); + + it('returns 422 for an invalid wallet address', async () => { + const response = await request(app) + .post('/api/v1/auth/challenge') + .send({ wallet_address: 'invalid' }); + expect(response.status).toBe(422); + }); +}); diff --git a/src/modules/creator/buy.controller.ts b/src/modules/creator/buy.controller.ts new file mode 100644 index 0000000..1709101 --- /dev/null +++ b/src/modules/creator/buy.controller.ts @@ -0,0 +1,54 @@ +import type { Response } from 'express'; +import { z } from 'zod'; +import type { StellarSignedRequest } from '../../middlewares/stellar-signature.middleware'; +import { ErrorCode } from '../../constants/error.constants'; +import { + sendError, + sendSuccess, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { buyGateway } from './buy.service'; + +const buySchema = z.object({ + quantity: z.number().int().positive(), + key_cost_xlm: z.number().nonnegative(), + fee_xlm: z.number().nonnegative().default(0), +}); + +export async function httpBuyCreatorKey( + req: StellarSignedRequest, + res: Response +): Promise { + const parsed = buySchema.safeParse(req.body); + if (!parsed.success) { + sendError( + res, + 422, + ErrorCode.VALIDATION_ERROR, + 'Invalid buy request', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const walletAddress = req.walletAddress!; + const required = + parsed.data.key_cost_xlm * parsed.data.quantity + parsed.data.fee_xlm; + const balance = await buyGateway.getXlmBalance(walletAddress); + if (balance < required) { + sendError( + res, + 422, + ErrorCode.INSUFFICIENT_BALANCE, + 'Wallet does not have enough XLM for the purchase and fees' + ); + return; + } + + const result = await buyGateway.submitBuy({ + walletAddress, + creatorId: String(req.params.id), + quantity: parsed.data.quantity, + }); + sendSuccess(res, result, 200); +} diff --git a/src/modules/creator/buy.integration.test.ts b/src/modules/creator/buy.integration.test.ts new file mode 100644 index 0000000..6dd263c --- /dev/null +++ b/src/modules/creator/buy.integration.test.ts @@ -0,0 +1,61 @@ +import express from 'express'; +import request from 'supertest'; +import { Keypair } from '@stellar/stellar-base'; +import { requireStellarSignature } from '../../middlewares/stellar-signature.middleware'; +import { buildAuthHeaders } from '../../utils/test/auth-request.utils'; +import { httpBuyCreatorKey } from './buy.controller'; +import { buyGateway } from './buy.service'; + +const app = express(); +app.use(express.json()); +app.post( + '/api/v1/creators/:id/buy', + requireStellarSignature(), + httpBuyCreatorKey +); + +describe('POST creator buy balance guard', () => { + const wallet = Keypair.random(); + const body = { quantity: 1, key_cost_xlm: 10, fee_xlm: 1 }; + + afterEach(() => jest.restoreAllMocks()); + + it('returns 422 without submitting when the wallet is underfunded', async () => { + jest.spyOn(buyGateway, 'getXlmBalance').mockResolvedValue(10.99); + const submit = jest + .spyOn(buyGateway, 'submitBuy') + .mockResolvedValue({ transactionHash: 'unused' }); + + const response = await request(app) + .post('/api/v1/creators/creator-1/buy') + .set(buildAuthHeaders(body, wallet)) + .send(body); + + expect(response.status).toBe(422); + expect(response.body.error.code).toBe('insufficient_balance'); + expect(submit).not.toHaveBeenCalled(); + }); + + it('submits when the balance exactly equals cost plus fees', async () => { + jest.spyOn(buyGateway, 'getXlmBalance').mockResolvedValue(11); + const submit = jest + .spyOn(buyGateway, 'submitBuy') + .mockResolvedValue({ transactionHash: 'tx-1' }); + + const response = await request(app) + .post('/api/v1/creators/creator-1/buy') + .set(buildAuthHeaders(body, wallet)) + .send(body); + + expect(response.status).toBe(200); + expect(response.body.data.transactionHash).toBe('tx-1'); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('returns 401 when signature authentication is missing', async () => { + const response = await request(app) + .post('/api/v1/creators/creator-1/buy') + .send(body); + expect(response.status).toBe(401); + }); +}); diff --git a/src/modules/creator/buy.service.ts b/src/modules/creator/buy.service.ts new file mode 100644 index 0000000..4148d7c --- /dev/null +++ b/src/modules/creator/buy.service.ts @@ -0,0 +1,17 @@ +export interface BuyGateway { + getXlmBalance(walletAddress: string): Promise; + submitBuy(input: { + walletAddress: string; + creatorId: string; + quantity: number; + }): Promise<{ transactionHash: string }>; +} + +export const buyGateway: BuyGateway = { + async getXlmBalance() { + throw new Error('Horizon balance adapter is not configured'); + }, + async submitBuy() { + throw new Error('Stellar buy adapter is not configured'); + }, +}; diff --git a/src/modules/creator/post.controller.ts b/src/modules/creator/post.controller.ts new file mode 100644 index 0000000..b34191f --- /dev/null +++ b/src/modules/creator/post.controller.ts @@ -0,0 +1,89 @@ +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import type { StellarSignedRequest } from '../../middlewares/stellar-signature.middleware'; +import { ErrorCode } from '../../constants/error.constants'; +import { prisma } from '../../utils/prisma.utils'; +import { + sendError, + sendSuccess, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; + +const postSchema = z.object({ + content: z.string().trim().min(1).max(5000), +}); + +function serializePost( + post: { + id: string; + content: string; + createdAt: Date; + }, + walletAddress: string | null +) { + return { + id: post.id, + content: post.content, + creator_wallet: walletAddress, + created_at: post.createdAt.toISOString(), + }; +} + +export async function httpCreatePost( + req: StellarSignedRequest, + res: Response +): Promise { + const parsed = postSchema.safeParse(req.body); + if (!parsed.success) { + sendError( + res, + 422, + ErrorCode.VALIDATION_ERROR, + 'Post content is required', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const creatorId = String(req.params.id); + const creator = await prisma.creatorProfile.findFirst({ + where: { + id: creatorId, + user: { stellarWallet: { address: req.walletAddress } }, + }, + }); + if (!creator) { + sendError( + res, + 403, + ErrorCode.NOT_A_CREATOR, + 'Authenticated wallet is not the requested creator' + ); + return; + } + + const post = await prisma.creatorPost.create({ + data: { creatorId: creator.id, content: parsed.data.content }, + }); + sendSuccess(res, serializePost(post, req.walletAddress!), 201); +} + +export async function httpListPosts( + req: Request, + res: Response +): Promise { + const creatorId = String(req.params.id); + const creator = await prisma.creatorProfile.findUnique({ + where: { id: creatorId }, + include: { user: { include: { stellarWallet: true } } }, + }); + const posts = await prisma.creatorPost.findMany({ + where: { creatorId }, + orderBy: { createdAt: 'desc' }, + }); + const walletAddress = creator?.user.stellarWallet?.address ?? null; + sendSuccess( + res, + posts.map(post => serializePost(post, walletAddress)) + ); +} diff --git a/src/modules/creator/post.integration.test.ts b/src/modules/creator/post.integration.test.ts new file mode 100644 index 0000000..901bb6d --- /dev/null +++ b/src/modules/creator/post.integration.test.ts @@ -0,0 +1,94 @@ +import express from 'express'; +import request from 'supertest'; +import { Keypair } from '@stellar/stellar-base'; +import { buildAuthHeaders } from '../../utils/test/auth-request.utils'; +import { requireStellarSignature } from '../../middlewares/stellar-signature.middleware'; + +const mockCreatorProfile = { findFirst: jest.fn(), findUnique: jest.fn() }; +const mockCreatorPost = { create: jest.fn(), findMany: jest.fn() }; +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: mockCreatorProfile, + creatorPost: mockCreatorPost, + }, +})); + +import { httpCreatePost, httpListPosts } from './post.controller'; + +const app = express(); +app.use(express.json()); +app.post( + '/api/v1/creators/:id/posts', + requireStellarSignature(), + httpCreatePost +); +app.get('/api/v1/creators/:id/posts', httpListPosts); + +describe('creator post integration', () => { + const wallet = Keypair.random(); + const creator = { + id: 'creator-1', + user: { stellarWallet: { address: wallet.publicKey() } }, + }; + const stored = { + id: 'post-1', + content: 'A first post', + createdAt: new Date('2026-07-29T00:00:00Z'), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockCreatorProfile.findFirst.mockResolvedValue(creator); + mockCreatorProfile.findUnique.mockResolvedValue(creator); + mockCreatorPost.create.mockResolvedValue(stored); + mockCreatorPost.findMany.mockResolvedValue([stored]); + }); + + it('persists a post, returns required fields, and lists it', async () => { + const body = { content: stored.content }; + const created = await request(app) + .post('/api/v1/creators/creator-1/posts') + .set(buildAuthHeaders(body, wallet)) + .send(body); + + expect(created.status).toBe(201); + expect(created.body.data).toEqual( + expect.objectContaining({ + id: 'post-1', + content: stored.content, + creator_wallet: wallet.publicKey(), + created_at: stored.createdAt.toISOString(), + }) + ); + expect(mockCreatorPost.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: { creatorId: 'creator-1', content: stored.content }, + }) + ); + + const listed = await request(app).get('/api/v1/creators/creator-1/posts'); + expect(listed.body.data).toContainEqual( + expect.objectContaining({ id: 'post-1' }) + ); + }); + + it('rejects a wallet that is not registered as the creator', async () => { + mockCreatorProfile.findFirst.mockResolvedValue(null); + const body = { content: 'Not allowed' }; + const response = await request(app) + .post('/api/v1/creators/creator-1/posts') + .set(buildAuthHeaders(body, wallet)) + .send(body); + expect(response.status).toBe(403); + expect(response.body.error.code).toBe('not_a_creator'); + }); + + it('rejects empty content with 422', async () => { + const body = { content: ' ' }; + const response = await request(app) + .post('/api/v1/creators/creator-1/posts') + .set(buildAuthHeaders(body, wallet)) + .send(body); + expect(response.status).toBe(422); + }); +}); diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index 8baf367..9c0558e 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -13,6 +13,9 @@ import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-route import { createCreatorReadMetricsMiddleware } from '../../utils/creator-read-metrics.utils'; import { normalizeTrailingSlash } from '../../middlewares/trailing-slash-normalizer.middleware'; import { validateCreatorParam } from '../../middlewares/creator-param.middleware'; +import { requireStellarSignature } from '../../middlewares/stellar-signature.middleware'; +import { httpBuyCreatorKey } from '../creator/buy.controller'; +import { httpCreatePost, httpListPosts } from '../creator/post.controller'; const creatorsRouter = Router(); @@ -21,6 +24,20 @@ const creatorsRouter = Router(); // Scoped to this router to avoid side-effects on other route groups. creatorsRouter.use(normalizeTrailingSlash); +creatorsRouter.post( + '/:id/buy', + validateCreatorParam('id'), + requireStellarSignature(), + httpBuyCreatorKey +); +creatorsRouter.get('/:id/posts', validateCreatorParam('id'), httpListPosts); +creatorsRouter.post( + '/:id/posts', + validateCreatorParam('id'), + requireStellarSignature(), + httpCreatePost +); + /** * GET /api/v1/creators *