first commit

This commit is contained in:
root
2025-12-05 07:14:11 +00:00
commit 2ed4393eb9
129 changed files with 20524 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"monorepo": false,
"sourceRoot": "src",
"entryFile": "main",
"language": "ts",
"generateOptions": {
"spec": false
},
"compilerOptions": {
"tsConfigPath": "./tsconfig.build.json",
"webpack": false,
"deleteOutDir": true,
"assets": [],
"watchAssets": false,
"plugins": []
}
}

View File

@@ -0,0 +1,42 @@
{
"name": "@sigma/api",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/main",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node .",
"format": "prettier -w src"
},
"dependencies": {
"@nestjs/common": "^11.1.9",
"@nestjs/core": "^11.1.9",
"@nestjs/jwt": "^11.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/mongoose": "^11.0.3",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.9",
"@sigma/common": "*",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"mongoose": "^8.20.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.12",
"@nestjs/schematics": "^11.0.9",
"@types/bcrypt": "^6.0.0",
"@types/node": "^24.10.1",
"@types/passport": "^0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,66 @@
import { Module } from "@nestjs/common";
import { ProjectsController } from "./controllers/projects.controller.js";
import { FunctionsController } from "./controllers/functions.controller.js";
import { MongooseModule } from "@nestjs/mongoose";
import { AuthService } from "./services/auth.service.js";
import { UsersService } from "./services/users.service.js";
import { User, UserSchema } from "./schemas/user.schema.js";
import { Project, ProjectSchema } from "./schemas/project.schema.js";
import { Function, FunctionSchema } from "./schemas/function.schema.js";
import { JwtModule } from "@nestjs/jwt";
import { BcryptService } from "./services/bcrypt.service.js";
import { JWT_SECRET } from "./auth/const.js";
import { AuthController } from "./controllers/auth.controller.js";
import { JwtStrategy } from "./auth/jwt.strategy.js";
import { LocalStrategy } from "./auth/local.strategy.js";
import { ProjectsService } from "./services/projects.service.js";
import { ProjectFunctionsService } from "./services/project-functions.service.js";
import { UsersController } from "./controllers/users.controller.js";
import { FunctionsModule } from "./functions/functions.module.js";
import { HealthService } from "./services/health.service.js";
import { HealthController } from "./controllers/health.controller.js";
@Module({
controllers: [
AuthController,
UsersController,
ProjectsController,
FunctionsController,
HealthController,
],
imports: [
JwtModule.register({
secret: JWT_SECRET,
signOptions: { expiresIn: "3600s" },
}),
MongooseModule.forRoot(
process.env.MONGO_URL ?? "mongodb://sigma:supersecret@localhost:27017"
),
MongooseModule.forFeature([
{
name: User.name,
schema: UserSchema,
},
{
name: Project.name,
schema: ProjectSchema,
},
{
name: Function.name,
schema: FunctionSchema,
},
]),
FunctionsModule,
],
providers: [
AuthService,
UsersService,
ProjectsService,
ProjectFunctionsService,
HealthService,
BcryptService,
LocalStrategy,
JwtStrategy,
],
})
export class AppModule {}

View File

@@ -0,0 +1 @@
export const JWT_SECRET = process.env["JWT_SECRET"] ?? "supersecret";

View File

@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

View File

@@ -0,0 +1,19 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { JWT_SECRET } from "./const.js";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: JWT_SECRET,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}

View File

@@ -0,0 +1,21 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { AuthService } from "../services/auth.service.js";
import type { UserDocument } from "../schemas/user.schema.js";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<UserDocument> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@@ -0,0 +1,16 @@
import { Controller, HttpCode, Post, Request, UseGuards } from "@nestjs/common";
import type { Request as ExpressRequest } from "express";
import { LocalAuthGuard } from "../auth/local-auth.guard.js";
import { AuthService } from "../services/auth.service.js";
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@HttpCode(200)
@Post("login")
async login(@Request() req: ExpressRequest): Promise<{ auth_token: string }> {
return this.authService.login(req.user);
}
}

View File

