diff --git a/README.md b/README.md index ebd0163..caf4681 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,19 @@ POST /creators/claim - Claim a slug (JWT) PATCH /creators/me - Update profile (JWT) PATCH /creators/me/splits - Update splits (JWT) +Reserved slugs (api, admin, dashboard, onboarding, settings, login, logout, +auth, support, help, about, terms, privacy, static, _next, novatip — see +RESERVED_SLUGS in creator.service.ts) can't be claimed and are reported as +unavailable by /creators/check/:slug. Run `npm run check:reserved-slugs` +against a given environment to find existing creators who already hold one +of these slugs from before the list existed. + +POST /creators/claim returns 409 when the slug or jarId is already taken, +including when two requests race for the same one — the database's unique +constraint is the real guard, not just the pre-check. The response body's +error.code is "SLUG_TAKEN" or "JARID_TAKEN" so callers can tell which +field conflicted. + GET /qr/:slug - QR code SVG GET /qr/:slug/png - QR code PNG download GET /resolve/:slug - Full tip-page data @@ -69,6 +82,12 @@ GET /analytics/timeseries - Daily breakdown ?days=30 (JWT) GET /analytics/top-supporters- Ranked supporters ?limit=10 (JWT) GET /analytics/recent - Live tip feed ?limit=20 (JWT) +/analytics/timeseries always returns exactly `days` points, oldest first, one +per UTC calendar day (00:00–23:59:59 UTC) up to and including today. Days +with no tips are included with tipCount: 0 and amountRaw: "0" rather than +omitted, so charts can plot the series directly without gap-filling. Day +boundaries are UTC, not the requesting client's local time zone. + GET /webhooks - List webhooks (JWT) POST /webhooks - Register webhook (JWT) DELETE /webhooks/:id - Remove webhook (JWT) @@ -150,6 +169,7 @@ npm run db:generate - regenerate Prisma client npm run db:migrate - apply migrations (dev) npm run db:migrate:deploy - apply migrations (production) npm run db:studio - open Prisma Studio +npm run check:reserved-slugs - report existing creators holding a reserved slug ## License diff --git a/package.json b/package.json index 5b47fab..bb04b4c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "db:migrate": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", "db:studio": "prisma studio", - "db:seed": "tsx prisma/seed.ts" + "db:seed": "tsx prisma/seed.ts", + "check:reserved-slugs": "tsx scripts/check-reserved-slugs.ts" }, "dependencies": { "@fastify/cors": "^9.0.1", @@ -27,6 +28,7 @@ "@fastify/rate-limit": "^9.1.0", "@novatip/sdk": "file:../novatip-sdk", "@prisma/client": "^5.15.0", + "@stellar/stellar-sdk": "^12.3.0", "dotenv": "^16.4.5", "fastify": "^4.28.0", "ioredis": "^5.4.1", diff --git a/scripts/check-reserved-slugs.ts b/scripts/check-reserved-slugs.ts new file mode 100644 index 0000000..e318c36 --- /dev/null +++ b/scripts/check-reserved-slugs.ts @@ -0,0 +1,41 @@ +/** + * scripts/check-reserved-slugs.ts + * + * Reports any existing creators whose slug now falls in RESERVED_SLUGS + * (src/modules/creator/creator.service.ts). Those slugs were claimable + * before the reserved-slug list existed, so run this once against + * production before/after deploying that change and note the results + * in the PR — the list only blocks new claims, it doesn't touch rows + * that already hold one of these slugs. + * + * npx tsx scripts/check-reserved-slugs.ts + */ + +import { PrismaClient } from "@prisma/client"; +import { RESERVED_SLUGS } from "../src/modules/creator/creator.service.js"; + +const db = new PrismaClient(); + +async function main(): Promise { + const creators = await db.creator.findMany({ + where: { slug: { in: [...RESERVED_SLUGS] } }, + select: { id: true, slug: true, walletAddress: true, createdAt: true }, + }); + + if (creators.length === 0) { + console.info("No existing creators hold a reserved slug."); + return; + } + + console.warn(`${creators.length} existing creator(s) hold a now-reserved slug:`); + for (const creator of creators) { + console.warn(` ${creator.slug} — ${creator.id} (${creator.walletAddress}, created ${creator.createdAt.toISOString()})`); + } +} + +main() + .catch((err) => { + console.error("check-reserved-slugs failed:", err); + process.exitCode = 1; + }) + .finally(() => db.$disconnect()); diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts index b3397d0..b69d15e 100644 --- a/src/modules/analytics/analytics.service.ts +++ b/src/modules/analytics/analytics.service.ts @@ -82,9 +82,25 @@ interface TimeSeriesRow { amountRaw: string; } +/** + * Midnight UTC for the given instant, as a Date. + * "ledgerAt" is stored as TIMESTAMP(3) with no time zone, and Prisma reads/ + * writes those naive values as UTC — so anchoring the window to UTC days here + * keeps this in lockstep with the date_trunc('day', "ledgerAt") grouping below. + */ +function utcMidnight(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +} + /** * Daily tip counts and amounts over the last N days. * Default window: 30 days. + * + * Days are UTC calendar days (00:00–23:59:59 UTC), not the caller's local + * days — see utcMidnight() above. The window always returns exactly `days` + * points, oldest first, one per UTC day up to and including today; days with + * no tips are filled in with tipCount: 0 and amountRaw: "0" rather than + * omitted. */ export async function getTimeSeries( creatorId: string, @@ -94,8 +110,9 @@ export async function getTimeSeries( const cached = await cacheGet(key); if (cached) return cached; - const since = new Date(); - since.setDate(since.getDate() - days); + const today = utcMidnight(new Date()); + const windowStart = new Date(today); + windowStart.setUTCDate(windowStart.getUTCDate() - (days - 1)); const rows = await db.$queryRaw` SELECT @@ -103,16 +120,32 @@ export async function getTimeSeries( COUNT(*) AS "tipCount", SUM(amount::numeric)::text AS "amountRaw" FROM "Tip" - WHERE "creatorId" = ${creatorId} AND "ledgerAt" >= ${since} + WHERE "creatorId" = ${creatorId} AND "ledgerAt" >= ${windowStart} GROUP BY date_trunc('day', "ledgerAt") ORDER BY date_trunc('day', "ledgerAt") ASC `; - const result: TimeSeriesPoint[] = rows.map((row) => ({ - date: row.date.toISOString().slice(0, 10), - tipCount: Number(row.tipCount), - amountRaw: row.amountRaw, - })); + const byDate = new Map(); + for (const row of rows) { + byDate.set(row.date.toISOString().slice(0, 10), { + tipCount: Number(row.tipCount), + amountRaw: row.amountRaw, + }); + } + + const result: TimeSeriesPoint[] = []; + for (let i = 0; i < days; i++) { + const day = new Date(windowStart); + day.setUTCDate(day.getUTCDate() + i); + const date = day.toISOString().slice(0, 10); + const point = byDate.get(date); + + result.push({ + date, + tipCount: point?.tipCount ?? 0, + amountRaw: point?.amountRaw ?? "0", + }); + } await cacheSet(key, result, CACHE_TTL); return result; diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index 2f96471..986108f 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -17,7 +17,6 @@ const ChallengeBody = z.object({ const VerifyBody = z.object({ walletAddress: z.string().min(56).max(56), signatureHex: z.string().length(128), // 64-byte sig → 128 hex chars - publicKeyHex: z.string().length(64), // 32-byte key → 64 hex chars }); export const authRoutes: FastifyPluginAsync = async (app) => { @@ -42,7 +41,6 @@ export const authRoutes: FastifyPluginAsync = async (app) => { const { jwt, isNewUser } = await verifyChallenge( body.data.walletAddress, body.data.signatureHex, - body.data.publicKeyHex, (payload) => app.jwt.sign(payload), ); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 95e91af..36f6676 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -9,11 +9,13 @@ * 3. Client calls POST /auth/verify → receives a JWT on success * * Signature verification uses TweetNaCl (Ed25519) — the same curve - * Stellar keypairs use — so no Stellar SDK dependency is needed here. + * Stellar keypairs use. The Stellar SDK is only used to decode the walletAddress + * strkey into its raw public key bytes. */ import nacl from "tweetnacl"; import { randomBytes } from "crypto"; +import { StrKey } from "@stellar/stellar-sdk"; import { setAuthNonce, consumeAuthNonce } from "../../redis.js"; import { db } from "../../db.js"; import { isValidAccountId } from "@novatip/sdk"; @@ -46,12 +48,10 @@ export interface VerifyResult { * * @param walletAddress - G... Stellar account address * @param signatureHex - Hex-encoded Ed25519 signature over the nonce bytes - * @param publicKeyHex - Hex-encoded 32-byte Ed25519 public key matching the address */ export async function verifyChallenge( walletAddress: string, signatureHex: string, - publicKeyHex: string, signJwt: (payload: object) => string, ): Promise { if (!isValidAccountId(walletAddress)) { @@ -67,8 +67,13 @@ export async function verifyChallenge( ); } + // The public key is derived from walletAddress itself rather than taken from + // the client, so there is no separate value that could name a different key + // than the one the signature is checked against. + const publicKey = StrKey.decodeEd25519PublicKey(walletAddress); + // Verify Ed25519 signature - const valid = verifyEd25519(nonce, signatureHex, publicKeyHex); + const valid = verifyEd25519(nonce, signatureHex, publicKey); if (!valid) { throw Object.assign(new Error("Signature verification failed."), { statusCode: 401 }); } @@ -108,12 +113,11 @@ export async function verifyChallenge( function verifyEd25519( nonce: string, signatureHex: string, - publicKeyHex: string, + publicKey: Buffer, ): boolean { try { const message = Buffer.from(nonce, "utf8"); const signature = Buffer.from(signatureHex, "hex"); - const publicKey = Buffer.from(publicKeyHex, "hex"); if (publicKey.length !== 32) return false; if (signature.length !== 64) return false; diff --git a/src/modules/creator/creator.service.ts b/src/modules/creator/creator.service.ts index 4ca5dfd..3b920ef 100644 --- a/src/modules/creator/creator.service.ts +++ b/src/modules/creator/creator.service.ts @@ -10,13 +10,45 @@ * backend just records the claimed slug + jarId) */ -import type { Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { db } from "../../db.js"; import { cacheInvalidate, cacheGet, cacheSet } from "../../redis.js"; const SLUG_REGEX = /^[a-z0-9_-]{3,32}$/; const PROFILE_CACHE_TTL = 60; // seconds +/** + * Slugs that can't be claimed by a creator. + * + * novatip-web serves creator pages from src/app/[slug]/page.tsx, at the same + * routing level as static routes like /dashboard and /onboarding. Next.js + * resolves static segments before dynamic ones, so a creator claiming one of + * those slugs would get a page permanently shadowed by the app's own route. + * api and _next are reserved for the same routing reason; admin/support/etc + * are reserved to prevent impersonation. + * + * Exported as a single constant so claimSlug and isSlugAvailable can't drift + * apart on what's reserved. + */ +export const RESERVED_SLUGS: ReadonlySet = new Set([ + "api", + "admin", + "dashboard", + "onboarding", + "settings", + "login", + "logout", + "auth", + "support", + "help", + "about", + "terms", + "privacy", + "static", + "_next", + "novatip", +]); + // ── Types ───────────────────────────────────────────────────────────────────── /** @@ -70,30 +102,72 @@ export async function claimSlug(input: ClaimSlugInput) { ); } - // Check availability + if (RESERVED_SLUGS.has(input.slug)) { + throw Object.assign( + new Error(`"${input.slug}" is a reserved slug and can't be claimed.`), + { statusCode: 400 }, + ); + } + + // Check availability. This pre-check handles the common case, but two + // requests can race and both pass it before either writes — the unique + // constraint below is what actually prevents a duplicate. const existing = await db.creator.findUnique({ where: { slug: input.slug } }); if (existing && existing.id !== input.creatorId) { - throw Object.assign(new Error("This slug is already taken."), { statusCode: 409 }); + throw Object.assign(new Error("This slug is already taken."), { + statusCode: 409, + code: "SLUG_TAKEN", + }); } - const creator = await db.creator.update({ - where: { id: input.creatorId }, - // Optional fields are spread in only when supplied. Prisma reads a missing - // key as "leave unchanged", but exactOptionalPropertyTypes rejects passing - // an explicit undefined to say the same thing. - data: { - slug: input.slug, - jarId: input.jarId, - splits: input.splits ?? [], - ...(input.displayName !== undefined && { displayName: input.displayName }), - ...(input.bio !== undefined && { bio: input.bio }), - }, - }); + let creator; + try { + creator = await db.creator.update({ + where: { id: input.creatorId }, + // Optional fields are spread in only when supplied. Prisma reads a missing + // key as "leave unchanged", but exactOptionalPropertyTypes rejects passing + // an explicit undefined to say the same thing. + data: { + slug: input.slug, + jarId: input.jarId, + splits: input.splits ?? [], + ...(input.displayName !== undefined && { displayName: input.displayName }), + ...(input.bio !== undefined && { bio: input.bio }), + }, + }); + } catch (err) { + throw toClaimConflict(err); + } await cacheInvalidate(`creator:${input.slug}`); return creator; } +/** + * A losing concurrent claim hits the DB's unique constraint (slug and jarId + * are both @unique) as Prisma error P2002, not the pre-check above. That + * reaches the global error handler with no statusCode and surfaces as a 500 + * — this rethrows it as the same 409 the pre-check produces, using + * err.meta.target to say which column conflicted. + */ +function toClaimConflict(err: unknown): unknown { + if (!(err instanceof Prisma.PrismaClientKnownRequestError) || err.code !== "P2002") { + return err; + } + + const target = err.meta?.["target"]; + const conflictsOnJarId = Array.isArray(target) && target.includes("jarId"); + + return Object.assign( + new Error( + conflictsOnJarId + ? "This jarId is already registered to another creator." + : "This slug is already taken.", + ), + { statusCode: 409, code: conflictsOnJarId ? "JARID_TAKEN" : "SLUG_TAKEN" }, + ); +} + // ── Profile ─────────────────────────────────────────────────────────────────── /** @@ -167,6 +241,7 @@ export async function updateCreatorSplits( */ export async function isSlugAvailable(slug: string): Promise { if (!SLUG_REGEX.test(slug)) return false; + if (RESERVED_SLUGS.has(slug)) return false; const existing = await db.creator.findUnique({ where: { slug } }); return !existing; }