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
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
production-dependencies:
dependency-type: "production"
development-dependencies:
dependency-type: "development"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Install dependencies
run: npm install

- name: Security audit
run: npm audit --audit-level=high || true

- name: Run migrations
run: npm run migrate --workspace backend

Expand Down
10 changes: 10 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
dist
.git
*.md
docker-compose*.yml
.env
.env.*
src/tests
coverage
.DS_Store
40 changes: 40 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# --- Builder stage ---
FROM node:20-alpine AS builder
WORKDIR /app

# Copy workspace root for npm workspaces resolution
COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/
COPY backend/package.json backend/

RUN npm ci --workspace backend --workspace @soundscore/contracts

COPY packages/contracts/ packages/contracts/
COPY backend/ backend/

RUN npm run build --workspace @soundscore/contracts && npm run build --workspace backend

# --- Production stage ---
FROM node:20-alpine AS production
WORKDIR /app

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/
COPY backend/package.json backend/

RUN npm ci --workspace backend --workspace @soundscore/contracts --omit=dev

COPY --from=builder /app/packages/contracts/dist packages/contracts/dist
COPY --from=builder /app/backend/dist backend/dist
COPY backend/src/db/schema backend/src/db/schema

USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -q --spider http://localhost:8080/health || exit 1

CMD ["node", "backend/dist/index.js"]
11 changes: 9 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
Expand All @@ -12,14 +15,18 @@
"test": "tsx --test src/tests/*.test.ts"
},
"dependencies": {
"@fastify/rate-limit": "^10.3.0",
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^13.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@soundscore/contracts": "0.1.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"fastify": "^5.2.0",
"ioredis": "^5.4.2",
"pg": "^8.13.1"
"pg": "^8.13.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
Expand Down
65 changes: 54 additions & 11 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,71 @@
import dotenv from "dotenv";
import { z } from "zod";

dotenv.config();

const toNumber = (value: string | undefined, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const DEV_DATABASE_URL = "postgresql://soundscore:soundscore@localhost:5432/soundscore";
const DEV_REDIS_URL = "redis://localhost:6379";

const EnvSchema = z.object({
PORT: z.coerce.number().default(8080),
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string().min(1).default(DEV_DATABASE_URL),
REDIS_URL: z.string().min(1).default(DEV_REDIS_URL),
AUTH_SALT_ROUNDS: z.coerce.number().default(10),
SPOTIFY_CLIENT_ID: z.string().optional().default(""),
SPOTIFY_CLIENT_SECRET: z.string().optional().default(""),
ALLOWED_ORIGINS: z.string().default("http://localhost:3000"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
});

const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
console.error("Invalid environment variables:");
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}

const validated = parsed.data;

// In production, require explicit DATABASE_URL and REDIS_URL (no dev defaults)
if (validated.NODE_ENV === "production") {
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL must be explicitly set in production");
process.exit(1);
}
if (!process.env.REDIS_URL) {
console.error("REDIS_URL must be explicitly set in production");
process.exit(1);
}
}

if (!validated.SPOTIFY_CLIENT_ID || !validated.SPOTIFY_CLIENT_SECRET) {
console.warn("Warning: SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET not set — provider features will be unavailable");
}

export const env = {
app: {
port: toNumber(process.env.PORT, 8080),
host: process.env.HOST ?? "0.0.0.0",
port: validated.PORT,
host: validated.HOST,
allowedOrigins: validated.ALLOWED_ORIGINS.split(",").map((s) => s.trim()),
nodeEnv: validated.NODE_ENV,
logLevel: validated.LOG_LEVEL,
},
postgres: {
connectionString: process.env.DATABASE_URL ?? "postgresql://soundscore:soundscore@localhost:5432/soundscore",
connectionString: validated.DATABASE_URL,
},
redis: {
url: process.env.REDIS_URL ?? "redis://localhost:6379",
url: validated.REDIS_URL,
},
auth: {
saltRounds: toNumber(process.env.AUTH_SALT_ROUNDS, 10),
saltRounds: validated.AUTH_SALT_ROUNDS,
},
spotify: {
clientId: process.env.SPOTIFY_CLIENT_ID ?? "",
clientSecret: process.env.SPOTIFY_CLIENT_SECRET ?? "",
clientId: validated.SPOTIFY_CLIENT_ID,
clientSecret: validated.SPOTIFY_CLIENT_SECRET,
},
};
7 changes: 6 additions & 1 deletion backend/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ export type Db = {
export const createDb = (): Db => {
const pool = new Pool({
connectionString: env.postgres.connectionString,
connectionTimeoutMillis: 5_000,
});

const redis = new Redis(env.redis.url, {
maxRetriesPerRequest: 1,
lazyConnect: false,
retryStrategy: (times) => (times <= 3 ? Math.min(times * 200, 2000) : null),
});

return {
Expand All @@ -28,7 +30,10 @@ export const createDb = (): Db => {
query: <T extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) =>
pool.query<T>(text, params),
close: async () => {
await Promise.all([pool.end(), redis.quit()]);
await Promise.all([
pool.end().catch(() => {}),
redis.quit().catch(() => { redis.disconnect(); }),
]);
},
};
};
38 changes: 38 additions & 0 deletions backend/src/db/schema/006_session_expiry_and_indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Add session expiration support
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;

-- Backfill existing sessions with a 24-hour window from creation
UPDATE sessions SET expires_at = created_at + INTERVAL '24 hours' WHERE expires_at IS NULL;

-- Now enforce NOT NULL
ALTER TABLE sessions ALTER COLUMN expires_at SET NOT NULL;

-- Create index for expired session cleanup
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);

-- Missing indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_ratings_album ON ratings(album_id);
CREATE INDEX IF NOT EXISTS idx_reviews_album ON reviews(album_id);
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_listening_album ON listening_events(album_id);
CREATE INDEX IF NOT EXISTS idx_notification_events_user ON notification_events(user_id, created_at DESC);

-- Full-text search support for albums
ALTER TABLE albums ADD COLUMN IF NOT EXISTS search_vector tsvector;

UPDATE albums SET search_vector = to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(artist, '')) WHERE search_vector IS NULL;