@@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
HttpCode,
NotFoundException,
Param,
Post,
Put,
Req,
UseGuards,
} from "@nestjs/common";
import type { Request } from "express";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { CreateFunctionDto, UpdateFunctionDto } from "@sigma/common";
import type { FunctionDocument } from "../schemas/function.schema.js";
import { ProjectFunctionsService } from "../services/project-functions.service.js";
@Controller("projects/:projectId/functions")
export class FunctionsController {
constructor(
private readonly projectFunctionsService: ProjectFunctionsService
) {}
@UseGuards(JwtAuthGuard)
@Post()
create(
@Req() request: Request,
@Param("projectId") projectId: string,
@Body() payload: CreateFunctionDto
): Promise<FunctionDocument> {
return this.projectFunctionsService.create(
request.user.userId,
projectId,
payload
);
}
@UseGuards(JwtAuthGuard)
@Put(":functionId")
async update(
@Req() request: Request,
@Param("projectId") projectId: string,
@Param("functionId") functionId: string,
@Body() payload: UpdateFunctionDto
): Promise<FunctionDocument> {
const func = await this.projectFunctionsService.update(
request.user.userId,
projectId,
functionId,
payload
);
if (!func) {
throw new NotFoundException("Function not found");
}
return func;
}
@UseGuards(JwtAuthGuard)
@Delete(":functionId")
@HttpCode(204)
async delete(
@Req() request: Request,
@Param("projectId") projectId: string,
@Param("functionId") functionId: string
): Promise<FunctionDocument | null> {
const func = await this.projectFunctionsService.delete(
request.user.userId,
projectId,
functionId
);
if (!func) {
throw new NotFoundException("Function not found");
}
return func;
}
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from "@nestjs/common";
import { HealthService } from "../services/health.service.js";
import { CpuInfoDto } from "@sigma/common";
@Controller("health")
export class HealthController {
constructor(private healthService: HealthService) {}
@Get("/cpu")
getCpuInfo(): CpuInfoDto {
return { usage: this.healthService.getServerCpuUsage() };
}
}

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import type { Request } from "express";
import { ProjectsService } from "../services/projects.service.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import {
CreateProjectDto,
ProjectDto,
QueryProjectDto,
UpdateProjectDto,
} from "@sigma/common";
import type { ProjectDocument } from "../schemas/project.schema.js";
@Controller("projects")
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@UseGuards(JwtAuthGuard)
@Post()
create(
@Req() request: Request,
@Body() createProjectDto: CreateProjectDto
): Promise<ProjectDocument> {
return this.projectsService.create(request.user.userId, createProjectDto);
}
@UseGuards(JwtAuthGuard)
@Get()
findAll(
@Req() request: Request,
@Query() query: QueryProjectDto
): Promise<ProjectDocument[]> {
return this.projectsService.findAll(request.user.userId, query);
}
@UseGuards(JwtAuthGuard)
@Get(":id")
findOne(
@Req() request: Request,
@Param("id") id: string
): Promise<ProjectDto | null> {
return this.projectsService.findOne(request.user.userId, id);
}
@UseGuards(JwtAuthGuard)
@Patch(":id")
update(
@Req() request: Request,
@Param("id") id: string,
@Body() updateProjectDto: UpdateProjectDto
): Promise<ProjectDocument | null> {
return this.projectsService.update(
request.user.userId,
id,
updateProjectDto
);
}
@UseGuards(JwtAuthGuard)
@Delete(":id")
@HttpCode(204)
remove(
@Req() request: Request,
@Param("id") id: string
): Promise<ProjectDocument | null> {
return this.projectsService.remove(request.user.userId, id);
}
}

View File

