diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41beda2..267ed48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,17 @@ pnpm exec prisma generate pnpm dev ``` +6. (Optional) Seed deterministic local data — three users with wallets and + creator profiles, sufficient to exercise list, read, and ownership-gated + write flows: + +```bash +pnpm exec ts-node prisma/seed.ts +``` + +See [docs/contributor-seed.md](./docs/contributor-seed.md) for the full +fixture catalogue, reset workflow, and example requests. + ## Verification commands ```bash diff --git a/docs/contributor-seed.md b/docs/contributor-seed.md new file mode 100644 index 0000000..69ab520 --- /dev/null +++ b/docs/contributor-seed.md @@ -0,0 +1,91 @@ +# Local seed and fixture guide + +A clean clone needs three users plus their wallets and creator profiles to +exercise most flows (creator list, profile read, ownership-gated update). +The repo ships an idempotent seed script that creates exactly that state so +contributors do not need to copy production-like data. + +## What gets seeded + +The script creates three deterministic users: + +| Email | Wallet address | Creator handle | Notes | +| ---------------------------- | ---------------- | -------------- | ---------------------------------------------------------------------------------------------- | +| `alice.creator@example.test` | `GA7XLM…ALICE` | `alice` | Verified creator. Use for happy-path creator profile tests. | +| `bob.creator@example.test` | `GA7XLM…BOB00` | `bob` | Unverified creator. Useful for permission and verification flows. | +| `charlie.fan@example.test` | `GA7XLM…CHARLIE` | `charlie` | Fan account (still has a creator profile). Use as the "wrong wallet" in ownership-gated tests. | + +All three share the password `localdev-password-1` (use this in any auth +flow that requires a password). The wallet addresses are obviously fake +placeholders — they keep the database happy without colliding with real +Stellar accounts. + +## Running the seed + +The seed file is at [`prisma/seed.ts`](../prisma/seed.ts) and is **idempotent**: +re-running it updates existing rows instead of failing on the unique +constraints. + +```sh +# Bring the local Postgres container up +pnpm db:up + +# Apply migrations +pnpm migrate + +# Generate the Prisma client +pnpm generate + +# Run the seed +pnpm exec ts-node prisma/seed.ts +``` + +If you want Prisma to call the seed automatically on `prisma migrate reset`, +add this to `package.json`: + +```jsonc +"prisma": { + "schema": "./prisma/schema", + "seed": "ts-node prisma/seed.ts" +} +``` + +## Resetting and re-seeding + +The fastest way to a known-good state is `prisma migrate reset`, which drops +the schema, re-applies migrations, and runs the seed (when the hook above is +in place): + +```sh +pnpm exec prisma migrate reset --force +``` + +Without the hook, do it in two steps: + +```sh +pnpm exec prisma migrate reset --force --skip-seed +pnpm exec ts-node prisma/seed.ts +``` + +## Adding fixtures for a new flow + +When you ship a feature that needs new fixture data: + +1. Add the row(s) to `SEED_USERS` (or a new typed array if the shape + differs) in [`prisma/seed.ts`](../prisma/seed.ts). +2. Use `upsert` with a stable unique key so re-runs stay idempotent. +3. Document any new account in this file's table. +4. Avoid real PII — `*.test` emails and synthetic wallet addresses are fine. + +## Common scenarios + +- **Test the creator list endpoint:** + `GET /api/v1/creators` returns Alice and Bob (Charlie's profile is also + included since the seed gives every user a creator profile). +- **Test ownership-gated profile update:** + `PUT /api/v1/creators/alice/profile` with header + `x-wallet-address: GA7XLM…ALICE` succeeds; the same request with + `x-wallet-address: GA7XLM…CHARLIE` returns `403 FORBIDDEN`. +- **Test wallet-not-mapped path:** + Send any request with a wallet address that is not in `SEED_USERS` — + ownership middleware returns `401 UNAUTHORIZED`. diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..b20c108 --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,90 @@ +# Backend release checklist + +A short, operationally actionable checklist for shipping a backend release +safely. Run through it for every deploy that touches production traffic. Each +item is fast — the goal is fewer broken deploys, not more process. + +> **Tip:** copy the markdown into the release PR description, tick boxes as +> you go, and link the resulting PR from the release announcement. + +## Pre-deploy + +### Repo state + +- [ ] `pnpm install` is clean on a fresh clone. +- [ ] `pnpm lint` passes. +- [ ] `pnpm build` passes (catches typos in `tsconfig.json` paths and Prisma + type drift before runtime). +- [ ] `pnpm exec jest` passes (or the failing suites are documented as + pre-existing in the release notes). +- [ ] No new `console.log` left over from debugging in production code. + +### Configuration + +- [ ] `src/config.ts` schema matches the env vars actually set in production. +- [ ] Any new required env var has a placeholder in `.env.example`. +- [ ] Secrets rotation is **not** part of this release (or, if it is, the + rotation runbook is linked in the release notes). +- [ ] Feature flags or kill switches needed for rollout are wired up and + default to the safe value. + +### Database migrations + +- [ ] `pnpm exec prisma migrate diff --from-migrations ./prisma/schema/migrations --to-schema-datamodel ./prisma/schema --script` produces only the diff you intend. +- [ ] No destructive operations (DROP TABLE / DROP COLUMN / ALTER COLUMN + type-narrow) in the migration. If unavoidable, schedule a separate + maintenance window and link the runbook here. +- [ ] Migrations are **forward-compatible with the previous app version** + so a partial rollout does not break the older replicas. Add columns + as nullable; backfill in a follow-up. +- [ ] Rollback path is documented: which migration to revert and which app + version to redeploy. + +### API / contract changes + +- [ ] No breaking changes to existing endpoint shapes. New fields go in + additively; field removals require a deprecation cycle. +- [ ] Public endpoints have explicit cache-control settings (cf. + `src/constants/creator-public-cache.constants.ts`). +- [ ] Routes that should be authenticated have a guard middleware applied + (e.g. `requireCreatorProfileOwnership`, `adminGuard`). +- [ ] OpenAPI / `tspec` definitions still validate (`pnpm validate-api`). + +## Rollout + +- [ ] Deploy to staging first; smoke-test: + - `GET /api/v1/health` returns `200`. + - `GET /api/v1/health/ready` returns `200` (DB reachable, indexer in sync). + - `GET /api/v1/health/detailed` shows no degraded checks. + - Hit at least one read path (`GET /api/v1/creators`) and one write path + (`PUT /api/v1/creators/:creatorId/profile` with a wallet you own). +- [ ] Deploy production with rolling restart; **do not** fast-fail the old + pods until the new ones report ready. +- [ ] Watch error rate and p95 latency for the first 5 minutes after + rollout completes. If either rises sharply, roll back without trying + to diagnose live. + +## Post-deploy + +- [ ] Verify migrations actually ran: `pnpm exec prisma migrate status` + in the deployed environment shows no pending migrations. +- [ ] Check `GET /api/v1/health/detailed` shows every dependency healthy. +- [ ] Confirm a representative sample of recent requests in the access log + look normal (no 5xx spike, no auth blowback). +- [ ] Update the release notes / changelog with the deployed SHA. + +## Rollback + +- [ ] Re-deploy the previous app version from the last green release tag. +- [ ] If the rollback uncovers a forward-only migration: + 1. Disable the affected code path with the feature flag (if any). + 2. Open a hotfix branch that adapts the new code to the live schema. + 3. **Do not** revert migrations against production data unless it is + genuinely safe (no writes against the new columns yet). +- [ ] Open a follow-up issue describing what failed and how to prevent it. + +## When this checklist needs an update + +If you hit something during a deploy that this checklist did not catch, +add it here in the same release PR. The checklist is the documentation of +the lessons we have already learned the hard way. diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..25dcca0 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,126 @@ +// prisma/seed.ts +// +// Idempotent seed script for local development. Run from the repo root: +// +// pnpm exec tsx prisma/seed.ts # tsx, if installed +// pnpm exec ts-node prisma/seed.ts # ts-node fallback +// +// Or wire it into Prisma's seeding hook by adding to package.json: +// +// "prisma": { +// "schema": "./prisma/schema", +// "seed": "ts-node prisma/seed.ts" +// } +// +// See `docs/contributor-seed.md` for the full setup workflow. + +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +interface SeedUser { + email: string; + firstName: string; + lastName: string; + handle: string; + displayName: string; + bio: string; + walletAddress: string; + isVerified: boolean; +} + +const SEED_USERS: SeedUser[] = [ + { + email: 'alice.creator@example.test', + firstName: 'Alice', + lastName: 'Example', + handle: 'alice', + displayName: 'Alice Example', + bio: 'Verified creator for local development.', + walletAddress: + 'GA7XLM00000000000000000000000000000000000000000000000ALICE', + isVerified: true, + }, + { + email: 'bob.creator@example.test', + firstName: 'Bob', + lastName: 'Example', + handle: 'bob', + displayName: 'Bob Example', + bio: 'Unverified creator for local development.', + walletAddress: + 'GA7XLM000000000000000000000000000000000000000000000000BOB00', + isVerified: false, + }, + { + email: 'charlie.fan@example.test', + firstName: 'Charlie', + lastName: 'Fan', + handle: 'charlie', + displayName: 'Charlie Fan', + bio: 'Fan/holder account for ownership-check testing.', + walletAddress: + 'GA7XLM0000000000000000000000000000000000000000000000CHARLIE', + isVerified: false, + }, +]; + +async function seed() { + const passwordHash = await bcrypt.hash('localdev-password-1', 10); + + for (const user of SEED_USERS) { + const upsertedUser = await prisma.user.upsert({ + where: { email: user.email }, + create: { + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + passwordHash, + emailVerified: true, + emailVerifiedAt: new Date(), + }, + update: { + firstName: user.firstName, + lastName: user.lastName, + }, + }); + + await prisma.stellarWallet.upsert({ + where: { userId: upsertedUser.id }, + create: { + userId: upsertedUser.id, + address: user.walletAddress, + }, + update: { + address: user.walletAddress, + }, + }); + + await prisma.creatorProfile.upsert({ + where: { userId: upsertedUser.id }, + create: { + userId: upsertedUser.id, + handle: user.handle, + displayName: user.displayName, + bio: user.bio, + isVerified: user.isVerified, + }, + update: { + handle: user.handle, + displayName: user.displayName, + bio: user.bio, + isVerified: user.isVerified, + }, + }); + + console.log(`✓ seeded ${user.email} (${user.handle})`); + } +} + +seed() + .catch(error => { + console.error('seed failed:', error); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/middlewares/wallet-ownership.middleware.test.ts b/src/middlewares/wallet-ownership.middleware.test.ts new file mode 100644 index 0000000..c08f0c3 --- /dev/null +++ b/src/middlewares/wallet-ownership.middleware.test.ts @@ -0,0 +1,149 @@ +import { Request, Response, NextFunction } from 'express'; +import { requireCreatorProfileOwnership } from './wallet-ownership.middleware'; +import * as walletOwnership from '../utils/wallet-ownership.utils'; + +jest.mock('../utils/wallet-ownership.utils', () => ({ + checkCreatorProfileOwnership: jest.fn(), +})); + +const mockedCheck = + walletOwnership.checkCreatorProfileOwnership as jest.MockedFunction< + typeof walletOwnership.checkCreatorProfileOwnership + >; + +function buildRes() { + const json = jest.fn(); + const status = jest.fn().mockImplementation(() => ({ json })); + const setHeader = jest.fn(); + return { json, status, setHeader } as unknown as Response & { + status: jest.Mock; + json: jest.Mock; + }; +} + +function buildReq(opts: { + address?: string | string[]; + creatorId?: string; +}): Request { + return { + headers: + opts.address !== undefined ? { 'x-wallet-address': opts.address } : {}, + params: opts.creatorId !== undefined ? { creatorId: opts.creatorId } : {}, + } as unknown as Request; +} + +describe('requireCreatorProfileOwnership', () => { + beforeEach(() => { + mockedCheck.mockReset(); + }); + + it('returns 401 when the wallet header is missing', async () => { + const req = buildReq({ creatorId: 'alice' }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(mockedCheck).not.toHaveBeenCalled(); + }); + + it('returns 400 when the path parameter is missing', async () => { + const req = buildReq({ address: 'GABC' }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when the helper reports an unknown wallet', async () => { + mockedCheck.mockResolvedValue({ + status: 'wallet_not_found', + address: 'GABC', + }); + const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(mockedCheck).toHaveBeenCalledWith('GABC', 'alice'); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 403 when the helper reports forbidden ownership', async () => { + mockedCheck.mockResolvedValue({ + status: 'forbidden', + address: 'GABC', + ownerUserId: 'someone-else', + }); + const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next and attaches owner metadata when access is granted', async () => { + mockedCheck.mockResolvedValue({ + status: 'granted', + ownerUserId: 'user-1', + }); + const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(next).toHaveBeenCalledWith(); + expect((req as Request & { walletAddress?: string }).walletAddress).toBe( + 'GABC' + ); + expect((req as Request & { ownerUserId?: string }).ownerUserId).toBe( + 'user-1' + ); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('uses the first value of an array-form wallet header', async () => { + mockedCheck.mockResolvedValue({ + status: 'granted', + ownerUserId: 'user-1', + }); + const req = buildReq({ + address: ['GFIRST', 'GSECOND'], + creatorId: 'alice', + }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(mockedCheck).toHaveBeenCalledWith('GFIRST', 'alice'); + expect(next).toHaveBeenCalledWith(); + }); + + it('returns 500 when the helper throws', async () => { + mockedCheck.mockRejectedValue(new Error('db down')); + const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const res = buildRes(); + const next = jest.fn(); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/src/middlewares/wallet-ownership.middleware.ts b/src/middlewares/wallet-ownership.middleware.ts new file mode 100644 index 0000000..8235061 --- /dev/null +++ b/src/middlewares/wallet-ownership.middleware.ts @@ -0,0 +1,111 @@ +// src/middlewares/wallet-ownership.middleware.ts +// Express middleware that gates a route on Stellar wallet ownership of the +// resource named by a path parameter. Backed by +// `utils/wallet-ownership.utils.ts` so the same check can be invoked from +// handlers when middleware composition is awkward. +// +// The caller's wallet address is read from the `x-wallet-address` header. +// Once an authenticated session layer lands this middleware should be wired +// to read from `req.user` instead — the helper takes a plain address either +// way. + +import { Request, Response, NextFunction } from 'express'; +import { checkCreatorProfileOwnership } from '../utils/wallet-ownership.utils'; +import { ErrorCode, sendError } from '../utils/api-response.utils'; + +export interface WalletOwnedRequest extends Request { + walletAddress?: string; + ownerUserId?: string; +} + +/** + * Pull the Stellar wallet address from the `x-wallet-address` header. + * Accepts the first value when the header was duplicated. + */ +function readWalletAddress(req: Request): string | undefined { + const raw = req.headers['x-wallet-address']; + if (Array.isArray(raw)) { + return raw[0]?.trim() || undefined; + } + return typeof raw === 'string' ? raw.trim() || undefined : undefined; +} + +/** + * Produce middleware that enforces wallet ownership of the creator profile + * named by `params[paramName]` (defaults to `creatorId`). On success the + * caller's wallet address and the resolved owner user id are attached to the + * request for downstream handlers. + */ +export function requireCreatorProfileOwnership( + paramName: string = 'creatorId' +) { + return async ( + req: WalletOwnedRequest, + res: Response, + next: NextFunction + ): Promise => { + const address = readWalletAddress(req); + if (!address) { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Wallet address is required to access this resource. Send it in the x-wallet-address header.' + ); + return; + } + + const rawParam = req.params[paramName]; + const creatorIdOrHandle = Array.isArray(rawParam) + ? rawParam[0] + : rawParam; + if (!creatorIdOrHandle) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + `Missing required path parameter "${paramName}".` + ); + return; + } + + try { + const verdict = await checkCreatorProfileOwnership( + address, + creatorIdOrHandle + ); + + if (verdict.status === 'wallet_not_found') { + sendError( + res, + 401, + ErrorCode.UNAUTHORIZED, + 'Wallet address is not registered. Map your wallet to a user before accessing gated resources.' + ); + return; + } + + if (verdict.status === 'forbidden') { + sendError( + res, + 403, + ErrorCode.FORBIDDEN, + 'Wallet does not own the requested resource.' + ); + return; + } + + req.walletAddress = address; + req.ownerUserId = verdict.ownerUserId; + next(); + } catch (error) { + console.error('wallet-ownership check failed:', error); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to verify wallet ownership.' + ); + } + }; +} diff --git a/src/modules/creator/creator-profile.service.test.ts b/src/modules/creator/creator-profile.service.test.ts new file mode 100644 index 0000000..d88eeb0 --- /dev/null +++ b/src/modules/creator/creator-profile.service.test.ts @@ -0,0 +1,78 @@ +import { + getCreatorProfile, + upsertCreatorProfile, +} from './creator-profile.service'; +import { UpsertCreatorProfileBodySchema } from './creator-profile.schemas'; + +describe('getCreatorProfile', () => { + it('returns the placeholder profile shape for the requested creator id', async () => { + const result = await getCreatorProfile('creator-1'); + + expect(result).toEqual({ + creatorId: 'creator-1', + displayName: null, + bio: null, + avatarUrl: null, + links: [], + metadata: { + source: 'placeholder', + isProfileComplete: false, + }, + }); + }); + + it('echoes the creator id verbatim so callers can correlate the response', async () => { + const result = await getCreatorProfile('whatever-id-123'); + expect(result.creatorId).toBe('whatever-id-123'); + }); +}); + +describe('upsertCreatorProfile', () => { + it('returns the placeholder envelope with the accepted payload', async () => { + const payload = UpsertCreatorProfileBodySchema.parse({ + displayName: 'Alice Example', + bio: 'Building things.', + links: [{ label: 'site', url: 'https://example.com' }], + }); + + const result = await upsertCreatorProfile('creator-1', payload); + + expect(result).toEqual({ + creatorId: 'creator-1', + acceptedProfile: payload, + metadata: { source: 'placeholder', persisted: false }, + }); + }); + + it('flags persisted=false until backing storage is wired up', async () => { + const payload = UpsertCreatorProfileBodySchema.parse({ + displayName: 'Bob', + }); + + const result = await upsertCreatorProfile('creator-2', payload); + + expect(result.metadata.persisted).toBe(false); + expect(result.metadata.source).toBe('placeholder'); + }); + + it('rejects an invalid payload at the schema boundary, not in the service', () => { + // Service trusts validated input — schema is the gate. This documents + // the boundary so future contributors do not duplicate validation. + const invalid = UpsertCreatorProfileBodySchema.safeParse({ + displayName: 'A', // shorter than 2 chars + }); + expect(invalid.success).toBe(false); + }); + + it('accepts the maximum number of allowed links without truncation', async () => { + const links = Array.from({ length: 8 }, (_, idx) => ({ + label: `link-${idx}`, + url: `https://example.com/${idx}`, + })); + const payload = UpsertCreatorProfileBodySchema.parse({ links }); + + const result = await upsertCreatorProfile('creator-3', payload); + + expect(result.acceptedProfile.links).toHaveLength(8); + }); +}); diff --git a/src/modules/creator/creator.routes.ts b/src/modules/creator/creator.routes.ts index 0900504..f87b4ce 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -9,6 +9,7 @@ import { ROOT as CREATORS_ROOT } from '../../constants/creator.constants'; import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants'; +import { requireCreatorProfileOwnership } from '../../middlewares/wallet-ownership.middleware'; const router = Router(); @@ -30,7 +31,9 @@ const router = Router(); */ router.get( CREATORS_ROOT, - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST] + ), listCreators ); @@ -41,15 +44,22 @@ router.get( */ router.get( '/:creatorId/profile', - cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE]), + cacheControl( + CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE] + ), getCreatorProfileHandler ); /** * @route PUT /api/v1/creators/:creatorId/profile * @desc Upsert creator profile scaffold payload - * @access Public for scaffold (auth follow-up required) + * @access Wallet ownership required — caller must send a `x-wallet-address` + * header tied to the creator profile being updated. */ -router.put('/:creatorId/profile', upsertCreatorProfileHandler); +router.put( + '/:creatorId/profile', + requireCreatorProfileOwnership('creatorId'), + upsertCreatorProfileHandler +); export default router; diff --git a/src/modules/creator/creator.service.test.ts b/src/modules/creator/creator.service.test.ts new file mode 100644 index 0000000..95016a6 --- /dev/null +++ b/src/modules/creator/creator.service.test.ts @@ -0,0 +1,156 @@ +import { getPaginatedCreators } from './creator.service'; +import { prisma } from '../../utils/prisma.utils'; +import { CreatorSortOptions } from './creator.utils'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findMany: jest.fn(), + count: jest.fn(), + }, + }, +})); + +const findMany = prisma.creatorProfile.findMany as jest.Mock; +const count = prisma.creatorProfile.count as jest.Mock; + +const baseSort: CreatorSortOptions = { field: 'createdAt', order: 'desc' }; + +function makeCreator(overrides: Record = {}) { + return { + id: 'creator-1', + userId: 'user-1', + handle: 'alice', + displayName: 'Alice', + bio: null, + avatarUrl: null, + perkSummary: null, + isVerified: false, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + user: { avatar: null, firstName: 'Alice', lastName: 'A' }, + ...overrides, + }; +} + +describe('getPaginatedCreators', () => { + beforeEach(() => { + findMany.mockReset(); + count.mockReset(); + }); + + it('translates page/limit into the correct skip and take', async () => { + findMany.mockResolvedValue([]); + count.mockResolvedValue(0); + + await getPaginatedCreators({ page: 3, limit: 20, sort: baseSort }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 40, // (3 - 1) * 20 + take: 20, + orderBy: { createdAt: 'desc' }, + }) + ); + }); + + it('returns the resolved creators and the matching pagination metadata', async () => { + const creators = [ + makeCreator(), + makeCreator({ id: 'creator-2', handle: 'bob' }), + ]; + findMany.mockResolvedValue(creators); + count.mockResolvedValue(35); + + const result = await getPaginatedCreators({ + page: 2, + limit: 10, + sort: baseSort, + }); + + expect(result.creators).toEqual(creators); + expect(result.meta).toEqual({ + page: 2, + limit: 10, + totalCount: 35, + totalPages: 4, + hasNextPage: true, + hasPrevPage: true, + }); + }); + + it('flags hasNextPage=false when on the last page', async () => { + findMany.mockResolvedValue([makeCreator()]); + count.mockResolvedValue(15); + + const result = await getPaginatedCreators({ + page: 2, + limit: 10, + sort: baseSort, + }); + + expect(result.meta.hasNextPage).toBe(false); + expect(result.meta.hasPrevPage).toBe(true); + expect(result.meta.totalPages).toBe(2); + }); + + it('flags hasPrevPage=false when on the first page', async () => { + findMany.mockResolvedValue([makeCreator()]); + count.mockResolvedValue(15); + + const result = await getPaginatedCreators({ + page: 1, + limit: 10, + sort: baseSort, + }); + + expect(result.meta.hasPrevPage).toBe(false); + expect(result.meta.hasNextPage).toBe(true); + }); + + it('returns zero pages and an empty list when there are no creators', async () => { + findMany.mockResolvedValue([]); + count.mockResolvedValue(0); + + const result = await getPaginatedCreators({ + page: 1, + limit: 10, + sort: baseSort, + }); + + expect(result.creators).toEqual([]); + expect(result.meta).toEqual({ + page: 1, + limit: 10, + totalCount: 0, + totalPages: 0, + hasNextPage: false, + hasPrevPage: false, + }); + }); + + it('runs findMany and count in parallel', async () => { + findMany.mockResolvedValue([]); + count.mockResolvedValue(0); + + await getPaginatedCreators({ page: 1, limit: 10, sort: baseSort }); + + expect(findMany).toHaveBeenCalledTimes(1); + expect(count).toHaveBeenCalledTimes(1); + }); + + it('applies the requested sort field and order', async () => { + findMany.mockResolvedValue([]); + count.mockResolvedValue(0); + + await getPaginatedCreators({ + page: 1, + limit: 10, + sort: { field: 'displayName', order: 'asc' }, + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { displayName: 'asc' } }) + ); + }); +}); diff --git a/src/utils/wallet-ownership.utils.test.ts b/src/utils/wallet-ownership.utils.test.ts new file mode 100644 index 0000000..2b76d3d --- /dev/null +++ b/src/utils/wallet-ownership.utils.test.ts @@ -0,0 +1,92 @@ +import { checkCreatorProfileOwnership } from './wallet-ownership.utils'; +import { prisma } from './prisma.utils'; + +jest.mock('./prisma.utils', () => ({ + prisma: { + stellarWallet: { + findUnique: jest.fn(), + }, + creatorProfile: { + findFirst: jest.fn(), + }, + }, +})); + +const walletFindUnique = prisma.stellarWallet.findUnique as jest.Mock; +const profileFindFirst = prisma.creatorProfile.findFirst as jest.Mock; + +describe('checkCreatorProfileOwnership', () => { + beforeEach(() => { + walletFindUnique.mockReset(); + profileFindFirst.mockReset(); + }); + + it('returns wallet_not_found when the address is empty', async () => { + const result = await checkCreatorProfileOwnership(' ', 'creator-1'); + expect(result).toEqual({ status: 'wallet_not_found', address: '' }); + expect(walletFindUnique).not.toHaveBeenCalled(); + expect(profileFindFirst).not.toHaveBeenCalled(); + }); + + it('returns wallet_not_found when the wallet is unknown', async () => { + walletFindUnique.mockResolvedValue(null); + profileFindFirst.mockResolvedValue({ userId: 'user-1' }); + + const result = await checkCreatorProfileOwnership('GABC', 'creator-1'); + + expect(result).toEqual({ status: 'wallet_not_found', address: 'GABC' }); + }); + + it('returns forbidden when the creator profile does not exist', async () => { + walletFindUnique.mockResolvedValue({ userId: 'user-1' }); + profileFindFirst.mockResolvedValue(null); + + const result = await checkCreatorProfileOwnership( + 'GABC', + 'missing-creator' + ); + + expect(result).toEqual({ + status: 'forbidden', + address: 'GABC', + ownerUserId: null, + }); + }); + + it('returns forbidden when the wallet belongs to a different user', async () => { + walletFindUnique.mockResolvedValue({ userId: 'wallet-user' }); + profileFindFirst.mockResolvedValue({ userId: 'profile-user' }); + + const result = await checkCreatorProfileOwnership('GABC', 'creator-1'); + + expect(result).toEqual({ + status: 'forbidden', + address: 'GABC', + ownerUserId: 'profile-user', + }); + }); + + it('grants access when the wallet owns the profile', async () => { + walletFindUnique.mockResolvedValue({ userId: 'shared-user' }); + profileFindFirst.mockResolvedValue({ userId: 'shared-user' }); + + const result = await checkCreatorProfileOwnership('GABC', 'creator-1'); + + expect(result).toEqual({ + status: 'granted', + ownerUserId: 'shared-user', + }); + }); + + it('looks up the creator by id or by handle', async () => { + walletFindUnique.mockResolvedValue({ userId: 'shared-user' }); + profileFindFirst.mockResolvedValue({ userId: 'shared-user' }); + + await checkCreatorProfileOwnership('GABC', 'alice'); + + expect(profileFindFirst).toHaveBeenCalledWith({ + where: { OR: [{ id: 'alice' }, { handle: 'alice' }] }, + select: { userId: true }, + }); + }); +}); diff --git a/src/utils/wallet-ownership.utils.ts b/src/utils/wallet-ownership.utils.ts new file mode 100644 index 0000000..6833b09 --- /dev/null +++ b/src/utils/wallet-ownership.utils.ts @@ -0,0 +1,68 @@ +// src/utils/wallet-ownership.utils.ts +// Reusable wallet-ownership access checks. +// +// Gated resources need a single, predictable answer to the question +// "does this caller's wallet own this resource?" so handlers and middleware +// stop reimplementing the check inline. Functions here return small typed +// results that the middleware turns into HTTP responses; handlers can call +// them directly when they need finer-grained control. + +import { prisma } from './prisma.utils'; + +/** + * Outcome of a wallet-ownership check. Distinguishes the three states the + * middleware needs to surface as different HTTP responses: + * + * - `granted` — the caller's wallet owns the resource. + * - `wallet_not_found` — the caller has no wallet on file. Treated as 401 + * so clients know to map a wallet first. + * - `forbidden` — caller has a wallet but it does not own the resource. + */ +export type WalletOwnershipResult = + | { status: 'granted'; ownerUserId: string } + | { status: 'wallet_not_found'; address: string } + | { status: 'forbidden'; address: string; ownerUserId: string | null }; + +/** + * Check whether the supplied Stellar address owns the given creator profile. + * + * Resolves both lookups in parallel — the wallet record (so we know the + * caller is a known user) and the creator profile (so we know who the owner + * is) — and returns a typed verdict. + */ +export async function checkCreatorProfileOwnership( + address: string, + creatorIdOrHandle: string +): Promise { + const trimmedAddress = address.trim(); + if (!trimmedAddress) { + return { status: 'wallet_not_found', address: trimmedAddress }; + } + + const [walletRecord, creatorProfile] = await Promise.all([ + prisma.stellarWallet.findUnique({ + where: { address: trimmedAddress }, + select: { userId: true }, + }), + prisma.creatorProfile.findFirst({ + where: { + OR: [{ id: creatorIdOrHandle }, { handle: creatorIdOrHandle }], + }, + select: { userId: true }, + }), + ]); + + if (!walletRecord) { + return { status: 'wallet_not_found', address: trimmedAddress }; + } + + if (!creatorProfile || creatorProfile.userId !== walletRecord.userId) { + return { + status: 'forbidden', + address: trimmedAddress, + ownerUserId: creatorProfile?.userId ?? null, + }; + } + + return { status: 'granted', ownerUserId: walletRecord.userId }; +}