CREATE INDEX IF NOT EXISTS idx_albums_search ON albums USING GIN (search_vector);

-- Trigger to keep search_vector updated on insert/update
CREATE OR REPLACE FUNCTION albums_search_vector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('english', COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.artist, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_albums_search_vector ON albums;
CREATE TRIGGER trg_albums_search_vector
BEFORE INSERT OR UPDATE OF title, artist ON albums
FOR EACH ROW EXECUTE FUNCTION albums_search_vector_update();
9 changes: 9 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ const run = async () => {
port: env.app.port,
host: env.app.host,
});

// Graceful shutdown: drain in-flight requests, close DB/Redis via onClose hook
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, async () => {
app.log.info({ signal }, "shutting down gracefully");
await app.close();
process.exit(0);
});
}
};

run().catch((error) => {
Expand Down
33 changes: 33 additions & 0 deletions backend/src/lib/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { FastifyRequest } from "fastify";

export type PaginationParams = {
cursor: string | null;
limit: number;
};

const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const MAX_CURSOR_LENGTH = 128;

export const parsePaginationParams = (request: FastifyRequest): PaginationParams => {
const query = request.query as { cursor?: string; limit?: string };
const rawLimit = Number(query.limit);
const limit = Number.isFinite(rawLimit) && rawLimit > 0
? Math.min(rawLimit, MAX_LIMIT)
: DEFAULT_LIMIT;

const raw = query.cursor?.trim() || null;
// Reject cursors that are too long or contain SQL-suspicious characters
const cursor = raw && raw.length <= MAX_CURSOR_LENGTH ? raw : null;

return { cursor, limit };
};

export const buildPaginatedResponse = <T>(items: T[], limit: number, getCursor: (item: T) => string) => {
const hasMore = items.length > limit;
const trimmed = hasMore ? items.slice(0, limit) : items;
return {
items: trimmed,
nextCursor: hasMore && trimmed.length > 0 ? getCursor(trimmed[trimmed.length - 1]) : null,
};
};
12 changes: 12 additions & 0 deletions backend/src/lib/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Sanitize user-submitted plain-text fields.
* SoundScore does not support rich text — all user content is plain text.
* Encodes HTML special characters to prevent XSS if content is ever
* rendered in a web context, and strips any remaining HTML tags.
*/
export const stripHtml = (text: string): string =>
text
.replace(/<[^>]*>/g, "")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.trim();
20 changes: 13 additions & 7 deletions backend/src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const writeProfileCache = async (db: Db, userId: string) => {
};

export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
app.post("/v1/auth/signup", async (request) => {
app.post("/v1/auth/signup", async (request, reply) => {
const payload = SignUpRequestSchema.parse(request.body);
const existing = await db.query<{ id: string }>(
"SELECT id FROM users WHERE email = $1",
Expand Down Expand Up @@ -82,8 +82,8 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {

await db.query(
`
INSERT INTO sessions(access_token, user_id, created_at)
VALUES($1, $2, $3)
INSERT INTO sessions(access_token, user_id, created_at, expires_at)
VALUES($1, $2, $3, NOW() + INTERVAL '24 hours')
`,
[accessToken, userId, now],
);
Expand All @@ -107,12 +107,12 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
userAgent: request.headers["user-agent"],
}).catch(() => {});

return buildAuthResponse(
return reply.status(201).send(buildAuthResponse(
accessToken,
refreshToken,
userId,
payload.handle.startsWith("@") ? payload.handle : `@${payload.handle}`,
);
));
});

app.post("/v1/auth/login", async (request) => {
Expand Down Expand Up @@ -145,10 +145,13 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
[user.id, refreshToken],
);
await db.query(
"INSERT INTO sessions(access_token, user_id, created_at) VALUES($1, $2, $3)",
"INSERT INTO sessions(access_token, user_id, created_at, expires_at) VALUES($1, $2, $3, NOW() + INTERVAL '24 hours')",
[accessToken, user.id, now],
);

// Clean up expired sessions for this user
await db.query("DELETE FROM sessions WHERE user_id = $1 AND expires_at < NOW()", [user.id]);

logAuditEvent(db, {
userId: user.id,
type: "user.login",
Expand Down Expand Up @@ -180,10 +183,13 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
[user.id, nextRefreshToken],
);
await db.query(
"INSERT INTO sessions(access_token, user_id, created_at) VALUES($1, $2, $3)",
"INSERT INTO sessions(access_token, user_id, created_at, expires_at) VALUES($1, $2, $3, NOW() + INTERVAL '24 hours')",
[accessToken, user.id, now],
);

// Clean up expired sessions for this user
await db.query("DELETE FROM sessions WHERE user_id = $1 AND expires_at < NOW()", [user.id]);

return buildAuthResponse(accessToken, nextRefreshToken, user.id, user.handle);
});

Expand Down
Loading
Loading