@@ -0,0 +1,69 @@
import {
Body,
Controller,
HttpCode,
Post,
Put,
UseGuards,
Request,
Get,
ConflictException,
} from "@nestjs/common";
import type { Request as ExpressRequest } from "express";
import { UsersService } from "../services/users.service.js";
import { BcryptService } from "../services/bcrypt.service.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { RegisterUserDto, UpdateUserDto, UserDto } from "@sigma/common";
@Controller("users")
export class UsersController {
constructor(
private usersService: UsersService,
private bcryptService: BcryptService
) {}
@Post()
@HttpCode(201)
async register(@Body() registerDto: RegisterUserDto): Promise<UserDto> {
try {
const user = await this.usersService.create({
username: registerDto.username,
passwordHash: await this.bcryptService.hashPassword(
registerDto.password
),
projects: [],
});
return {
username: user.username,
};
} catch (error: any) {
if ("code" in error && error.code === 11000) {
throw new ConflictException("Username already exists");
}
throw error;
}
}
@UseGuards(JwtAuthGuard)
@Put()
async update(
@Request() req: ExpressRequest,
@Body() updateDto: UpdateUserDto
) {
this.usersService.update(req.user?._id, {
passwordHash: await this.bcryptService.hashPassword(updateDto.password),
});
}
@UseGuards(JwtAuthGuard)
@Get("me")
async getProfile(@Request() req: ExpressRequest): Promise<UserDto> {
const user = await this.usersService.findOne(req.user?.username);
return {
username: user!.username,
};
}
}

View File

@@ -0,0 +1,65 @@
import { All, Controller, Param, Req, Res } from "@nestjs/common";
import type { Request, Response } from "express";
import { FunctionExecutionService } from "./execution.service.js";
@Controller("exec")
export class ExecutionController {
constructor(
private readonly functionExecutionService: FunctionExecutionService
) {}
@All(":projectSlug")
async handleRoot(
@Param("projectSlug") projectSlug: string,
@Req() request: Request,
@Res() response: Response
) {
await this.dispatchExecution(projectSlug, request, response);
}
@All(":projectSlug/*path")
async handleNested(
@Param("projectSlug") projectSlug: string,
@Req() request: Request,
@Res() response: Response
) {
await this.dispatchExecution(projectSlug, request, response);
}
private async dispatchExecution(
projectSlug: string,
request: Request,
response: Response
) {
const executionResult = await this.functionExecutionService.execute(
projectSlug,
request
);
if (executionResult.responsePayload) {
const { statusCode, headers, body } = executionResult.responsePayload;
this.applyHeaders(response, headers);
this.sendBody(response, statusCode ?? 200, body ?? "");
return;
} else {
this.sendBody(response, 500, executionResult.stderr);
}
}
private applyHeaders(
response: Response,
headers: Record<string, string> | undefined
) {
if (!headers) {
return;
}
for (const [key, value] of Object.entries(headers)) {
response.setHeader(key, value);
}
}
private sendBody(response: Response, statusCode: number, body: string) {
response.status(statusCode).send(body);
}
}

View File

