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:
saravanakumardb1 2026-02-12 11:39:00 -08:00
parent 72c6e64854
commit e1ab956ac3
35 changed files with 2338 additions and 0 deletions

2
services/platform-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

View 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"]

View 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"
}
}

View 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)
);
}

View 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);

View 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);
}

View 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);
});
});

View 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);
}
}

View 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";

View 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);
});
});

View 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;
}

View 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) };
});
}

View 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>;

View 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);
});
});

View 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;
};
}

View 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);
}

View 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 };
}
});
}

View 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>;

View 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");
}
});
});

View 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,
})),
};
});
}

View 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>;

View 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);
});
});

View 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;
}
}

View 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 };
});
}

View 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>;

View File

@ -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);
});
});

View File

@ -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!;
}

View File

@ -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);
});
}

View 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>;

View 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;
});
}

View 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();
}

View 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>;

View 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);
}

View 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"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});