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
This commit is contained in:
parent
72c6e64854
commit
e1ab956ac3
2
services/platform-service/.gitignore
vendored
Normal file
2
services/platform-service/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
16
services/platform-service/Dockerfile
Normal file
16
services/platform-service/Dockerfile
Normal file
@ -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"]
|
||||
33
services/platform-service/package.json
Normal file
33
services/platform-service/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
150
services/platform-service/src/lib/blob.ts
Normal file
150
services/platform-service/src/lib/blob.ts
Normal file
@ -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<string, ContainerClient>();
|
||||
|
||||
/**
|
||||
* 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<ContainerClient> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
31
services/platform-service/src/lib/config.ts
Normal file
31
services/platform-service/src/lib/config.ts
Normal file
@ -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);
|
||||
24
services/platform-service/src/lib/cosmos.ts
Normal file
24
services/platform-service/src/lib/cosmos.ts
Normal file
@ -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);
|
||||
}
|
||||
44
services/platform-service/src/lib/errors.test.ts
Normal file
44
services/platform-service/src/lib/errors.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
37
services/platform-service/src/lib/errors.ts
Normal file
37
services/platform-service/src/lib/errors.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
8
services/platform-service/src/lib/product-config.ts
Normal file
8
services/platform-service/src/lib/product-config.ts
Normal file
@ -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";
|
||||
70
services/platform-service/src/modules/audit/audit.test.ts
Normal file
70
services/platform-service/src/modules/audit/audit.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
74
services/platform-service/src/modules/audit/repository.ts
Normal file
74
services/platform-service/src/modules/audit/repository.ts
Normal file
@ -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<AuditDoc> {
|
||||
const { resource } = await container().items.create({
|
||||
...doc,
|
||||
ttl: doc.ttl ?? DEFAULT_TTL,
|
||||
});
|
||||
return resource as AuditDoc;
|
||||
}
|
||||
|
||||
export async function query(input: QueryAuditInput): Promise<AuditDoc[]> {
|
||||
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<AuditDoc>({ query: queryText, parameters })
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function getStats(days = 30): Promise<Record<string, number>> {
|
||||
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<string, number> = {};
|
||||
for (const r of resources) {
|
||||
stats[r.action] = r.count;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
59
services/platform-service/src/modules/audit/routes.ts
Normal file
59
services/platform-service/src/modules/audit/routes.ts
Normal file
@ -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<string, string>;
|
||||
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) };
|
||||
});
|
||||
}
|
||||
39
services/platform-service/src/modules/audit/types.ts
Normal file
39
services/platform-service/src/modules/audit/types.ts
Normal file
@ -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<string, unknown>;
|
||||
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<typeof CreateAuditSchema>;
|
||||
export type QueryAuditInput = z.infer<typeof QueryAuditSchema>;
|
||||
87
services/platform-service/src/modules/auth/auth.test.ts
Normal file
87
services/platform-service/src/modules/auth/auth.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
56
services/platform-service/src/modules/auth/jwt.ts
Normal file
56
services/platform-service/src/modules/auth/jwt.ts
Normal file
@ -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<string> {
|
||||
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<string> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
62
services/platform-service/src/modules/auth/repository.ts
Normal file
62
services/platform-service/src/modules/auth/repository.ts
Normal file
@ -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<UserDoc | null> {
|
||||
const { resources } = await container().items
|
||||
.query<UserDoc>({
|
||||
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<UserDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(user: UserDoc): Promise<UserDoc> {
|
||||
const { resource } = await container().items.create(user);
|
||||
return resource as UserDoc;
|
||||
}
|
||||
|
||||
export async function updateLastLogin(id: string): Promise<void> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
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<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
141
services/platform-service/src/modules/auth/routes.ts
Normal file
141
services/platform-service/src/modules/auth/routes.ts
Normal file
@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
46
services/platform-service/src/modules/auth/types.ts
Normal file
46
services/platform-service/src/modules/auth/types.ts
Normal file
@ -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<typeof LoginSchema>;
|
||||
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
||||
141
services/platform-service/src/modules/blob/blob.test.ts
Normal file
141
services/platform-service/src/modules/blob/blob.test.ts
Normal file
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
155
services/platform-service/src/modules/blob/routes.ts
Normal file
155
services/platform-service/src/modules/blob/routes.ts
Normal file
@ -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<string, string | string[] | undefined> }) {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
48
services/platform-service/src/modules/blob/types.ts
Normal file
48
services/platform-service/src/modules/blob/types.ts
Normal file
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export type GenerateSasInput = z.infer<typeof GenerateSasSchema>;
|
||||
export type ListBlobsInput = z.infer<typeof ListBlobsSchema>;
|
||||
export type DeleteBlobInput = z.infer<typeof DeleteBlobSchema>;
|
||||
export type UploadMetadataInput = z.infer<typeof UploadMetadataSchema>;
|
||||
130
services/platform-service/src/modules/flags/flags.test.ts
Normal file
130
services/platform-service/src/modules/flags/flags.test.ts
Normal file
@ -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<number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
63
services/platform-service/src/modules/flags/repository.ts
Normal file
63
services/platform-service/src/modules/flags/repository.ts
Normal file
@ -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<FeatureFlagDoc[]> {
|
||||
const { resources } = await container().items
|
||||
.query<FeatureFlagDoc>({
|
||||
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<FeatureFlagDoc | null> {
|
||||
const { resources } = await container().items
|
||||
.query<FeatureFlagDoc>({
|
||||
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<FeatureFlagDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as FeatureFlagDoc;
|
||||
}
|
||||
|
||||
export async function update(
|
||||
id: string,
|
||||
updates: Partial<FeatureFlagDoc>,
|
||||
): Promise<FeatureFlagDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<FeatureFlagDoc>();
|
||||
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<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
141
services/platform-service/src/modules/flags/routes.ts
Normal file
141
services/platform-service/src/modules/flags/routes.ts
Normal file
@ -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<string, boolean> = {};
|
||||
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 };
|
||||
});
|
||||
}
|
||||
39
services/platform-service/src/modules/flags/types.ts
Normal file
39
services/platform-service/src/modules/flags/types.ts
Normal file
@ -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<typeof CreateFlagSchema>;
|
||||
export type UpdateFlagInput = z.infer<typeof UpdateFlagSchema>;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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<DeviceDoc[]> {
|
||||
const { resources } = await deviceContainer().items
|
||||
.query<DeviceDoc>({
|
||||
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<DeviceDoc> {
|
||||
const { resource } = await deviceContainer().items.upsert<DeviceDoc>(doc);
|
||||
return resource!;
|
||||
}
|
||||
|
||||
export async function removeDevice(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
await deviceContainer().item(id, userId).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preferences ──
|
||||
|
||||
export async function getPrefs(userId: string): Promise<NotificationPrefsDoc | null> {
|
||||
const id = `prefs_${PRODUCT_ID}_${userId}`;
|
||||
try {
|
||||
const { resource } = await prefsContainer().item(id, userId).read<NotificationPrefsDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertPrefs(doc: NotificationPrefsDoc): Promise<NotificationPrefsDoc> {
|
||||
const { resource } = await prefsContainer().items.upsert<NotificationPrefsDoc>(doc);
|
||||
return resource!;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
49
services/platform-service/src/modules/notifications/types.ts
Normal file
49
services/platform-service/src/modules/notifications/types.ts
Normal file
@ -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<string, boolean>;
|
||||
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<typeof RegisterDeviceSchema>;
|
||||
export type UpdatePrefsInput = z.infer<typeof UpdatePrefsSchema>;
|
||||
108
services/platform-service/src/modules/ratelimit/routes.ts
Normal file
108
services/platform-service/src/modules/ratelimit/routes.ts
Normal file
@ -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<string, RateLimitConfig> {
|
||||
const configs = new Map<string, RateLimitConfig>();
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
101
services/platform-service/src/modules/ratelimit/store.ts
Normal file
101
services/platform-service/src/modules/ratelimit/store.ts
Normal file
@ -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<string, RateLimitEntry>();
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
53
services/platform-service/src/modules/ratelimit/types.ts
Normal file
53
services/platform-service/src/modules/ratelimit/types.ts
Normal file
@ -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<typeof CheckRateLimitSchema>;
|
||||
86
services/platform-service/src/server.ts
Normal file
86
services/platform-service/src/server.ts
Normal file
@ -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);
|
||||
}
|
||||
19
services/platform-service/tsconfig.json
Normal file
19
services/platform-service/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
9
services/platform-service/vitest.config.ts
Normal file
9
services/platform-service/vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user