@@ -0,0 +1,64 @@
export class ExecutionQueueOverflowError extends Error {
constructor(message = "Execution queue is full") {
super(message);
this.name = "ExecutionQueueOverflowError";
}
}
type Task<T> = () => Promise<T>;
interface PendingTask {
task: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
export class ExecutionQueue {
private running = 0;
private readonly queue: PendingTask[] = [];
constructor(
private readonly concurrency: number,
private readonly queueLimit: number
) {}
run<T>(task: Task<T>): Promise<T> {
if (this.running < this.concurrency) {
return this.execute(task);
}
const limit = this.queueLimit;
if (Number.isFinite(limit) && limit >= 0 && this.queue.length >= limit) {
throw new ExecutionQueueOverflowError();
}
return new Promise<T>((resolve, reject) => {
this.queue.push({
task: () => task(),
resolve: (value) => resolve(value as T),
reject,
});
});
}
private async execute<T>(task: Task<T>): Promise<T> {
this.running += 1;
try {
return await task();
} finally {
this.running -= 1;
this.processQueue();
}
}
private processQueue(): void {
while (this.running < this.concurrency && this.queue.length > 0) {
const next = this.queue.shift();
if (!next) {
return;
}
this.execute(next.task).then(next.resolve).catch(next.reject);
}
}
}

View File

@@ -0,0 +1,310 @@
import {
Injectable,
InternalServerErrorException,
NotFoundException,
RequestTimeoutException,
ServiceUnavailableException,
} from "@nestjs/common";
import type { Request } from "express";
import { FunctionsService } from "./functions.service.js";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { performance } from "node:perf_hooks";
import {
ExecutionQueue,
ExecutionQueueOverflowError,
} from "./execution.queue.js";
import {
IsNumber,
IsObject,
IsString,
Max,
Min,
validate,
} from "class-validator";
export interface ExecutionResponse {
project: string;
function: string;
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number;
timedOut: boolean;
responsePayload?: ExecutorHttpResponse | null;
}
export class ExecutorHttpResponse {
@IsNumber({})
@Min(100)
@Max(599)
statusCode!: number;
@IsObject()
headers!: Record<string, string>;
@IsString()
body!: string;
}
@Injectable()
export class FunctionExecutionService {
private baseRuntimeDir = "/tmp/sigma";
private executorBinary =
process.env.EXECUTOR_BIN_PATH ??
path.resolve(
process.cwd(),
"..",
"executor",
"target",
"release",
"executor"
);
private timeoutMs = 1000;
private maxConcurrency = 10;
private maxQueueSize = 32;
private queue = new ExecutionQueue(this.maxConcurrency, this.maxQueueSize);
constructor(private functionsService: FunctionsService) {}
async execute(
projectSlug: string,
request: Request
): Promise<ExecutionResponse> {
const project = await this.functionsService.findProjectBySlug(projectSlug);
if (!project) {
throw new NotFoundException(`Project ${projectSlug} not found`);
}
const functionPath = this.extractFunctionPath(
request.path ?? request.url,
projectSlug
);
const func = await this.functionsService.findRunnableFunction(
project._id,
functionPath,
request.method
);
if (!func) {
throw new NotFoundException(
`No function for ${request.method.toUpperCase()} ${functionPath}`
);
}
await this.functionsService.ensureCpuQuota(project);
const eventPayload = this.buildEventPayload(request, functionPath);
const executionResult = await this.runExecutor(
projectSlug,
func.code,
eventPayload
);
await this.functionsService.recordCpuUsage(
project,
executionResult.durationMs
);
await this.functionsService.recordInvocation(
func._id.toString(),
executionResult.exitCode === 0,
executionResult.stderr
.split("\n")
.filter((line) => line.trim().length > 0)
);
if (executionResult.timedOut) {
throw new RequestTimeoutException("Function execution timed out");
}
return {
project: project.slug,
function: func.name,
...executionResult,
};
}
private extractFunctionPath(
pathname: string | undefined,
slug: string
): string {
if (!pathname) {
return "/";
}
const needle = `/exec/${slug}`;
const index = pathname.indexOf(needle);
let relative =
index === -1 ? pathname : pathname.slice(index + needle.length);
if (!relative || relative.length === 0) {
return "/";
}
if (!relative.startsWith("/")) {
relative = `/${relative}`;
}
if (relative.length > 1 && relative.endsWith("/")) {
relative = relative.slice(0, -1);
}
return relative || "/";
}
private buildEventPayload(request: Request, functionPath: string) {
const body = this.serializeBody(request.body);
const pathValue = request.path ?? request.url ?? functionPath;
return {
method: request.method,
path: pathValue,
originalUrl: request.originalUrl,
functionPath,
headers: request.headers,
query: request.query,
params: request.params,
body,
};
}
private serializeBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return body.toString("utf8");
}
return body;
}
private async runExecutor(slug: string, code: string, eventPayload: unknown) {
try {
return await this.queue.run(() =>
this.executeWithinRuntime(slug, code, eventPayload)
);
} catch (error) {
if (error instanceof ExecutionQueueOverflowError) {
throw new ServiceUnavailableException(
"Execution queue is full, please retry later"
);
}
throw error;
}
}
private async executeWithinRuntime(
slug: string,
code: string,
eventPayload: unknown
) {
await fs.mkdir(this.baseRuntimeDir, { recursive: true });
const tempDir = path.join(this.baseRuntimeDir, slug, randomUUID());
await fs.mkdir(tempDir, { recursive: true });
const scriptPath = path.join(tempDir, "function.js");
const eventPath = path.join(tempDir, "event.json");
await fs.writeFile(scriptPath, `${code}\n`, "utf8");
await fs.writeFile(
eventPath,
JSON.stringify(eventPayload, (_, value) => value ?? null, 2),
"utf8"
);
const start = performance.now();
try {
const result = await this.spawnExecutor(slug, scriptPath, tempDir);
const durationMs = performance.now() - start;
return {
...result,
durationMs,
responsePayload: await this.extractResponsePayload(result.stdout),
};
} catch (e) {
await fs.rm(tempDir, { recursive: true, force: true });
return {
stdout: "",
stderr: (e as Error).message,
exitCode: null,
durationMs: performance.now() - start,
timedOut: false,
};
}
}
private spawnExecutor(slug: string, scriptPath: string, cwd: string) {
return new Promise<{
stdout: string;
stderr: string;
exitCode: number | null;
timedOut: boolean;
}>((resolve, reject) => {
const child = spawn(
this.executorBinary,
["--enable-fs", "--enable-sql", `scope_${slug}`, scriptPath],
{
cwd,
}
);
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, this.timeoutMs);
child.on("error", (err) => {
clearTimeout(timer);
reject(new InternalServerErrorException(err.message));
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({
stdout,
stderr,
exitCode: code,
timedOut,
});
});
});
}
private async extractResponsePayload(
stdout: string
): Promise<ExecutorHttpResponse | null> {
try {
const parsed = JSON.parse(stdout.trim());
const response = new ExecutorHttpResponse();
response.statusCode = parsed.statusCode;
response.headers = parsed.headers;
response.body = parsed.body;
const errors = await validate(response);
if (errors.length === 0) {
return response;
}
return null;
} catch (error) {
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { ExecutionController } from "./execution.controller.js";
import { FunctionExecutionService } from "./execution.service.js";
import { FunctionsService } from "./functions.service.js";
import { Project, ProjectSchema } from "../schemas/project.schema.js";
import { Function, FunctionSchema } from "../schemas/function.schema.js";
@Module({
imports: [
MongooseModule.forFeature([
{ name: Project.name, schema: ProjectSchema },
{ name: Function.name, schema: FunctionSchema },
]),
],
controllers: [ExecutionController],
providers: [FunctionExecutionService, FunctionsService],
})
export class FunctionsModule {}

View File

@@ -0,0 +1,162 @@
import { HttpException, HttpStatus, Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import type { Model } from "mongoose";
import { Project, type ProjectDocument } from "../schemas/project.schema.js";
import {
Function as SigmaFunction,
type FunctionDocument,
} from "../schemas/function.schema.js";
@Injectable()
export class FunctionsService {
constructor(
@InjectModel(Project.name)
private readonly projectModel: Model<Project>,
@InjectModel(SigmaFunction.name)
private readonly functionModel: Model<SigmaFunction>
) {}
async findProjectBySlug(slug: string): Promise<ProjectDocument | null> {
return this.projectModel.findOne({ slug }).exec();
}
async findRunnableFunction(
projectId: ProjectDocument["_id"],
requestPath: string,
method: string
): Promise<FunctionDocument | null> {
const functions = await this.functionModel
.find({ project: projectId })
.exec();
const normalizedPath = this.normalizePath(requestPath);
const upperMethod = method.toUpperCase();
for (const func of functions) {
if (!this.supportsMethod(func, upperMethod)) {
continue;
}
if (this.pathMatches(func.path, normalizedPath)) {
return func;
}
}
return null;
}
async ensureCpuQuota(project: ProjectDocument): Promise<void> {
const quota = project.cpuTimeQuotaMsPerMinute ?? 1000;
const updated = this.resetUsageWindowIfNeeded(project);
if (project.cpuTimeUsedMs >= quota) {
throw new HttpException(
`CPU time quota exceeded for project ${project.slug}`,
HttpStatus.TOO_MANY_REQUESTS
);
}
if (updated) {
await project.save();
}
}
async recordCpuUsage(
project: ProjectDocument,
elapsedMs: number
): Promise<void> {
const quota = project.cpuTimeQuotaMsPerMinute ?? 1000;
this.resetUsageWindowIfNeeded(project);
project.cpuTimeUsedMs = Math.min(
quota,
(project.cpuTimeUsedMs ?? 0) + Math.max(0, Math.round(elapsedMs))
);
await project.save();
}
async recordInvocation(
functionId: string,
successful: boolean,
logs: string[]
): Promise<void> {
const func = await this.functionModel.findByIdAndUpdate(functionId, {
$inc: { invocationCount: 1, errorCount: successful ? 0 : 1 },
lastInvocation: new Date(),
$push: { logs: { $each: logs, $slice: -100 } },
});
if (func) {
await this.projectModel.findByIdAndUpdate(func._id, {
$inc: {
functionsInvocationCount: 1,
functionsErrorCount: successful ? 0 : 1,
},
});
}
}
private supportsMethod(func: FunctionDocument, method: string): boolean {
if (!func.methods || func.methods.length === 0) {
return true;
}
return func.methods.some((allowed) => allowed.toUpperCase() === method);
}
private pathMatches(pattern: string, requestPath: string): boolean {
const normalizedPattern = this.normalizePath(pattern);
if (normalizedPattern === "/*") {
return true;
}
const patternSegments = normalizedPattern.split("/").filter(Boolean);
const requestSegments = requestPath.split("/").filter(Boolean);
if (patternSegments.length !== requestSegments.length) {
return false;
}
return patternSegments.every((segment, index) => {
const value = requestSegments[index];
if (segment.startsWith(":")) {
return typeof value === "string" && value.length > 0;
}
return segment === value;
});
}
private normalizePath(input: string): string {
if (!input || input === "") {
return "/";
}
let normalized = input.startsWith("/") ? input : `/${input}`;
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
private resetUsageWindowIfNeeded(project: ProjectDocument): boolean {
const now = Date.now();
const startedAt = project.cpuTimeWindowStartedAt?.getTime() ?? 0;
if (now - startedAt >= 60_000) {
project.cpuTimeWindowStartedAt = new Date(now);
project.cpuTimeUsedMs = 0;
return true;
}
if (!project.cpuTimeWindowStartedAt) {
project.cpuTimeWindowStartedAt = new Date(now);
project.cpuTimeUsedMs = 0;
return true;
}
if (project.cpuTimeUsedMs == null) {
project.cpuTimeUsedMs = 0;
return true;
}
return false;
}
}

View File

@@ -0,0 +1,24 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module.js";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: {
credentials: true,
origin: "http://localhost:1234",
},
// logger: ["debug", "log", "error", "warn", "fatal"],
});
app.setGlobalPrefix("/api/v1");
app.set("query parser", "extended");
app.useGlobalPipes(
new ValidationPipe({
whitelist: false,
})
);
await app.listen(process.env.PORT || 3000);
}
bootstrap();

