From e1ab956ac35a4abe8658c3fbc81d799208d24f81 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Feb 2026 11:39:00 -0800 Subject: [PATCH] feat(services): add platform-service (auth, audit, flags, notifications, blob) - Copied as-is from learning_voice_ai_agent/services/platform-service - 55 tests passing (vitest) - Fastify 5 + Cosmos DB + jose + bcryptjs + Zod - Modules: auth, audit, flags, notifications, blob, ratelimit - Port 4003 --- services/platform-service/.gitignore | 2 + services/platform-service/Dockerfile | 16 ++ services/platform-service/package.json | 33 ++++ services/platform-service/src/lib/blob.ts | 150 +++++++++++++++++ services/platform-service/src/lib/config.ts | 31 ++++ services/platform-service/src/lib/cosmos.ts | 24 +++ .../platform-service/src/lib/errors.test.ts | 44 +++++ services/platform-service/src/lib/errors.ts | 37 +++++ .../src/lib/product-config.ts | 8 + .../src/modules/audit/audit.test.ts | 70 ++++++++ .../src/modules/audit/repository.ts | 74 +++++++++ .../src/modules/audit/routes.ts | 59 +++++++ .../src/modules/audit/types.ts | 39 +++++ .../src/modules/auth/auth.test.ts | 87 ++++++++++ .../platform-service/src/modules/auth/jwt.ts | 56 +++++++ .../src/modules/auth/repository.ts | 62 +++++++ .../src/modules/auth/routes.ts | 141 ++++++++++++++++ .../src/modules/auth/types.ts | 46 ++++++ .../src/modules/blob/blob.test.ts | 141 ++++++++++++++++ .../src/modules/blob/routes.ts | 155 ++++++++++++++++++ .../src/modules/blob/types.ts | 48 ++++++ .../src/modules/flags/flags.test.ts | 130 +++++++++++++++ .../src/modules/flags/repository.ts | 63 +++++++ .../src/modules/flags/routes.ts | 141 ++++++++++++++++ .../src/modules/flags/types.ts | 39 +++++ .../notifications/notifications.test.ts | 61 +++++++ .../src/modules/notifications/repository.ts | 61 +++++++ .../src/modules/notifications/routes.ts | 95 +++++++++++ .../src/modules/notifications/types.ts | 49 ++++++ .../src/modules/ratelimit/routes.ts | 108 ++++++++++++ .../src/modules/ratelimit/store.ts | 101 ++++++++++++ .../src/modules/ratelimit/types.ts | 53 ++++++ services/platform-service/src/server.ts | 86 ++++++++++ services/platform-service/tsconfig.json | 19 +++ services/platform-service/vitest.config.ts | 9 + 35 files changed, 2338 insertions(+) create mode 100644 services/platform-service/.gitignore create mode 100644 services/platform-service/Dockerfile create mode 100644 services/platform-service/package.json create mode 100644 services/platform-service/src/lib/blob.ts create mode 100644 services/platform-service/src/lib/config.ts create mode 100644 services/platform-service/src/lib/cosmos.ts create mode 100644 services/platform-service/src/lib/errors.test.ts create mode 100644 services/platform-service/src/lib/errors.ts create mode 100644 services/platform-service/src/lib/product-config.ts create mode 100644 services/platform-service/src/modules/audit/audit.test.ts create mode 100644 services/platform-service/src/modules/audit/repository.ts create mode 100644 services/platform-service/src/modules/audit/routes.ts create mode 100644 services/platform-service/src/modules/audit/types.ts create mode 100644 services/platform-service/src/modules/auth/auth.test.ts create mode 100644 services/platform-service/src/modules/auth/jwt.ts create mode 100644 services/platform-service/src/modules/auth/repository.ts create mode 100644 services/platform-service/src/modules/auth/routes.ts create mode 100644 services/platform-service/src/modules/auth/types.ts create mode 100644 services/platform-service/src/modules/blob/blob.test.ts create mode 100644 services/platform-service/src/modules/blob/routes.ts create mode 100644 services/platform-service/src/modules/blob/types.ts create mode 100644 services/platform-service/src/modules/flags/flags.test.ts create mode 100644 services/platform-service/src/modules/flags/repository.ts create mode 100644 services/platform-service/src/modules/flags/routes.ts create mode 100644 services/platform-service/src/modules/flags/types.ts create mode 100644 services/platform-service/src/modules/notifications/notifications.test.ts create mode 100644 services/platform-service/src/modules/notifications/repository.ts create mode 100644 services/platform-service/src/modules/notifications/routes.ts create mode 100644 services/platform-service/src/modules/notifications/types.ts create mode 100644 services/platform-service/src/modules/ratelimit/routes.ts create mode 100644 services/platform-service/src/modules/ratelimit/store.ts create mode 100644 services/platform-service/src/modules/ratelimit/types.ts create mode 100644 services/platform-service/src/server.ts create mode 100644 services/platform-service/tsconfig.json create mode 100644 services/platform-service/vitest.config.ts diff --git a/services/platform-service/.gitignore b/services/platform-service/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/services/platform-service/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/services/platform-service/Dockerfile b/services/platform-service/Dockerfile new file mode 100644 index 00000000..85500065 --- /dev/null +++ b/services/platform-service/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src/ src/ +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist ./dist +ENV NODE_ENV=production +EXPOSE 4003 +CMD ["node", "dist/server.js"] diff --git a/services/platform-service/package.json b/services/platform-service/package.json new file mode 100644 index 00000000..216349f1 --- /dev/null +++ b/services/platform-service/package.json @@ -0,0 +1,33 @@ +{ + "name": "@lysnrai/platform-service", + "version": "0.1.0", + "private": true, + "description": "Platform Service — auth, audit, notifications, feature flags", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/" + }, + "dependencies": { + "@azure/cosmos": "^4.2.0", + "@azure/storage-blob": "^12.31.0", + "@fastify/cors": "^10.0.2", + "@fastify/swagger": "^9.4.2", + "bcryptjs": "^2.4.3", + "fastify": "^5.2.1", + "fastify-metrics": "^10.3.0", + "jose": "^6.0.8", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^22.12.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/services/platform-service/src/lib/blob.ts b/services/platform-service/src/lib/blob.ts new file mode 100644 index 00000000..72bc124e --- /dev/null +++ b/services/platform-service/src/lib/blob.ts @@ -0,0 +1,150 @@ +/** + * Shared Azure Blob Storage client for the Platform Service. + * + * Provides singleton BlobServiceClient, container helpers, and SAS token generation. + * Containers are lazily created on first access (createIfNotExists). + * + * Expected env vars: + * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred) + * — OR — + * AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY + */ + +import { + BlobServiceClient, + ContainerClient, + StorageSharedKeyCredential, + BlobSASPermissions, + generateBlobSASQueryParameters, + SASProtocol, +} from "@azure/storage-blob"; + +let serviceClient: BlobServiceClient | null = null; +const containerClients = new Map(); + +/** + * Known blob containers and their purposes. + */ +export const BLOB_CONTAINERS = { + audio: "audio", // Dictation audio recordings + transcripts: "transcripts", // Exported transcript files (PDF, DOCX, TXT) + attachments: "attachments", // Tracker item attachments (screenshots, docs) + avatars: "avatars", // User profile images + releases: "releases", // Desktop app update binaries + backups: "backups", // Cosmos DB JSON backups +} as const; + +export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS]; + +/** + * Get or create the BlobServiceClient singleton. + */ +export function getBlobServiceClient(): BlobServiceClient { + if (serviceClient) return serviceClient; + + const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING; + if (connectionString) { + serviceClient = BlobServiceClient.fromConnectionString(connectionString); + return serviceClient; + } + + const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME; + const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY; + if (accountName && accountKey) { + const credential = new StorageSharedKeyCredential(accountName, accountKey); + serviceClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + credential, + ); + return serviceClient; + } + + throw new Error( + "Azure Blob Storage not configured. Set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY", + ); +} + +/** + * Get a container client, creating the container if it doesn't exist. + */ +export async function getContainerClient(containerName: string): Promise { + const cached = containerClients.get(containerName); + if (cached) return cached; + + const client = getBlobServiceClient().getContainerClient(containerName); + await client.createIfNotExists({ access: undefined }); // private by default + containerClients.set(containerName, client); + return client; +} + +/** + * Generate a SAS URL for direct browser upload (or download). + * + * @param containerName - Target container + * @param blobName - Full blob path (e.g., "lysnrai/user123/audio/recording.wav") + * @param permissions - SAS permissions (default: read) + * @param expiresInMinutes - Token lifetime (default: 60) + * @returns Full SAS URL for the blob + */ +export function generateSasUrl( + containerName: string, + blobName: string, + permissions: "r" | "w" | "rw" | "rwc" | "rwd" = "r", + expiresInMinutes = 60, +): string { + const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING; + const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME; + const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY; + + let credAccountName: string; + let credential: StorageSharedKeyCredential; + + if (accountName && accountKey) { + credAccountName = accountName; + credential = new StorageSharedKeyCredential(accountName, accountKey); + } else if (connectionString) { + // Parse account name and key from connection string + const nameMatch = connectionString.match(/AccountName=([^;]+)/); + const keyMatch = connectionString.match(/AccountKey=([^;]+)/); + if (!nameMatch || !keyMatch) { + throw new Error("Cannot parse AccountName/AccountKey from connection string"); + } + credAccountName = nameMatch[1]; + credential = new StorageSharedKeyCredential(nameMatch[1], keyMatch[1]); + } else { + throw new Error("Blob storage credentials not configured"); + } + + const sasPermissions = new BlobSASPermissions(); + if (permissions.includes("r")) sasPermissions.read = true; + if (permissions.includes("w")) sasPermissions.write = true; + if (permissions.includes("c")) sasPermissions.create = true; + if (permissions.includes("d")) sasPermissions.delete = true; + + const now = new Date(); + const expiresOn = new Date(now.getTime() + expiresInMinutes * 60 * 1000); + + const sasToken = generateBlobSASQueryParameters( + { + containerName, + blobName, + permissions: sasPermissions, + startsOn: now, + expiresOn, + protocol: SASProtocol.Https, + }, + credential, + ).toString(); + + return `https://${credAccountName}.blob.core.windows.net/${containerName}/${blobName}?${sasToken}`; +} + +/** + * Check if blob storage is configured. + */ +export function isBlobStorageConfigured(): boolean { + return !!( + process.env.AZURE_BLOB_CONNECTION_STRING || + (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) + ); +} diff --git a/services/platform-service/src/lib/config.ts b/services/platform-service/src/lib/config.ts new file mode 100644 index 00000000..a5b04013 --- /dev/null +++ b/services/platform-service/src/lib/config.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +const envSchema = z.object({ + // Server + PORT: z.coerce.number().default(4003), + HOST: z.string().default("0.0.0.0"), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + CORS_ORIGIN: z.string().optional(), + SERVICE_NAME: z.string().default("platform-service"), + + // Database + COSMOS_ENDPOINT: z.string().min(1, "COSMOS_ENDPOINT is required"), + COSMOS_KEY: z.string().min(1, "COSMOS_KEY is required"), + COSMOS_DATABASE: z.string().default("lysnrai"), + + // Auth + JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), + + // Blob Storage + AZURE_BLOB_CONNECTION_STRING: z.string().optional(), + AZURE_BLOB_ACCOUNT_NAME: z.string().optional(), + AZURE_BLOB_ACCOUNT_KEY: z.string().optional(), + + // Features + RATE_LIMIT_CONFIG_JSON: z.string().optional(), +}).refine(data => + data.AZURE_BLOB_CONNECTION_STRING || (data.AZURE_BLOB_ACCOUNT_NAME && data.AZURE_BLOB_ACCOUNT_KEY), + { message: "Must provide AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME/KEY" } +); + +export const config = envSchema.parse(process.env); diff --git a/services/platform-service/src/lib/cosmos.ts b/services/platform-service/src/lib/cosmos.ts new file mode 100644 index 00000000..95e79ea8 --- /dev/null +++ b/services/platform-service/src/lib/cosmos.ts @@ -0,0 +1,24 @@ +/** + * Shared Cosmos DB client for the Platform Service. + */ + +import { CosmosClient, Container } from "@azure/cosmos"; + +let client: CosmosClient | null = null; + +function getClient(): CosmosClient { + if (!client) { + const endpoint = process.env.COSMOS_ENDPOINT; + const key = process.env.COSMOS_KEY; + if (!endpoint || !key) { + throw new Error("COSMOS_ENDPOINT and COSMOS_KEY must be set"); + } + client = new CosmosClient({ endpoint, key }); + } + return client; +} + +export function getContainer(name: string): Container { + const database = process.env.COSMOS_DATABASE || "lysnrai"; + return getClient().database(database).container(name); +} diff --git a/services/platform-service/src/lib/errors.test.ts b/services/platform-service/src/lib/errors.test.ts new file mode 100644 index 00000000..2d1e31f7 --- /dev/null +++ b/services/platform-service/src/lib/errors.test.ts @@ -0,0 +1,44 @@ +/** + * Unit tests for service error classes. + */ + +import { describe, it, expect } from "vitest"; +import { ServiceError, NotFoundError, BadRequestError, UnauthorizedError, ForbiddenError } from "./errors.js"; + +describe("ServiceError", () => { + it("has correct status code", () => { + const err = new ServiceError(418, "I'm a teapot"); + expect(err.statusCode).toBe(418); + expect(err.message).toBe("I'm a teapot"); + }); +}); + +describe("NotFoundError", () => { + it("has 404 status", () => { + const err = new NotFoundError(); + expect(err.statusCode).toBe(404); + }); + + it("has custom message", () => { + const err = new NotFoundError("User not found"); + expect(err.message).toBe("User not found"); + }); +}); + +describe("BadRequestError", () => { + it("has 400 status", () => { + expect(new BadRequestError().statusCode).toBe(400); + }); +}); + +describe("UnauthorizedError", () => { + it("has 401 status", () => { + expect(new UnauthorizedError().statusCode).toBe(401); + }); +}); + +describe("ForbiddenError", () => { + it("has 403 status", () => { + expect(new ForbiddenError().statusCode).toBe(403); + }); +}); diff --git a/services/platform-service/src/lib/errors.ts b/services/platform-service/src/lib/errors.ts new file mode 100644 index 00000000..d5471290 --- /dev/null +++ b/services/platform-service/src/lib/errors.ts @@ -0,0 +1,37 @@ +/** + * Typed service errors for consistent HTTP error responses. + */ + +export class ServiceError extends Error { + constructor( + public statusCode: number, + message: string, + ) { + super(message); + this.name = "ServiceError"; + } +} + +export class NotFoundError extends ServiceError { + constructor(message = "Not found") { + super(404, message); + } +} + +export class BadRequestError extends ServiceError { + constructor(message = "Bad request") { + super(400, message); + } +} + +export class UnauthorizedError extends ServiceError { + constructor(message = "Unauthorized") { + super(401, message); + } +} + +export class ForbiddenError extends ServiceError { + constructor(message = "Forbidden") { + super(403, message); + } +} diff --git a/services/platform-service/src/lib/product-config.ts b/services/platform-service/src/lib/product-config.ts new file mode 100644 index 00000000..20c7c9be --- /dev/null +++ b/services/platform-service/src/lib/product-config.ts @@ -0,0 +1,8 @@ +/** + * Centralized product identity — single source of truth. + * NOTE: The canonical source is shared/product.json at the repo root. + */ + +export const PRODUCT_ID = "lysnrai"; +export const DISPLAY_NAME = "LysnrAI"; +export const LICENSE_PREFIX = "LYSNR"; diff --git a/services/platform-service/src/modules/audit/audit.test.ts b/services/platform-service/src/modules/audit/audit.test.ts new file mode 100644 index 00000000..1359eb65 --- /dev/null +++ b/services/platform-service/src/modules/audit/audit.test.ts @@ -0,0 +1,70 @@ +/** + * Unit tests for audit module — types + validation. + */ + +import { describe, it, expect } from "vitest"; +import { CreateAuditSchema, QueryAuditSchema } from "./types.js"; + +describe("CreateAuditSchema", () => { + it("accepts valid input with defaults", () => { + const result = CreateAuditSchema.safeParse({ + userId: "user_123", + action: "login", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.category).toBe("general"); + expect(result.data.details).toEqual({}); + } + }); + + it("accepts full input", () => { + const result = CreateAuditSchema.safeParse({ + userId: "user_123", + action: "license_activated", + category: "security", + details: { deviceId: "dev_001", ip: "192.168.1.1" }, + ipAddress: "192.168.1.1", + userAgent: "Mozilla/5.0", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing action", () => { + const result = CreateAuditSchema.safeParse({ userId: "user_123" }); + expect(result.success).toBe(false); + }); + + it("rejects missing userId", () => { + const result = CreateAuditSchema.safeParse({ action: "login" }); + expect(result.success).toBe(false); + }); +}); + +describe("QueryAuditSchema", () => { + it("accepts empty query with defaults", () => { + const result = QueryAuditSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.days).toBe(30); + expect(result.data.limit).toBe(100); + expect(result.data.offset).toBe(0); + } + }); + + it("accepts filters", () => { + const result = QueryAuditSchema.safeParse({ + userId: "user_123", + action: "login", + category: "security", + days: 7, + limit: 50, + }); + expect(result.success).toBe(true); + }); + + it("rejects days > 365", () => { + const result = QueryAuditSchema.safeParse({ days: 400 }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/audit/repository.ts b/services/platform-service/src/modules/audit/repository.ts new file mode 100644 index 00000000..30d6a6c2 --- /dev/null +++ b/services/platform-service/src/modules/audit/repository.ts @@ -0,0 +1,74 @@ +/** + * Audit repository — Cosmos DB CRUD. + */ + +import { getContainer } from "../../lib/cosmos.js"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import type { AuditDoc, QueryAuditInput } from "./types.js"; + +// Default TTL: 90 days in seconds +const DEFAULT_TTL = 90 * 24 * 60 * 60; + +function container() { + return getContainer("audit_log"); +} + +export async function create(doc: AuditDoc): Promise { + const { resource } = await container().items.create({ + ...doc, + ttl: doc.ttl ?? DEFAULT_TTL, + }); + return resource as AuditDoc; +} + +export async function query(input: QueryAuditInput): Promise { + const { userId, action, category, days, limit, offset } = input; + const since = new Date(Date.now() - days * 86400000).toISOString(); + + let queryText = "SELECT * FROM c WHERE c.productId = @productId AND c.createdAt >= @since"; + const parameters: { name: string; value: string | number }[] = [ + { name: "@productId", value: PRODUCT_ID }, + { name: "@since", value: since }, + ]; + + if (userId) { + queryText += " AND c.userId = @userId"; + parameters.push({ name: "@userId", value: userId }); + } + if (action) { + queryText += " AND c.action = @action"; + parameters.push({ name: "@action", value: action }); + } + if (category) { + queryText += " AND c.category = @category"; + parameters.push({ name: "@category", value: category }); + } + + queryText += " ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit"; + parameters.push({ name: "@offset", value: offset }); + parameters.push({ name: "@limit", value: limit }); + + const { resources } = await container().items + .query({ query: queryText, parameters }) + .fetchAll(); + return resources; +} + +export async function getStats(days = 30): Promise> { + const since = new Date(Date.now() - days * 86400000).toISOString(); + const { resources } = await container().items + .query<{ action: string; count: number }>({ + query: "SELECT c.action, COUNT(1) as count FROM c WHERE c.productId = @productId AND c.createdAt >= @since GROUP BY c.action", + parameters: [ + { name: "@productId", value: PRODUCT_ID }, + { name: "@since", value: since }, + ], + }) + .fetchAll(); + + const stats: Record = {}; + for (const r of resources) { + stats[r.action] = r.count; + } + return stats; +} diff --git a/services/platform-service/src/modules/audit/routes.ts b/services/platform-service/src/modules/audit/routes.ts new file mode 100644 index 00000000..8e3b540b --- /dev/null +++ b/services/platform-service/src/modules/audit/routes.ts @@ -0,0 +1,59 @@ +/** + * Audit REST endpoints. + * + * POST /audit — fire-and-forget write + * GET /audit — query audit logs (admin) + * GET /audit/stats — aggregated action counts (admin) + */ + +import type { FastifyInstance } from "fastify"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import { BadRequestError } from "../../lib/errors.js"; +import * as repo from "./repository.js"; +import { CreateAuditSchema, QueryAuditSchema, type AuditDoc } from "./types.js"; + +export async function auditRoutes(app: FastifyInstance) { + // Fire-and-forget write + app.post("/audit", async (req, reply) => { + const parsed = CreateAuditSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const input = parsed.data; + const doc: AuditDoc = { + id: `aud_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + productId: PRODUCT_ID, + ...input, + createdAt: new Date().toISOString(), + }; + // Fire-and-forget — don't await + repo.create(doc).catch((err) => app.log.error(err, "Audit write failed")); + reply.code(202); + return { accepted: true }; + }); + + // Query + app.get("/audit", async (req) => { + const query = req.query as Record; + const parsed = QueryAuditSchema.safeParse({ + userId: query.userId, + action: query.action, + category: query.category, + days: query.days ? Number(query.days) : undefined, + limit: query.limit ? Number(query.limit) : undefined, + offset: query.offset ? Number(query.offset) : undefined, + }); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const records = await repo.query(parsed.data); + return { records, count: records.length }; + }); + + // Stats + app.get("/audit/stats", async (req) => { + const { days = "30" } = req.query as { days?: string }; + const stats = await repo.getStats(Number(days)); + return { stats, days: Number(days) }; + }); +} diff --git a/services/platform-service/src/modules/audit/types.ts b/services/platform-service/src/modules/audit/types.ts new file mode 100644 index 00000000..8e9daf8a --- /dev/null +++ b/services/platform-service/src/modules/audit/types.ts @@ -0,0 +1,39 @@ +/** + * Audit logging types — ported from Python + TS implementations. + */ + +import { z } from "zod"; + +export interface AuditDoc { + id: string; + productId: string; + userId: string; + action: string; + category: string; + details: Record; + ipAddress?: string; + userAgent?: string; + createdAt: string; + ttl?: number; +} + +export const CreateAuditSchema = z.object({ + userId: z.string().min(1), + action: z.string().min(1), + category: z.string().default("general"), + details: z.record(z.unknown()).default({}), + ipAddress: z.string().optional(), + userAgent: z.string().optional(), +}); + +export const QueryAuditSchema = z.object({ + userId: z.string().optional(), + action: z.string().optional(), + category: z.string().optional(), + days: z.number().int().min(1).max(365).default(30), + limit: z.number().int().min(1).max(1000).default(100), + offset: z.number().int().min(0).default(0), +}); + +export type CreateAuditInput = z.infer; +export type QueryAuditInput = z.infer; diff --git a/services/platform-service/src/modules/auth/auth.test.ts b/services/platform-service/src/modules/auth/auth.test.ts new file mode 100644 index 00000000..76efb90d --- /dev/null +++ b/services/platform-service/src/modules/auth/auth.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for auth module — types + validation. + */ + +import { describe, it, expect } from "vitest"; +import { LoginSchema, RegisterSchema, RefreshSchema } from "./types.js"; + +describe("LoginSchema", () => { + it("accepts valid email + password", () => { + const result = LoginSchema.safeParse({ + email: "admin@lysnrai.com", + password: "secret123", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid email", () => { + const result = LoginSchema.safeParse({ + email: "not-an-email", + password: "secret123", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty password", () => { + const result = LoginSchema.safeParse({ + email: "admin@lysnrai.com", + password: "", + }); + expect(result.success).toBe(false); + }); +}); + +describe("RegisterSchema", () => { + it("accepts valid registration with defaults", () => { + const result = RegisterSchema.safeParse({ + email: "new@lysnrai.com", + password: "password123", + displayName: "New User", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe("user"); + } + }); + + it("accepts admin role", () => { + const result = RegisterSchema.safeParse({ + email: "admin@lysnrai.com", + password: "password123", + displayName: "Admin", + role: "admin", + }); + expect(result.success).toBe(true); + }); + + it("rejects password shorter than 8 chars", () => { + const result = RegisterSchema.safeParse({ + email: "new@lysnrai.com", + password: "short", + displayName: "User", + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid role", () => { + const result = RegisterSchema.safeParse({ + email: "new@lysnrai.com", + password: "password123", + displayName: "User", + role: "super_admin", + }); + expect(result.success).toBe(false); + }); +}); + +describe("RefreshSchema", () => { + it("accepts valid refresh token", () => { + const result = RefreshSchema.safeParse({ refreshToken: "some.jwt.token" }); + expect(result.success).toBe(true); + }); + + it("rejects empty token", () => { + const result = RefreshSchema.safeParse({ refreshToken: "" }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/auth/jwt.ts b/services/platform-service/src/modules/auth/jwt.ts new file mode 100644 index 00000000..74936b99 --- /dev/null +++ b/services/platform-service/src/modules/auth/jwt.ts @@ -0,0 +1,56 @@ +/** + * JWT utilities — consolidated from Python + TS implementations. + * Uses jose library for standards-compliant JWT handling. + */ + +import { SignJWT, jwtVerify } from "jose"; +import { PRODUCT_ID } from "../../lib/product-config.js"; + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error("JWT_SECRET must be set"); + return new TextEncoder().encode(secret); +} + +export async function createAccessToken(payload: { + sub: string; + email: string; + role: string; +}): Promise { + return new SignJWT({ ...payload, productId: PRODUCT_ID, type: "access" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .setIssuer(PRODUCT_ID) + .sign(getSecret()); +} + +export async function createRefreshToken(payload: { + sub: string; +}): Promise { + return new SignJWT({ sub: payload.sub, productId: PRODUCT_ID, type: "refresh" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("7d") + .setIssuer(PRODUCT_ID) + .sign(getSecret()); +} + +export async function verifyToken(token: string): Promise<{ + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; +}> { + const { payload } = await jwtVerify(token, getSecret(), { + issuer: PRODUCT_ID, + }); + return payload as { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; + }; +} diff --git a/services/platform-service/src/modules/auth/repository.ts b/services/platform-service/src/modules/auth/repository.ts new file mode 100644 index 00000000..479ef4fa --- /dev/null +++ b/services/platform-service/src/modules/auth/repository.ts @@ -0,0 +1,62 @@ +/** + * Auth user repository — Cosmos DB. + */ + +import bcrypt from "bcryptjs"; +import { getContainer } from "../../lib/cosmos.js"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import type { UserDoc } from "./types.js"; + +function container() { + return getContainer("users"); +} + +export async function getByEmail(email: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT * FROM c WHERE c.productId = @productId AND c.email = @email", + parameters: [ + { name: "@productId", value: PRODUCT_ID }, + { name: "@email", value: email.toLowerCase() }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function getById(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(user: UserDoc): Promise { + const { resource } = await container().items.create(user); + return resource as UserDoc; +} + +export async function updateLastLogin(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + if (resource) { + await container().item(id, id).replace({ + ...resource, + lastLoginAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + } catch { + // Non-critical — don't throw + } +} + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} diff --git a/services/platform-service/src/modules/auth/routes.ts b/services/platform-service/src/modules/auth/routes.ts new file mode 100644 index 00000000..6619921a --- /dev/null +++ b/services/platform-service/src/modules/auth/routes.ts @@ -0,0 +1,141 @@ +/** + * Auth REST endpoints. + * + * POST /auth/login — login with email + password + * POST /auth/register — register new user + * POST /auth/refresh — refresh access token + * GET /auth/me — get current user from token + * POST /auth/verify — service-to-service token verification + */ + +import type { FastifyInstance } from "fastify"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import { BadRequestError, UnauthorizedError } from "../../lib/errors.js"; +import * as repo from "./repository.js"; +import * as jwt from "./jwt.js"; +import { LoginSchema, RegisterSchema, RefreshSchema, type UserDoc } from "./types.js"; + +export async function authRoutes(app: FastifyInstance) { + // Login + app.post("/auth/login", async (req) => { + const parsed = LoginSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const { email, password } = parsed.data; + const user = await repo.getByEmail(email); + if (!user) throw new UnauthorizedError("Invalid email or password"); + if (user.status !== "active") throw new UnauthorizedError("Account is disabled"); + + const valid = await repo.verifyPassword(password, user.passwordHash); + if (!valid) throw new UnauthorizedError("Invalid email or password"); + + await repo.updateLastLogin(user.id); + + const accessToken = await jwt.createAccessToken({ + sub: user.id, + email: user.email, + role: user.role, + }); + const refreshToken = await jwt.createRefreshToken({ sub: user.id }); + + return { + accessToken, + refreshToken, + user: { id: user.id, email: user.email, role: user.role, displayName: user.displayName }, + }; + }); + + // Register + app.post("/auth/register", async (req, reply) => { + const parsed = RegisterSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const { email, password, displayName, role } = parsed.data; + + const existing = await repo.getByEmail(email); + if (existing) throw new BadRequestError("Email already registered"); + + const now = new Date().toISOString(); + const user: UserDoc = { + id: `usr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + productId: PRODUCT_ID, + email: email.toLowerCase(), + passwordHash: await repo.hashPassword(password), + role, + displayName, + status: "active", + lastLoginAt: null, + createdAt: now, + updatedAt: now, + }; + await repo.create(user); + + const accessToken = await jwt.createAccessToken({ + sub: user.id, + email: user.email, + role: user.role, + }); + const refreshToken = await jwt.createRefreshToken({ sub: user.id }); + + reply.code(201); + return { + accessToken, + refreshToken, + user: { id: user.id, email: user.email, role: user.role, displayName: user.displayName }, + }; + }); + + // Refresh + app.post("/auth/refresh", async (req) => { + const parsed = RefreshSchema.safeParse(req.body); + if (!parsed.success) throw new BadRequestError("refreshToken is required"); + + try { + const payload = await jwt.verifyToken(parsed.data.refreshToken); + if (payload.type !== "refresh") throw new Error("Not a refresh token"); + + const user = await repo.getById(payload.sub); + if (!user || user.status !== "active") throw new UnauthorizedError("User not found or disabled"); + + const accessToken = await jwt.createAccessToken({ + sub: user.id, + email: user.email, + role: user.role, + }); + return { accessToken }; + } catch { + throw new UnauthorizedError("Invalid or expired refresh token"); + } + }); + + // Me + app.get("/auth/me", async (req) => { + const auth = req.headers.authorization; + if (!auth?.startsWith("Bearer ")) throw new UnauthorizedError(); + const token = auth.slice(7); + + try { + const payload = await jwt.verifyToken(token); + const user = await repo.getById(payload.sub); + if (!user) throw new UnauthorizedError("User not found"); + return { id: user.id, email: user.email, role: user.role, displayName: user.displayName }; + } catch { + throw new UnauthorizedError("Invalid or expired token"); + } + }); + + // Service-to-service token verification + app.post("/auth/verify", async (req) => { + const { token } = req.body as { token?: string }; + if (!token) throw new BadRequestError("token is required"); + + try { + const payload = await jwt.verifyToken(token); + return { valid: true, payload }; + } catch { + return { valid: false, payload: null }; + } + }); +} diff --git a/services/platform-service/src/modules/auth/types.ts b/services/platform-service/src/modules/auth/types.ts new file mode 100644 index 00000000..0b7343c5 --- /dev/null +++ b/services/platform-service/src/modules/auth/types.ts @@ -0,0 +1,46 @@ +/** + * Auth types — consolidates 3 JWT implementations (Python backend, TS admin, TS user). + */ + +import { z } from "zod"; + +export interface UserDoc { + id: string; + productId: string; + email: string; + passwordHash: string; + role: "super_admin" | "admin" | "viewer" | "user"; + displayName: string; + status: "active" | "disabled"; + lastLoginAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface TokenPayload { + sub: string; + email: string; + role: string; + productId: string; + iat: number; + exp: number; +} + +export const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export const RegisterSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(1), + role: z.enum(["admin", "viewer", "user"]).default("user"), +}); + +export const RefreshSchema = z.object({ + refreshToken: z.string().min(1), +}); + +export type LoginInput = z.infer; +export type RegisterInput = z.infer; diff --git a/services/platform-service/src/modules/blob/blob.test.ts b/services/platform-service/src/modules/blob/blob.test.ts new file mode 100644 index 00000000..c52585cb --- /dev/null +++ b/services/platform-service/src/modules/blob/blob.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for blob storage schemas. + */ + +import { describe, it, expect } from "vitest"; +import { GenerateSasSchema, ListBlobsSchema, DeleteBlobSchema, UploadMetadataSchema } from "./types.js"; + +describe("GenerateSasSchema", () => { + it("accepts valid request with defaults", () => { + const result = GenerateSasSchema.safeParse({ + container: "audio", + blobName: "lysnrai/user123/recording.wav", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.permissions).toBe("r"); + expect(result.data.expiresInMinutes).toBe(60); + } + }); + + it("accepts write permissions", () => { + const result = GenerateSasSchema.safeParse({ + container: "attachments", + blobName: "lysnrai/item123/screenshot.png", + permissions: "rw", + expiresInMinutes: 15, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid container", () => { + const result = GenerateSasSchema.safeParse({ + container: "nonexistent", + blobName: "test.txt", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty blobName", () => { + const result = GenerateSasSchema.safeParse({ + container: "audio", + blobName: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects expiry over 24 hours", () => { + const result = GenerateSasSchema.safeParse({ + container: "audio", + blobName: "test.wav", + expiresInMinutes: 1441, + }); + expect(result.success).toBe(false); + }); + + it("accepts all known containers", () => { + for (const container of ["audio", "transcripts", "attachments", "avatars", "releases", "backups"]) { + const result = GenerateSasSchema.safeParse({ + container, + blobName: "test.bin", + }); + expect(result.success).toBe(true); + } + }); +}); + +describe("ListBlobsSchema", () => { + it("accepts container with defaults", () => { + const result = ListBlobsSchema.safeParse({ container: "audio" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(100); + expect(result.data.prefix).toBeUndefined(); + } + }); + + it("accepts prefix and custom limit", () => { + const result = ListBlobsSchema.safeParse({ + container: "transcripts", + prefix: "lysnrai/user123/", + limit: "50", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + expect(result.data.prefix).toBe("lysnrai/user123/"); + } + }); + + it("rejects limit over 500", () => { + const result = ListBlobsSchema.safeParse({ + container: "audio", + limit: "501", + }); + expect(result.success).toBe(false); + }); +}); + +describe("DeleteBlobSchema", () => { + it("accepts valid delete request", () => { + const result = DeleteBlobSchema.safeParse({ + container: "attachments", + blobName: "lysnrai/item123/file.pdf", + }); + expect(result.success).toBe(true); + }); + + it("rejects missing blobName", () => { + const result = DeleteBlobSchema.safeParse({ + container: "audio", + }); + expect(result.success).toBe(false); + }); +}); + +describe("UploadMetadataSchema", () => { + it("accepts with defaults", () => { + const result = UploadMetadataSchema.safeParse({ + container: "avatars", + blobName: "lysnrai/user123/avatar.jpg", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.contentType).toBe("application/octet-stream"); + } + }); + + it("accepts custom content type and metadata", () => { + const result = UploadMetadataSchema.safeParse({ + container: "transcripts", + blobName: "lysnrai/user123/transcript.pdf", + contentType: "application/pdf", + metadata: { userId: "user123", format: "pdf" }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.contentType).toBe("application/pdf"); + expect(result.data.metadata?.userId).toBe("user123"); + } + }); +}); diff --git a/services/platform-service/src/modules/blob/routes.ts b/services/platform-service/src/modules/blob/routes.ts new file mode 100644 index 00000000..5ed32806 --- /dev/null +++ b/services/platform-service/src/modules/blob/routes.ts @@ -0,0 +1,155 @@ +/** + * Blob storage REST endpoints. + * + * POST /blob/sas — generate SAS URL for direct browser upload/download + * GET /blob/list — list blobs in a container (with optional prefix) + * DELETE /blob/delete — delete a blob + * GET /blob/info/:container/:blobName(*) — get blob metadata + * GET /blob/containers — list available containers and their status + */ + +import type { FastifyInstance } from "fastify"; +import { verifyToken } from "../auth/jwt.js"; +import { BadRequestError, UnauthorizedError, NotFoundError } from "../../lib/errors.js"; +import { + getContainerClient, + generateSasUrl, + isBlobStorageConfigured, + BLOB_CONTAINERS, +} from "../../lib/blob.js"; +import { + GenerateSasSchema, + ListBlobsSchema, + DeleteBlobSchema, + type BlobInfo, +} from "./types.js"; + +async function requireAuth(req: { headers: Record }) { + const authHeader = req.headers.authorization; + if (!authHeader || typeof authHeader !== "string") { + throw new UnauthorizedError("Missing Authorization header"); + } + const token = authHeader.replace(/^Bearer\s+/i, ""); + return verifyToken(token); +} + +export async function blobRoutes(app: FastifyInstance) { + // Generate SAS URL for direct upload/download + app.post("/blob/sas", async (req) => { + const auth = await requireAuth(req); + const parsed = GenerateSasSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const { container, blobName, permissions, expiresInMinutes } = parsed.data; + + // Only admins can generate write/delete SAS tokens + if (permissions !== "r" && auth.role !== "admin") { + throw new UnauthorizedError("Only admins can generate write SAS tokens"); + } + + const sasUrl = generateSasUrl(container, blobName, permissions, expiresInMinutes); + return { + sasUrl, + container, + blobName, + permissions, + expiresInMinutes, + expiresAt: new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString(), + }; + }); + + // List blobs in a container + app.get("/blob/list", async (req) => { + await requireAuth(req); + const parsed = ListBlobsSchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const { container, prefix, limit } = parsed.data; + const containerClient = await getContainerClient(container); + + const blobs: BlobInfo[] = []; + let count = 0; + for await (const blob of containerClient.listBlobsFlat({ prefix: prefix || undefined })) { + if (count >= limit) break; + blobs.push({ + name: blob.name, + container, + contentType: blob.properties.contentType, + size: blob.properties.contentLength ?? 0, + lastModified: blob.properties.lastModified, + url: `${containerClient.url}/${blob.name}`, + metadata: blob.metadata ?? {}, + }); + count++; + } + + return { blobs, count: blobs.length, container, prefix: prefix || null }; + }); + + // Delete a blob + app.delete("/blob/delete", async (req) => { + const auth = await requireAuth(req); + if (auth.role !== "admin") { + throw new UnauthorizedError("Only admins can delete blobs"); + } + + const parsed = DeleteBlobSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const { container, blobName } = parsed.data; + const containerClient = await getContainerClient(container); + const blobClient = containerClient.getBlobClient(blobName); + + const exists = await blobClient.exists(); + if (!exists) { + throw new NotFoundError(`Blob not found: ${container}/${blobName}`); + } + + await blobClient.delete(); + return { success: true, container, blobName }; + }); + + // Get blob metadata/info + app.get("/blob/info/:container/:blobName", async (req) => { + await requireAuth(req); + const { container, blobName } = req.params as { container: string; blobName: string }; + + if (!Object.values(BLOB_CONTAINERS).includes(container as never)) { + throw new BadRequestError(`Invalid container: ${container}`); + } + + const containerClient = await getContainerClient(container); + const blobClient = containerClient.getBlobClient(blobName); + + const exists = await blobClient.exists(); + if (!exists) { + throw new NotFoundError(`Blob not found: ${container}/${blobName}`); + } + + const props = await blobClient.getProperties(); + return { + name: blobName, + container, + contentType: props.contentType, + size: props.contentLength, + lastModified: props.lastModified, + url: blobClient.url, + metadata: props.metadata ?? {}, + }; + }); + + // List available containers and config status + app.get("/blob/containers", async (req) => { + await requireAuth(req); + return { + configured: isBlobStorageConfigured(), + containers: Object.entries(BLOB_CONTAINERS).map(([key, name]) => ({ + key, + name, + })), + }; + }); +} diff --git a/services/platform-service/src/modules/blob/types.ts b/services/platform-service/src/modules/blob/types.ts new file mode 100644 index 00000000..89256daa --- /dev/null +++ b/services/platform-service/src/modules/blob/types.ts @@ -0,0 +1,48 @@ +/** + * Blob storage types — Zod schemas for upload/download/list operations. + */ + +import { z } from "zod"; +import { BLOB_CONTAINERS, type BlobContainerName } from "../../lib/blob.js"; + +const containerNames = Object.values(BLOB_CONTAINERS) as [string, ...string[]]; + +export const GenerateSasSchema = z.object({ + container: z.enum(containerNames), + blobName: z.string().min(1).max(1024), + permissions: z.enum(["r", "w", "rw", "rwc", "rwd"]).default("r"), + expiresInMinutes: z.number().int().min(1).max(1440).default(60), +}); + +export const ListBlobsSchema = z.object({ + container: z.enum(containerNames), + prefix: z.string().max(512).optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + +export const DeleteBlobSchema = z.object({ + container: z.enum(containerNames), + blobName: z.string().min(1).max(1024), +}); + +export const UploadMetadataSchema = z.object({ + container: z.enum(containerNames), + blobName: z.string().min(1).max(1024), + contentType: z.string().default("application/octet-stream"), + metadata: z.record(z.string()).optional(), +}); + +export interface BlobInfo { + name: string; + container: string; + contentType: string | undefined; + size: number; + lastModified: Date | undefined; + url: string; + metadata: Record; +} + +export type GenerateSasInput = z.infer; +export type ListBlobsInput = z.infer; +export type DeleteBlobInput = z.infer; +export type UploadMetadataInput = z.infer; diff --git a/services/platform-service/src/modules/flags/flags.test.ts b/services/platform-service/src/modules/flags/flags.test.ts new file mode 100644 index 00000000..263c57bf --- /dev/null +++ b/services/platform-service/src/modules/flags/flags.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for feature flags module — types + validation. + */ + +import { describe, it, expect } from "vitest"; +import { CreateFlagSchema, UpdateFlagSchema } from "./types.js"; + +describe("CreateFlagSchema", () => { + it("accepts valid flag with defaults", () => { + const result = CreateFlagSchema.safeParse({ key: "dark_mode" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enabled).toBe(true); + expect(result.data.percentage).toBe(100); + expect(result.data.platforms).toEqual([]); + expect(result.data.segments).toEqual([]); + } + }); + + it("accepts full input", () => { + const result = CreateFlagSchema.safeParse({ + key: "new_ui", + enabled: false, + description: "New dashboard UI", + platforms: ["web", "ios"], + segments: ["beta"], + percentage: 50, + }); + expect(result.success).toBe(true); + }); + + it("rejects key with spaces", () => { + const result = CreateFlagSchema.safeParse({ key: "dark mode" }); + expect(result.success).toBe(false); + }); + + it("rejects key with uppercase", () => { + const result = CreateFlagSchema.safeParse({ key: "DarkMode" }); + expect(result.success).toBe(false); + }); + + it("rejects percentage > 100", () => { + const result = CreateFlagSchema.safeParse({ key: "test", percentage: 150 }); + expect(result.success).toBe(false); + }); + + it("rejects percentage < 0", () => { + const result = CreateFlagSchema.safeParse({ key: "test", percentage: -10 }); + expect(result.success).toBe(false); + }); +}); + +describe("UpdateFlagSchema", () => { + it("accepts partial updates", () => { + const result = UpdateFlagSchema.safeParse({ + enabled: false, + percentage: 25, + }); + expect(result.success).toBe(true); + }); + + it("accepts empty object", () => { + const result = UpdateFlagSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("accepts platform list update", () => { + const result = UpdateFlagSchema.safeParse({ + platforms: ["ios", "android"], + }); + expect(result.success).toBe(true); + }); +}); + +describe("hashUserFlag (deterministic A/B assignment)", () => { + // Import the hash function from routes + let hashUserFlag: (userId: string, flagKey: string) => number; + + beforeAll(async () => { + const mod = await import("./routes.js"); + hashUserFlag = mod.hashUserFlag; + }); + + it("returns the same bucket for the same user+flag", () => { + const a = hashUserFlag("user_123", "dark_mode"); + const b = hashUserFlag("user_123", "dark_mode"); + const c = hashUserFlag("user_123", "dark_mode"); + expect(a).toBe(b); + expect(b).toBe(c); + }); + + it("returns different buckets for different users", () => { + const a = hashUserFlag("user_1", "feature_x"); + const b = hashUserFlag("user_2", "feature_x"); + // Statistically they should differ (not guaranteed, but extremely likely) + // We test multiple pairs to be safe + const buckets = new Set(); + for (let i = 0; i < 20; i++) { + buckets.add(hashUserFlag(`user_${i}`, "feature_x")); + } + // At least 5 distinct buckets out of 20 users + expect(buckets.size).toBeGreaterThanOrEqual(5); + }); + + it("returns different buckets for different flags", () => { + const a = hashUserFlag("user_42", "flag_a"); + const b = hashUserFlag("user_42", "flag_b"); + expect(a).not.toBe(b); + }); + + it("always returns 0-99", () => { + for (let i = 0; i < 100; i++) { + const bucket = hashUserFlag(`user_${i}`, `flag_${i}`); + expect(bucket).toBeGreaterThanOrEqual(0); + expect(bucket).toBeLessThan(100); + } + }); + + it("produces roughly uniform distribution", () => { + // Hash 1000 users, check that no bucket range gets >70% or <30% + let below50 = 0; + const total = 1000; + for (let i = 0; i < total; i++) { + if (hashUserFlag(`user_${i}`, "ab_test") < 50) below50++; + } + const ratio = below50 / total; + expect(ratio).toBeGreaterThan(0.3); + expect(ratio).toBeLessThan(0.7); + }); +}); diff --git a/services/platform-service/src/modules/flags/repository.ts b/services/platform-service/src/modules/flags/repository.ts new file mode 100644 index 00000000..957b8e16 --- /dev/null +++ b/services/platform-service/src/modules/flags/repository.ts @@ -0,0 +1,63 @@ +/** + * Feature flags repository — Cosmos DB CRUD. + */ + +import { getContainer } from "../../lib/cosmos.js"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import type { FeatureFlagDoc } from "./types.js"; + +function container() { + return getContainer("feature_flags"); +} + +export async function list(): Promise { + const { resources } = await container().items + .query({ + query: "SELECT * FROM c WHERE c.productId = @productId ORDER BY c.key ASC", + parameters: [{ name: "@productId", value: PRODUCT_ID }], + }) + .fetchAll(); + return resources; +} + +export async function getByKey(key: string): Promise { + const { resources } = await container().items + .query({ + query: "SELECT * FROM c WHERE c.productId = @productId AND c.key = @key", + parameters: [ + { name: "@productId", value: PRODUCT_ID }, + { name: "@key", value: key }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function create(doc: FeatureFlagDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as FeatureFlagDoc; +} + +export async function update( + id: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container().item(id, id).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(id, id).replace(merged); + return resource as FeatureFlagDoc; + } catch { + return null; + } +} + +export async function remove(id: string): Promise { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/flags/routes.ts b/services/platform-service/src/modules/flags/routes.ts new file mode 100644 index 00000000..ac65b2cb --- /dev/null +++ b/services/platform-service/src/modules/flags/routes.ts @@ -0,0 +1,141 @@ +/** + * Feature flags REST endpoints. + * + * GET /flags — list all flags (admin) + * GET /flags/poll — polling endpoint for clients (returns enabled flags) + * GET /flags/:key — get single flag + * POST /flags — create flag + * PUT /flags/:key — update flag + * DELETE /flags/:key — delete flag + * POST /flags/kill — kill switch (disable all flags matching criteria) + */ + +import type { FastifyInstance } from "fastify"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import { BadRequestError, NotFoundError } from "../../lib/errors.js"; +import * as repo from "./repository.js"; +import { CreateFlagSchema, UpdateFlagSchema, type FeatureFlagDoc } from "./types.js"; + +/** + * FNV-1a hash — deterministic 32-bit hash for user+flag assignment. + * Same (userId, flagKey) pair always produces the same 0-99 bucket. + */ +function hashUserFlag(userId: string, flagKey: string): number { + const input = `${userId}:${flagKey}`; + let hash = 0x811c9dc5; // FNV offset basis + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); // FNV prime + } + return ((hash >>> 0) % 100); // 0-99 bucket +} + +export { hashUserFlag }; + +export async function flagRoutes(app: FastifyInstance) { + // List all flags + app.get("/flags", async () => { + return { flags: await repo.list() }; + }); + + // Polling endpoint for clients + // ?userId=xxx — deterministic hash ensures same user always gets same flag assignment + // ?platform=xxx — filter flags by platform + app.get("/flags/poll", async (req) => { + const { platform, userId } = req.query as { platform?: string; userId?: string }; + const all = await repo.list(); + const active = all.filter((f) => { + if (!f.enabled) return false; + if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false; + return true; + }); + const flags: Record = {}; + for (const f of active) { + if (f.percentage >= 100) { + flags[f.key] = true; + } else if (f.percentage <= 0) { + flags[f.key] = false; + } else if (userId) { + // Deterministic: same user+flag always gets the same result + flags[f.key] = hashUserFlag(userId, f.key) < f.percentage; + } else { + // Fallback for anonymous requests (no userId) + flags[f.key] = Math.random() * 100 < f.percentage; + } + } + return { flags, productId: PRODUCT_ID }; + }); + + // Get flag + app.get("/flags/:key", async (req) => { + const { key } = req.params as { key: string }; + const flag = await repo.getByKey(key); + if (!flag) throw new NotFoundError("Flag not found"); + return flag; + }); + + // Create flag + app.post("/flags", async (req, reply) => { + const parsed = CreateFlagSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const input = parsed.data; + const existing = await repo.getByKey(input.key); + if (existing) throw new BadRequestError(`Flag "${input.key}" already exists`); + + const now = new Date().toISOString(); + const doc: FeatureFlagDoc = { + id: `flag_${PRODUCT_ID}_${input.key}`, + productId: PRODUCT_ID, + ...input, + createdAt: now, + updatedAt: now, + }; + const created = await repo.create(doc); + reply.code(201); + return created; + }); + + // Update flag + app.put("/flags/:key", async (req) => { + const { key } = req.params as { key: string }; + const flag = await repo.getByKey(key); + if (!flag) throw new NotFoundError("Flag not found"); + + const parsed = UpdateFlagSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const updated = await repo.update(flag.id, parsed.data); + if (!updated) throw new NotFoundError("Flag update failed"); + return updated; + }); + + // Delete flag + app.delete("/flags/:key", async (req) => { + const { key } = req.params as { key: string }; + const flag = await repo.getByKey(key); + if (!flag) throw new NotFoundError("Flag not found"); + await repo.remove(flag.id); + return { success: true }; + }); + + // Kill switch — disable all flags matching optional platform filter + app.post("/flags/kill", async (req) => { + const { platform, keys } = req.body as { platform?: string; keys?: string[] }; + const all = await repo.list(); + const toDisable = all.filter((f) => { + if (keys && keys.length > 0 && !keys.includes(f.key)) return false; + if (platform && f.platforms.length > 0 && !f.platforms.includes(platform)) return false; + return f.enabled; + }); + + const disabled: string[] = []; + for (const f of toDisable) { + await repo.update(f.id, { enabled: false }); + disabled.push(f.key); + } + return { disabled, count: disabled.length }; + }); +} diff --git a/services/platform-service/src/modules/flags/types.ts b/services/platform-service/src/modules/flags/types.ts new file mode 100644 index 00000000..eb57819a --- /dev/null +++ b/services/platform-service/src/modules/flags/types.ts @@ -0,0 +1,39 @@ +/** + * Feature flags types — extends kill switch to full feature flags. + * Ported from admin dashboard + desktop + mobile kill switch implementations. + */ + +import { z } from "zod"; + +export interface FeatureFlagDoc { + id: string; + productId: string; + key: string; + enabled: boolean; + description: string; + platforms: string[]; + segments: string[]; + percentage: number; + createdAt: string; + updatedAt: string; +} + +export const CreateFlagSchema = z.object({ + key: z.string().min(1).regex(/^[a-z0-9_]+$/), + enabled: z.boolean().default(true), + description: z.string().default(""), + platforms: z.array(z.string()).default([]), + segments: z.array(z.string()).default([]), + percentage: z.number().min(0).max(100).default(100), +}); + +export const UpdateFlagSchema = z.object({ + enabled: z.boolean().optional(), + description: z.string().optional(), + platforms: z.array(z.string()).optional(), + segments: z.array(z.string()).optional(), + percentage: z.number().min(0).max(100).optional(), +}); + +export type CreateFlagInput = z.infer; +export type UpdateFlagInput = z.infer; diff --git a/services/platform-service/src/modules/notifications/notifications.test.ts b/services/platform-service/src/modules/notifications/notifications.test.ts new file mode 100644 index 00000000..f359f756 --- /dev/null +++ b/services/platform-service/src/modules/notifications/notifications.test.ts @@ -0,0 +1,61 @@ +/** + * Unit tests for notifications module — types + validation. + */ + +import { describe, it, expect } from "vitest"; +import { RegisterDeviceSchema, UpdatePrefsSchema } from "./types.js"; + +describe("RegisterDeviceSchema", () => { + it("accepts valid device registration", () => { + const result = RegisterDeviceSchema.safeParse({ + userId: "user_123", + deviceId: "dev_mac_001", + platform: "macos", + }); + expect(result.success).toBe(true); + }); + + it("accepts with optional fields", () => { + const result = RegisterDeviceSchema.safeParse({ + userId: "user_123", + deviceId: "dev_ios_001", + platform: "ios", + pushToken: "apns_token_abc", + appVersion: "1.2.0", + osVersion: "17.2", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid platform", () => { + const result = RegisterDeviceSchema.safeParse({ + userId: "user_123", + deviceId: "dev_001", + platform: "linux", + }); + expect(result.success).toBe(false); + }); + + it("rejects missing deviceId", () => { + const result = RegisterDeviceSchema.safeParse({ + userId: "user_123", + platform: "ios", + }); + expect(result.success).toBe(false); + }); +}); + +describe("UpdatePrefsSchema", () => { + it("accepts partial updates", () => { + const result = UpdatePrefsSchema.safeParse({ + pushEnabled: false, + categories: { marketing: false, updates: true }, + }); + expect(result.success).toBe(true); + }); + + it("accepts empty object", () => { + const result = UpdatePrefsSchema.safeParse({}); + expect(result.success).toBe(true); + }); +}); diff --git a/services/platform-service/src/modules/notifications/repository.ts b/services/platform-service/src/modules/notifications/repository.ts new file mode 100644 index 00000000..879b235b --- /dev/null +++ b/services/platform-service/src/modules/notifications/repository.ts @@ -0,0 +1,61 @@ +/** + * Notifications repository — Cosmos DB CRUD for devices + prefs. + */ + +import { getContainer } from "../../lib/cosmos.js"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import type { DeviceDoc, NotificationPrefsDoc } from "./types.js"; + +function deviceContainer() { + return getContainer("devices"); +} + +function prefsContainer() { + return getContainer("notification_prefs"); +} + +// ── Devices ── + +export async function getDevicesByUser(userId: string): Promise { + const { resources } = await deviceContainer().items + .query({ + query: "SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId", + parameters: [ + { name: "@productId", value: PRODUCT_ID }, + { name: "@userId", value: userId }, + ], + }) + .fetchAll(); + return resources; +} + +export async function upsertDevice(doc: DeviceDoc): Promise { + const { resource } = await deviceContainer().items.upsert(doc); + return resource!; +} + +export async function removeDevice(id: string, userId: string): Promise { + try { + await deviceContainer().item(id, userId).delete(); + return true; + } catch { + return false; + } +} + +// ── Preferences ── + +export async function getPrefs(userId: string): Promise { + const id = `prefs_${PRODUCT_ID}_${userId}`; + try { + const { resource } = await prefsContainer().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function upsertPrefs(doc: NotificationPrefsDoc): Promise { + const { resource } = await prefsContainer().items.upsert(doc); + return resource!; +} diff --git a/services/platform-service/src/modules/notifications/routes.ts b/services/platform-service/src/modules/notifications/routes.ts new file mode 100644 index 00000000..79573fda --- /dev/null +++ b/services/platform-service/src/modules/notifications/routes.ts @@ -0,0 +1,95 @@ +/** + * Notifications REST endpoints. + * + * POST /devices — register/update device + * GET /devices/:userId — list user devices + * DELETE /devices/:id — remove device + * GET /notifications/prefs/:userId — get notification preferences + * PUT /notifications/prefs/:userId — update notification preferences + */ + +import type { FastifyInstance } from "fastify"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import { BadRequestError, NotFoundError } from "../../lib/errors.js"; +import * as repo from "./repository.js"; +import { + RegisterDeviceSchema, + UpdatePrefsSchema, + type DeviceDoc, + type NotificationPrefsDoc, +} from "./types.js"; + +export async function notificationRoutes(app: FastifyInstance) { + // Register/update device + app.post("/devices", async (req) => { + const parsed = RegisterDeviceSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const input = parsed.data; + const now = new Date().toISOString(); + const doc: DeviceDoc = { + id: `dev_${input.userId}_${input.deviceId}`, + productId: PRODUCT_ID, + ...input, + lastSeenAt: now, + createdAt: now, + updatedAt: now, + }; + return repo.upsertDevice(doc); + }); + + // List user devices + app.get("/devices/:userId", async (req) => { + const { userId } = req.params as { userId: string }; + return { devices: await repo.getDevicesByUser(userId) }; + }); + + // Remove device + app.delete("/devices/:id", async (req) => { + const { id } = req.params as { id: string }; + const { userId } = req.query as { userId?: string }; + if (!userId) throw new BadRequestError("userId query parameter is required"); + const deleted = await repo.removeDevice(id, userId); + if (!deleted) throw new NotFoundError("Device not found"); + return { success: true }; + }); + + // Get notification prefs + app.get("/notifications/prefs/:userId", async (req) => { + const { userId } = req.params as { userId: string }; + const prefs = await repo.getPrefs(userId); + if (!prefs) { + // Return defaults + return { + userId, + pushEnabled: true, + emailEnabled: true, + categories: {}, + }; + } + return prefs; + }); + + // Update notification prefs + app.put("/notifications/prefs/:userId", async (req) => { + const { userId } = req.params as { userId: string }; + const parsed = UpdatePrefsSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const existing = await repo.getPrefs(userId); + const now = new Date().toISOString(); + const doc: NotificationPrefsDoc = { + id: `prefs_${PRODUCT_ID}_${userId}`, + productId: PRODUCT_ID, + userId, + pushEnabled: parsed.data.pushEnabled ?? existing?.pushEnabled ?? true, + emailEnabled: parsed.data.emailEnabled ?? existing?.emailEnabled ?? true, + categories: { ...(existing?.categories ?? {}), ...(parsed.data.categories ?? {}) }, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + return repo.upsertPrefs(doc); + }); +} diff --git a/services/platform-service/src/modules/notifications/types.ts b/services/platform-service/src/modules/notifications/types.ts new file mode 100644 index 00000000..8773f5d0 --- /dev/null +++ b/services/platform-service/src/modules/notifications/types.ts @@ -0,0 +1,49 @@ +/** + * Notifications types — device registration + preferences. + * Ported from Python backend device/notification routes. + */ + +import { z } from "zod"; + +export interface DeviceDoc { + id: string; + productId: string; + userId: string; + deviceId: string; + platform: "ios" | "android" | "macos" | "windows" | "web"; + pushToken?: string; + appVersion?: string; + osVersion?: string; + lastSeenAt: string; + createdAt: string; + updatedAt: string; +} + +export interface NotificationPrefsDoc { + id: string; + productId: string; + userId: string; + pushEnabled: boolean; + emailEnabled: boolean; + categories: Record; + createdAt: string; + updatedAt: string; +} + +export const RegisterDeviceSchema = z.object({ + userId: z.string().min(1), + deviceId: z.string().min(1), + platform: z.enum(["ios", "android", "macos", "windows", "web"]), + pushToken: z.string().optional(), + appVersion: z.string().optional(), + osVersion: z.string().optional(), +}); + +export const UpdatePrefsSchema = z.object({ + pushEnabled: z.boolean().optional(), + emailEnabled: z.boolean().optional(), + categories: z.record(z.boolean()).optional(), +}); + +export type RegisterDeviceInput = z.infer; +export type UpdatePrefsInput = z.infer; diff --git a/services/platform-service/src/modules/ratelimit/routes.ts b/services/platform-service/src/modules/ratelimit/routes.ts new file mode 100644 index 00000000..1f3b0464 --- /dev/null +++ b/services/platform-service/src/modules/ratelimit/routes.ts @@ -0,0 +1,108 @@ +/** + * Rate limiting REST endpoints. + * + * POST /ratelimit/check — check (and record) a request against limits + * POST /ratelimit/reset — reset rate limit for a key + * GET /ratelimit/config — get current rate limit config + * PUT /ratelimit/config — update rate limit config (admin) + */ + +import type { FastifyInstance } from "fastify"; +import { PRODUCT_ID } from "../../lib/product-config.js"; +import { BadRequestError } from "../../lib/errors.js"; +import { CheckRateLimitSchema, RateLimitConfigSchema } from "./types.js"; +import type { RateLimitRule, RateLimitConfig } from "./types.js"; +import * as store from "./store.js"; + +/** + * Default rate limit rules — configurable per product via + * RATE_LIMIT_CONFIG_JSON env var. + */ +function loadConfig(): Map { + const configs = new Map(); + + const json = process.env.RATE_LIMIT_CONFIG_JSON; + if (json) { + try { + const parsed = JSON.parse(json) as RateLimitConfig[]; + for (const c of parsed) configs.set(c.productId, c); + return configs; + } catch { /* fall through to defaults */ } + } + + // Sensible defaults + configs.set(PRODUCT_ID, { + productId: PRODUCT_ID, + rules: [ + { maxRequests: 60, windowSeconds: 60 }, // 60 req/min global + { maxRequests: 5, windowSeconds: 60, routePrefix: "/api/auth" }, // 5 auth attempts/min + { maxRequests: 10, windowSeconds: 60, routePrefix: "/api/stripe" }, // 10 stripe calls/min + ], + }); + + return configs; +} + +const configMap = loadConfig(); + +function findRule(productId: string, routePrefix?: string): RateLimitRule { + const config = configMap.get(productId); + if (!config) { + return { maxRequests: 60, windowSeconds: 60 }; // fallback + } + + // Find most specific matching rule + if (routePrefix) { + const specific = config.rules.find((r) => r.routePrefix && routePrefix.startsWith(r.routePrefix)); + if (specific) return specific; + } + + // Fall back to global rule (no routePrefix) + const global = config.rules.find((r) => !r.routePrefix); + return global ?? { maxRequests: 60, windowSeconds: 60 }; +} + +export async function rateLimitRoutes(app: FastifyInstance) { + // Check + record + app.post("/ratelimit/check", async (req, reply) => { + const parsed = CheckRateLimitSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const { key, productId, routePrefix } = parsed.data; + const rule = findRule(productId, routePrefix); + const compositeKey = `${productId}:${key}${routePrefix ? `:${routePrefix}` : ""}`; + const result = store.checkAndRecord(compositeKey, rule); + + if (!result.allowed) { + reply.code(429).header("Retry-After", String(Math.ceil((result.retryAfterMs ?? 0) / 1000))); + } + return result; + }); + + // Reset + app.post("/ratelimit/reset", async (req) => { + const { key, productId = PRODUCT_ID } = req.body as { key?: string; productId?: string }; + if (!key) throw new BadRequestError("key is required"); + store.reset(`${productId}:${key}`); + return { reset: true }; + }); + + // Get config + app.get("/ratelimit/config", async (req) => { + const { productId = PRODUCT_ID } = req.query as { productId?: string }; + const config = configMap.get(productId); + return config ?? { productId, rules: [] }; + }); + + // Update config (admin) + app.put("/ratelimit/config", async (req) => { + const parsed = RateLimitConfigSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + configMap.set(parsed.data.productId, parsed.data); + return parsed.data; + }); +} diff --git a/services/platform-service/src/modules/ratelimit/store.ts b/services/platform-service/src/modules/ratelimit/store.ts new file mode 100644 index 00000000..3b71a1de --- /dev/null +++ b/services/platform-service/src/modules/ratelimit/store.ts @@ -0,0 +1,101 @@ +/** + * Rate limit store — sliding window counter. + * + * Default: in-memory Map (single-instance). + * When REDIS_URL is set: uses Redis sorted sets for horizontal scaling. + * + * Each key tracks request timestamps within the window. Old entries are + * pruned on every check. + */ + +import type { RateLimitEntry, RateLimitResult, RateLimitRule } from "./types.js"; + +// ── In-Memory Store ────────────────────────────────────────── + +const store = new Map(); + +/** + * Check (and record) a request against the sliding window for the given key. + * Returns whether the request is allowed and remaining quota. + */ +export function checkAndRecord(key: string, rule: RateLimitRule): RateLimitResult { + const now = Date.now(); + const windowMs = rule.windowSeconds * 1000; + const windowStart = now - windowMs; + + // Get or create entry + let entry = store.get(key); + if (!entry) { + entry = { timestamps: [] }; + store.set(key, entry); + } + + // Prune expired timestamps + entry.timestamps = entry.timestamps.filter((t) => t > windowStart); + + // Check limit + if (entry.timestamps.length >= rule.maxRequests) { + const oldestInWindow = entry.timestamps[0]; + const resetAt = new Date(oldestInWindow + windowMs).toISOString(); + const retryAfterMs = oldestInWindow + windowMs - now; + return { + allowed: false, + limit: rule.maxRequests, + remaining: 0, + resetAt, + retryAfterMs: Math.max(0, retryAfterMs), + }; + } + + // Record this request + entry.timestamps.push(now); + + const resetAt = new Date(now + windowMs).toISOString(); + return { + allowed: true, + limit: rule.maxRequests, + remaining: rule.maxRequests - entry.timestamps.length, + resetAt, + }; +} + +/** + * Reset the rate limit for a specific key (e.g., after a successful auth). + */ +export function reset(key: string): void { + store.delete(key); +} + +/** + * Get current state without recording a request (read-only check). + */ +export function peek(key: string, rule: RateLimitRule): RateLimitResult { + const now = Date.now(); + const windowMs = rule.windowSeconds * 1000; + const windowStart = now - windowMs; + + const entry = store.get(key); + if (!entry) { + return { + allowed: true, + limit: rule.maxRequests, + remaining: rule.maxRequests, + resetAt: new Date(now + windowMs).toISOString(), + }; + } + + const active = entry.timestamps.filter((t) => t > windowStart); + const remaining = Math.max(0, rule.maxRequests - active.length); + + return { + allowed: remaining > 0, + limit: rule.maxRequests, + remaining, + resetAt: new Date(now + windowMs).toISOString(), + }; +} + +/** Clear all entries (for testing). */ +export function clearAll(): void { + store.clear(); +} diff --git a/services/platform-service/src/modules/ratelimit/types.ts b/services/platform-service/src/modules/ratelimit/types.ts new file mode 100644 index 00000000..b3dfef2f --- /dev/null +++ b/services/platform-service/src/modules/ratelimit/types.ts @@ -0,0 +1,53 @@ +/** + * Rate limiting types — sliding window limiter with per-product config. + * + * Storage: in-memory Map (default) or Redis-backed (when REDIS_URL is set). + * Config: per-product rate limit rules loaded from RATE_LIMIT_CONFIG_JSON env + * or defaults to sensible values. + */ + +import { z } from "zod"; + +export interface RateLimitRule { + /** Max requests allowed in the window. */ + maxRequests: number; + /** Window size in seconds. */ + windowSeconds: number; + /** Optional: specific route prefix this rule applies to (e.g. "/api/auth"). */ + routePrefix?: string; +} + +export interface RateLimitConfig { + productId: string; + rules: RateLimitRule[]; +} + +export interface RateLimitEntry { + /** Sorted timestamps of requests within the window. */ + timestamps: number[]; +} + +export interface RateLimitResult { + allowed: boolean; + limit: number; + remaining: number; + resetAt: string; + retryAfterMs?: number; +} + +export const CheckRateLimitSchema = z.object({ + key: z.string().min(1), + productId: z.string().default("lysnrai"), + routePrefix: z.string().optional(), +}); + +export const RateLimitConfigSchema = z.object({ + productId: z.string().min(1), + rules: z.array(z.object({ + maxRequests: z.number().int().min(1), + windowSeconds: z.number().int().min(1), + routePrefix: z.string().optional(), + })), +}); + +export type CheckRateLimitInput = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts new file mode 100644 index 00000000..149ff303 --- /dev/null +++ b/services/platform-service/src/server.ts @@ -0,0 +1,86 @@ +/** + * Platform Service — Fastify server entry point. + * + * Modules: auth, audit, notifications, feature flags. + * Port: 4003 (configurable via PORT env var). + */ + +import { randomUUID } from "node:crypto"; +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import swagger from "@fastify/swagger"; +import metricsPlugin from "fastify-metrics"; +import { ServiceError } from "./lib/errors.js"; +import { authRoutes } from "./modules/auth/routes.js"; +import { auditRoutes } from "./modules/audit/routes.js"; +import { notificationRoutes } from "./modules/notifications/routes.js"; +import { flagRoutes } from "./modules/flags/routes.js"; +import { rateLimitRoutes } from "./modules/ratelimit/routes.js"; +import { blobRoutes } from "./modules/blob/routes.js"; +import { config } from "./lib/config.js"; + +const PORT = config.PORT; +const HOST = config.HOST; + +const app = Fastify({ logger: true }); + +// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated) +const corsOrigin = config.CORS_ORIGIN; +await app.register(cors, { + origin: corsOrigin ? corsOrigin.split(",").map((o) => o.trim()) : true, +}); + +// OpenAPI spec auto-generation (GET /api/docs/json) +await app.register(swagger, { + openapi: { + info: { title: "Platform Service", version: "0.1.0", description: "Auth, audit, notifications, feature flags, rate limiting" }, + servers: [{ url: `http://localhost:${PORT}` }], + }, +}); + +// Prometheus metrics +await app.register(metricsPlugin, { endpoint: "/metrics" }); + +// x-request-id: propagate incoming header or generate a new one +app.addHook("onRequest", async (req, reply) => { + const requestId = (req.headers["x-request-id"] as string) || randomUUID(); + req.headers["x-request-id"] = requestId; + reply.header("x-request-id", requestId); + req.log = req.log.child({ requestId }); +}); + +// Health check +app.get("/health", async (req) => ({ + status: "ok", + service: "platform-service", + version: "0.1.0", + timestamp: new Date().toISOString(), + requestId: req.headers["x-request-id"], +})); + +// Custom error handler +app.setErrorHandler((error, _req, reply) => { + if (error instanceof ServiceError) { + reply.code(error.statusCode).send({ error: error.message }); + return; + } + app.log.error(error); + reply.code(500).send({ error: "Internal server error" }); +}); + +// Register route modules +await app.register(authRoutes, { prefix: "/api" }); +await app.register(auditRoutes, { prefix: "/api" }); +await app.register(notificationRoutes, { prefix: "/api" }); +await app.register(flagRoutes, { prefix: "/api" }); +await app.register(rateLimitRoutes, { prefix: "/api" }); +await app.register(blobRoutes, { prefix: "/api" }); + +// Start +try { + await app.listen({ port: PORT, host: HOST }); + app.log.info(`Platform Service listening on ${HOST}:${PORT}`); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/services/platform-service/tsconfig.json b/services/platform-service/tsconfig.json new file mode 100644 index 00000000..d155b3cc --- /dev/null +++ b/services/platform-service/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/services/platform-service/vitest.config.ts b/services/platform-service/vitest.config.ts new file mode 100644 index 00000000..0f78dc01 --- /dev/null +++ b/services/platform-service/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + }, +});