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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions scripts/check-reserved-slugs.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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());
49 changes: 41 additions & 8 deletions src/modules/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -94,25 +110,42 @@ export async function getTimeSeries(
const cached = await cacheGet<TimeSeriesPoint[]>(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<TimeSeriesRow[]>`
SELECT
date_trunc('day', "ledgerAt") AS "date",
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<string, { tipCount: number; amountRaw: string }>();
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;
Expand Down
2 changes: 0 additions & 2 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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),
);

Expand Down
16 changes: 10 additions & 6 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<VerifyResult> {
if (!isValidAccountId(walletAddress)) {
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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;
Expand Down
107 changes: 91 additions & 16 deletions src/modules/creator/creator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
"api",
"admin",
"dashboard",
"onboarding",
"settings",
"login",
"logout",
"auth",
"support",
"help",
"about",
"terms",
"privacy",
"static",
"_next",
"novatip",
]);

// ── Types ─────────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -167,6 +241,7 @@ export async function updateCreatorSplits(
*/
export async function isSlugAvailable(slug: string): Promise<boolean> {
if (!SLUG_REGEX.test(slug)) return false;
if (RESERVED_SLUGS.has(slug)) return false;
const existing = await db.creator.findUnique({ where: { slug } });
return !existing;
}