View File

@@ -0,0 +1,36 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import type { HydratedDocument } from "mongoose";
import mongoose from "mongoose";
@Schema()
export class Function {
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: "Project" })
project!: mongoose.Types.ObjectId;
@Prop()
name!: string;
@Prop()
path!: string;
@Prop()
methods!: string[];
@Prop({ default: "// Write your function code here" })
code!: string;
@Prop()
invocationCount: number = 0;
@Prop()
errorCount: number = 0;
@Prop()
logs: string[] = [];
@Prop()
lastInvocation?: Date;
}
export type FunctionDocument = HydratedDocument<Function>;
export const FunctionSchema = SchemaFactory.createForClass(Function);

View File

@@ -0,0 +1,43 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import type { Function } from "./function.schema.js";
import type { HydratedDocument } from "mongoose";
import mongoose from "mongoose";
@Schema()
export class Project {
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: "User" })
owner!: mongoose.Types.ObjectId;
@Prop()
name!: string;
@Prop({ unique: true })
slug!: string;
@Prop()
description!: string;
@Prop({
type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Function" }],
default: [],
})
functions!: Function[];
@Prop({ default: 1000 })
cpuTimeQuotaMsPerMinute!: number;
@Prop({ default: Date.now })
cpuTimeWindowStartedAt!: Date;
@Prop({ default: 0 })
cpuTimeUsedMs!: number;
@Prop()
functionsInvocationCount: number = 0;
@Prop()
functionsErrorCount: number = 0;
}
export type ProjectDocument = HydratedDocument<Project>;
export const ProjectSchema = SchemaFactory.createForClass(Project);

View File

@@ -0,0 +1,22 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import type { HydratedDocument } from "mongoose";
import mongoose from "mongoose";
import type { Project } from "./project.schema.js";
@Schema()
export class User {
@Prop({ unique: true })
username!: string;
@Prop()
passwordHash!: string;
@Prop({
type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Project" }],
default: [],
})
projects!: Project[];
}
export type UserDocument = HydratedDocument<User>;
export const UserSchema = SchemaFactory.createForClass(User);

View File

@@ -0,0 +1,41 @@
import { Injectable } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { JwtService } from "@nestjs/jwt";
import type { UserDocument } from "../schemas/user.schema.js";
import { BcryptService } from "./bcrypt.service.js";
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private bcryptService: BcryptService
) {}
async validateUser(
username: string,
password: string
): Promise<UserDocument | null> {
const user = await this.usersService.findOne(username);
if (
user &&
(await this.bcryptService.comparePasswords(password, user.passwordHash))
) {
return user;
}
return null;
}
async login(user: UserDocument): Promise<{ auth_token: string }> {
const payload = {
sub: user._id,
username: user.username,
};
return {
auth_token: await this.jwtService.signAsync(payload),
};
}
}

View File

@@ -0,0 +1,15 @@
import * as bcrypt from "bcrypt";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BcryptService {
async hashPassword(password: string): Promise<string> {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
async comparePasswords(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}

View File

@@ -0,0 +1,42 @@
import * as os from "os";
import { Injectable } from "@nestjs/common";
@Injectable()
export class HealthService {
private totalTime?: number;
private usedTime?: number;
private currentUsage?: number;
constructor() {
setInterval(() => this.updateCpuUsage(), 1000);
}
private updateCpuUsage() {
const cpus = os.cpus();
const totalTime = cpus.reduce(
(acc, cpu) =>
acc + Object.values(cpu.times).reduce((acc, time) => acc + time, 0),
0
);
const usedTime = cpus.reduce(
(acc, cpu) => acc + cpu.times.user + cpu.times.sys,
0
);
if (this.totalTime && this.usedTime) {
const totalDiff = totalTime - this.totalTime;
const usedDiff = usedTime - this.usedTime;
this.currentUsage = (usedDiff / totalDiff) * 100;
}
this.totalTime = totalTime;
this.usedTime = usedTime;
}
getServerCpuUsage(): number {
return this.currentUsage ?? 0;
}
}

View File

@@ -0,0 +1,111 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import type { Model, ObjectId } from "mongoose";
import { Project, type ProjectDocument } from "../schemas/project.schema.js";
import {
Function as SigmaFunction,
type FunctionDocument,
} from "../schemas/function.schema.js";
import { CreateFunctionDto, UpdateFunctionDto } from "@sigma/common";
@Injectable()
export class ProjectFunctionsService {
constructor(
@InjectModel(Project.name)
private readonly projectModel: Model<Project>,
@InjectModel(SigmaFunction.name)
private readonly functionModel: Model<SigmaFunction>
) {}
async create(
owner: ObjectId,
projectId: string,
payload: CreateFunctionDto
): Promise<FunctionDocument> {
const project = await this.projectModel
.findOne({
_id: projectId,
owner,
})
.exec();
if (!project) {
throw new NotFoundException("Project not found");
}
const createdFunction = await this.functionModel.create({
project: project._id,
name: payload.name,
path: payload.path,
});
await this.appendFunctionReference(project, createdFunction._id);
return createdFunction;
}
async delete(
owner: ObjectId,
projectId: string,
functionId: string
): Promise<FunctionDocument | null> {
const project = await this.projectModel.findOne({
_id: projectId,
owner,
});
if (!project) {
throw new NotFoundException("Project not found");
}
const func = await this.functionModel.findOneAndDelete({
project: project._id,
_id: functionId,
});
await project.updateOne(
{
functions: project.functions.filter(
(funcId) => funcId.toString() !== functionId
),
},
{ new: true }
);
return func;
}
async update(
owner: ObjectId,
projectId: string,
functionId: string,
payload: UpdateFunctionDto
): Promise<FunctionDocument | null> {
const project = await this.projectModel.findOne({
_id: projectId,
owner,
});
if (!project) {
throw new NotFoundException("Project not found");
}
return await this.functionModel.findOneAndUpdate(
{
project: project._id,
_id: functionId,
},
payload,
{ new: true }
);
}
private async appendFunctionReference(
project: ProjectDocument,
functionId: FunctionDocument["_id"]
): Promise<void> {
await this.projectModel
.updateOne({ _id: project._id }, { $addToSet: { functions: functionId } })
.exec();
}
}

View File

@@ -0,0 +1,126 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import type { Model, ObjectId } from "mongoose";
import { Project, type ProjectDocument } from "../schemas/project.schema.js";
import {
Function as SigmaFunction,
type FunctionDocument,
} from "../schemas/function.schema.js";
import {
CreateProjectDto,
FunctionDto,
ProjectDto,
UpdateProjectDto,
} from "@sigma/common";
@Injectable()
export class ProjectsService {
constructor(
@InjectModel(Project.name) private projectModel: Model<Project>,
@InjectModel(SigmaFunction.name)
private functionModel: Model<SigmaFunction>
) {}
async create(
owner: ObjectId,
createProjectDto: CreateProjectDto
): Promise<ProjectDocument> {
const createdProject = new this.projectModel({
...createProjectDto,
owner,
});
return createdProject.save();
}
async findAll(
owner: ObjectId,
query: UpdateProjectDto
): Promise<ProjectDocument[]> {
return this.projectModel
.find({
owner,
...query,
})
.exec();
}
async findOne(owner: ObjectId, id: string): Promise<ProjectDto | null> {
const project = await this.projectModel
.findOne({
_id: id,
owner,
})
.exec();
if (!project) {
return null;
}
const functions = await this.functionModel
.find({ project: project._id })
.exec();
return this.toProjectDto(project, functions);
}
async update(
owner: ObjectId,
id: string,
updateProjectDto: UpdateProjectDto
): Promise<ProjectDocument | null> {
return this.projectModel
.findOneAndUpdate(
{
_id: id,
owner,
},
updateProjectDto,
{ new: true }
)
.exec();
}
async remove(owner: ObjectId, id: string): Promise<ProjectDocument | null> {
return this.projectModel
.findOneAndDelete({
_id: id,
owner,
})
.exec();
}
private toProjectDto(
project: ProjectDocument,
functions: FunctionDocument[]
): ProjectDto {
return {
_id: project._id.toString(),
name: project.name,
slug: project.slug,
description: project.description,
cpuTimeQuotaMsPerMinute: project.cpuTimeQuotaMsPerMinute ?? 0,
cpuTimeWindowStartedAt:
project.cpuTimeWindowStartedAt?.toISOString() ??
new Date(0).toISOString(),
cpuTimeUsedMs: project.cpuTimeUsedMs ?? 0,
functionsInvocationCount: project.functionsInvocationCount ?? 0,
functionsErrorCount: project.functionsErrorCount ?? 0,
functions: functions.map((func) => this.toFunctionDto(func)),
};
}
private toFunctionDto(func: FunctionDocument): FunctionDto {
return {
_id: func._id.toString(),
name: func.name,
path: func.path,
methods: func.methods ?? [],
code: func.code,
invocations: func.invocationCount ?? 0,
errors: func.errorCount ?? 0,
logs: func.logs ?? [],
lastInvocation: func.lastInvocation,
};
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { User, type UserDocument } from "../schemas/user.schema.js";
import type { Model } from "mongoose";
@Injectable()
export class UsersService {
constructor(@InjectModel(User.name) private userModel: Model<User>) {}
async create(user: User): Promise<UserDocument> {
const createdUser = new this.userModel(user);
return createdUser.save();
}
async findOne(username: string): Promise<UserDocument | null> {
return this.userModel.findOne({
username: username,
});
}
async update(
id: string,
updateData: Partial<User>
): Promise<UserDocument | null> {
return this.userModel.findByIdAndUpdate(id, updateData, { new: true });
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}