init studio launcher backend

This commit is contained in:
2026-07-01 18:01:05 +09:00
commit 702d093736
69 changed files with 14881 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { AppsModule } from './apps/apps.module';
import { NoticesModule } from './notices/notices.module';
import { NotificationsModule } from './notifications/notifications.module';
import { DownloadsModule } from './downloads/downloads.module';
import { ProvisioningModule } from './provisioning/provisioning.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
}),
AuthModule,
NoticesModule,
NotificationsModule,
DownloadsModule,
ProvisioningModule,
AppsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+48
View File
@@ -0,0 +1,48 @@
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AppsService } from './apps.service';
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
@Controller('api/apps')
export class AppsController {
constructor(private readonly appsService: AppsService) {}
@Get()
getApps(@Req() request: Request) {
return this.appsService.getApps(this.getBearerToken(request));
}
@Get(':appKey/update')
getUpdateStatus(
@Param('appKey') appKey: string,
@Req() request: Request,
@Query('currentVersion') currentVersion?: string,
@Query('installed') installed?: string,
) {
return this.appsService.getUpdateStatus(appKey, {
currentVersion,
installed: installed === 'true',
accessToken: this.getBearerToken(request),
});
}
@Post(':appKey/users/:authUserId/permission')
grantAppPermission(
@Param('appKey') appKey: string,
@Param('authUserId') authUserId: string,
@Body() body: GrantAppPermissionDto,
@Req() request: Request,
) {
return this.appsService.grantAppPermission(
appKey,
authUserId,
body,
this.getBearerToken(request),
);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { AppsController } from './apps.controller';
import { AppsService } from './apps.service';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [AppsController],
providers: [AppsService],
})
export class AppsModule {}
+299
View File
@@ -0,0 +1,299 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
type UpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE';
type UpdateStatus = 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP';
type UpdateStatusInput = {
currentVersion?: string;
installed: boolean;
accessToken?: string;
};
type AuthMeResponse = {
user?: {
id?: string;
role?: string;
roles?: string[];
};
};
const APP_PERMISSION_ADMIN_ROLES = new Set(['ADMIN']);
function normalizeVersion(version?: string): string | undefined {
return version?.trim().replace(/^v/i, '') || undefined;
}
function compareVersions(left?: string, right?: string): number {
const a = normalizeVersion(left);
const b = normalizeVersion(right);
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
const leftParts = a.split(/[.-]/).map((part) => Number.parseInt(part, 10));
const rightParts = b.split(/[.-]/).map((part) => Number.parseInt(part, 10));
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftValue = Number.isNaN(leftParts[index]) ? 0 : leftParts[index] ?? 0;
const rightValue = Number.isNaN(rightParts[index]) ? 0 : rightParts[index] ?? 0;
if (leftValue > rightValue) return 1;
if (leftValue < rightValue) return -1;
}
return 0;
}
@Injectable()
export class AppsService {
constructor(
private readonly prisma: PrismaService,
private readonly authService: AuthService,
) {}
async getApps(accessToken?: string) {
const authUserId = await this.getAuthUserId(accessToken);
const apps = await this.prisma.launcherApp.findMany({
where: { isEnabled: true },
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
select: {
appKey: true,
displayName: true,
description: true,
subtitle: true,
detailUrl: true,
videoPath: true,
iconKey: true,
launchUrl: true,
devAppPath: true,
execPath: true,
sortOrder: true,
userApps: authUserId
? {
where: { authUserId },
select: {
canView: true,
canLaunch: true,
},
take: 1,
}
: false,
},
});
return {
apps: apps.map((app) => {
const permission = app.userApps?.[0];
const canView = permission?.canView ?? false;
return {
appKey: app.appKey,
displayName: app.displayName,
description: app.description,
subtitle: app.subtitle,
detailUrl: app.detailUrl,
videoPath: app.videoPath,
iconKey: app.iconKey,
launchUrl: app.launchUrl,
devAppPath: app.devAppPath,
execPath: app.execPath,
sortOrder: app.sortOrder,
canView,
canLaunch: canView && (permission?.canLaunch ?? false),
};
}),
};
}
async getUpdateStatus(appKey: string, input: UpdateStatusInput) {
const isLauncherUpdate = appKey.toLowerCase() === 'launcher';
const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken);
const canLaunch = isLauncherUpdate
? true
: authUserId
? await this.canLaunchApp(authUserId, appKey)
: false;
const appVersion = await this.prisma.launcherAppVersion.findUnique({
where: { appKey },
});
if (!appVersion) {
return {
appKey,
installed: input.installed,
localVersion: input.currentVersion ?? null,
latestVersion: null,
status: 'UNKNOWN_APP' satisfies UpdateStatus,
action: 'UNAVAILABLE' satisfies UpdateAction,
updateRequired: false,
};
}
if (!appVersion.isEnabled) {
return {
appKey,
displayName: appVersion.displayName,
installed: input.installed,
localVersion: input.currentVersion ?? null,
latestVersion: appVersion.latestVersion,
status: 'DISABLED' satisfies UpdateStatus,
action: 'UNAVAILABLE' satisfies UpdateAction,
updateRequired: false,
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
};
}
if (!canLaunch) {
return {
appKey,
displayName: appVersion.displayName,
installed: input.installed,
localVersion: input.currentVersion ?? null,
latestVersion: appVersion.latestVersion,
status: 'DISABLED' satisfies UpdateStatus,
action: 'UNAVAILABLE' satisfies UpdateAction,
updateRequired: false,
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
canLaunch: false,
};
}
const installed = input.installed;
const localVersion = normalizeVersion(input.currentVersion);
const hasUpdate = installed && compareVersions(localVersion, appVersion.latestVersion) < 0;
const status: UpdateStatus = !installed
? 'NOT_INSTALLED'
: hasUpdate
? 'UPDATE_AVAILABLE'
: 'READY';
const action: UpdateAction = status === 'NOT_INSTALLED'
? 'INSTALL'
: status === 'UPDATE_AVAILABLE'
? 'UPDATE'
: 'LAUNCH';
return {
appKey,
displayName: appVersion.displayName,
installed,
localVersion: localVersion ?? null,
latestVersion: appVersion.latestVersion,
minimumSupportedVersion: appVersion.minimumSupportedVersion,
status,
action,
updateRequired: status === 'UPDATE_AVAILABLE',
downloadUrl: appVersion.downloadUrl,
releaseNotes: appVersion.releaseNotes,
canLaunch: true,
};
}
async grantAppPermission(
appKey: string,
authUserId: string,
input: GrantAppPermissionDto,
accessToken?: string,
) {
const actor = await this.requireAppPermissionAdmin(accessToken);
const app = await this.prisma.launcherApp.findUnique({
where: { appKey },
select: { id: true, appKey: true },
});
if (!app) throw new NotFoundException('App not found');
const permission = await this.prisma.launcherUserApp.upsert({
where: {
authUserId_appId: {
authUserId,
appId: app.id,
},
},
update: {
canView: input.canView ?? true,
canLaunch: input.canLaunch ?? true,
assignedById: actor.id,
assignedAt: new Date(),
},
create: {
authUserId,
appId: app.id,
canView: input.canView ?? true,
canLaunch: input.canLaunch ?? true,
assignedById: actor.id,
},
select: {
id: true,
authUserId: true,
canView: true,
canLaunch: true,
assignedAt: true,
app: {
select: {
appKey: true,
displayName: true,
},
},
},
});
return { permission };
}
private async getAuthUserId(accessToken?: string): Promise<string | null> {
if (!accessToken) return null;
try {
const response = await this.authService.me(accessToken) as { user?: { id?: string } };
return response.user?.id ?? null;
} catch {
return null;
}
}
private async requireAppPermissionAdmin(accessToken?: string) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const response = await this.authService.me(accessToken) as AuthMeResponse;
const user = response.user;
const roles = new Set(
[user?.role, ...(user?.roles ?? [])]
.filter(Boolean)
.map((role) => String(role).toUpperCase()),
);
if (!user?.id || ![...APP_PERMISSION_ADMIN_ROLES].some((role) => roles.has(role))) {
throw new ForbiddenException('App permission admin role required');
}
return { id: user.id };
}
private async canLaunchApp(authUserId: string, appKey: string): Promise<boolean> {
const permission = await this.prisma.launcherUserApp.findFirst({
where: {
authUserId,
canView: true,
canLaunch: true,
app: {
appKey,
isEnabled: true,
},
},
select: { id: true },
});
return Boolean(permission);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class GrantAppPermissionDto {
@IsOptional()
@IsBoolean()
canView?: boolean;
@IsOptional()
@IsBoolean()
canLaunch?: boolean;
}
+100
View File
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, Post, Put, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { HandoffDto } from './dto/handoff.dto';
import { LoginDto } from './dto/login.dto';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(
@Body() loginDto: LoginDto,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.login(
loginDto.email,
loginDto.password,
request.headers.cookie,
response,
);
}
@Post('refresh')
refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.refresh(request.headers.cookie, response);
}
@Post('logout')
logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
return this.authService.logout(request.headers.cookie, response);
}
@Get('me')
me(@Req() request: Request) {
return this.authService.me(this.getBearerToken(request));
}
@Post('password/change')
changePassword(@Req() request: Request, @Body() body: ChangePasswordDto) {
return this.authService.changePassword(
this.getBearerToken(request),
body.currentPassword,
body.newPassword,
);
}
@Get('profile/avatar')
async getProfileAvatar(
@Req() request: Request,
@Res() response: Response,
) {
const result = await this.authService.getProfileAvatar(
this.getBearerToken(request),
);
response.setHeader('Content-Type', result.contentType);
response.setHeader('Cache-Control', 'no-store');
response.send(result.body);
}
@Put('profile/avatar')
async updateProfileAvatar(@Req() request: Request) {
const body = Buffer.isBuffer(request.body)
? request.body
: Buffer.from([]);
await this.authService.updateProfileAvatar(
this.getBearerToken(request),
request.headers['content-type'],
body,
);
return { ok: true };
}
@Delete('profile/avatar')
deleteProfileAvatar(@Req() request: Request) {
return this.authService.deleteProfileAvatar(this.getBearerToken(request));
}
@Post('handoff')
handoff(@Req() request: Request, @Body() body: HandoffDto) {
return this.authService.handoff(
this.getBearerToken(request),
body.audienceApp ?? 'platform-ide',
);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [ConfigModule],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
+297
View File
@@ -0,0 +1,297 @@
import {
BadRequestException,
BadGatewayException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Response } from 'express';
export type AuthUser = {
id: string;
email: string;
name: string | null;
role: string;
roles?: string[];
};
type AuthTokenResponse = {
accessToken: string;
accessTokenExpiresIn?: number;
user: AuthUser;
};
@Injectable()
export class AuthService {
private readonly authServerUrl: string;
constructor(private readonly configService: ConfigService) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
}
async login(
email: string,
password: string,
cookieHeader: string | undefined,
response: Response,
) {
const authResponse = await this.request<AuthTokenResponse>('/auth/login', {
method: 'POST',
body: { email, password },
cookieHeader,
response,
unauthorizedMessage: 'Invalid email or password',
});
return this.frontendTokenResponse(authResponse);
}
async refresh(cookieHeader: string | undefined, response: Response) {
const authResponse = await this.request<AuthTokenResponse>(
'/auth/refresh',
{
method: 'POST',
cookieHeader,
response,
unauthorizedMessage: 'Invalid session',
},
);
return this.frontendTokenResponse(authResponse);
}
async logout(cookieHeader: string | undefined, response: Response) {
await this.request('/auth/logout', {
method: 'POST',
cookieHeader,
response,
ignoreAuthErrors: true,
});
return { ok: true };
}
async me(accessToken: string | undefined) {
return this.request('/auth/me', {
method: 'GET',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
}
async handoff(accessToken: string | undefined, audienceApp: string) {
return this.request('/auth/handoff', {
method: 'POST',
accessToken,
body: { audienceApp },
unauthorizedMessage: 'Invalid bearer token',
});
}
async changePassword(
accessToken: string | undefined,
currentPassword: string,
newPassword: string,
) {
return this.request('/auth/password/change', {
method: 'POST',
accessToken,
body: { currentPassword, newPassword },
unauthorizedMessage: 'Invalid bearer token',
});
}
async getProfileAvatar(accessToken: string | undefined) {
return this.binaryRequest('/auth/profile/avatar', {
method: 'GET',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
}
async updateProfileAvatar(
accessToken: string | undefined,
contentType: string | undefined,
body: Buffer,
) {
return this.binaryRequest('/auth/profile/avatar', {
method: 'PUT',
accessToken,
contentType,
body,
unauthorizedMessage: 'Invalid bearer token',
});
}
async deleteProfileAvatar(accessToken: string | undefined) {
await this.binaryRequest('/auth/profile/avatar', {
method: 'DELETE',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
return { ok: true };
}
private frontendTokenResponse(authResponse: AuthTokenResponse) {
return {
accessToken: authResponse.accessToken,
accessTokenExpiresIn: authResponse.accessTokenExpiresIn,
tokenType: 'Bearer',
user: authResponse.user,
};
}
private async request<T = unknown>(
path: string,
options: {
method: 'GET' | 'POST';
body?: unknown;
accessToken?: string;
cookieHeader?: string;
response?: Response;
unauthorizedMessage?: string;
ignoreAuthErrors?: boolean;
},
): Promise<T> {
const headers: Record<string, string> = {};
if (options.body) headers['content-type'] = 'application/json';
if (options.accessToken) {
headers.authorization = `Bearer ${options.accessToken}`;
}
if (options.cookieHeader) {
headers.cookie = options.cookieHeader;
}
let authResponse: globalThis.Response;
try {
authResponse = await fetch(`${this.authServerUrl}${path}`, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new BadGatewayException('Auth server is unavailable');
}
this.forwardSetCookie(authResponse, options.response);
const payload: unknown = await authResponse.json().catch((): null => null);
if (authResponse.status === 401) {
if (options.ignoreAuthErrors) return { ok: true } as T;
throw new UnauthorizedException(
options.unauthorizedMessage ?? 'Unauthorized',
);
}
if (authResponse.status === 400) {
throw new BadRequestException(payload);
}
if (!authResponse.ok) {
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: authResponse.status,
error: payload,
});
}
return payload as T;
}
private async binaryRequest(
path: string,
options: {
method: 'GET' | 'PUT' | 'DELETE';
accessToken?: string;
contentType?: string;
body?: Buffer;
unauthorizedMessage?: string;
},
) {
const headers: Record<string, string> = {};
if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
if (options.contentType) headers['content-type'] = options.contentType;
const requestBody = options.body
? new Uint8Array(options.body).buffer
: undefined;
let authResponse: globalThis.Response;
try {
authResponse = await fetch(`${this.authServerUrl}${path}`, {
method: options.method,
headers,
body: requestBody,
});
} catch {
throw new BadGatewayException('Auth server is unavailable');
}
if (authResponse.status === 401) {
throw new UnauthorizedException(
options.unauthorizedMessage ?? 'Unauthorized',
);
}
if (!authResponse.ok) {
const payload: unknown = await authResponse.json().catch((): null => null);
if (authResponse.status === 404) {
throw new NotFoundException(payload);
}
if ([400, 413, 415].includes(authResponse.status)) {
throw new BadRequestException(payload);
}
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: authResponse.status,
error: payload,
});
}
return {
contentType: authResponse.headers.get('content-type') ?? 'application/octet-stream',
body: Buffer.from(await authResponse.arrayBuffer()),
};
}
private forwardSetCookie(
authResponse: globalThis.Response,
response?: Response,
) {
if (!response) return;
const cookies = this.getSetCookieHeaders(authResponse.headers);
for (const cookie of cookies) {
response.append('Set-Cookie', cookie);
}
}
private getSetCookieHeaders(headers: Headers): string[] {
const maybeHeaders = headers as unknown as {
getSetCookie?: unknown;
};
if (typeof maybeHeaders.getSetCookie === 'function') {
const cookies = maybeHeaders.getSetCookie.call(headers);
return Array.isArray(cookies)
? cookies.filter(
(cookie): cookie is string => typeof cookie === 'string',
)
: [];
}
return this.splitSetCookieHeader(headers.get('set-cookie'));
}
private splitSetCookieHeader(header: string | null): string[] {
if (!header) return [];
return header
.split(/,(?=\s*[^;,=]+=[^;,]+)/)
.map((cookie) => cookie.trim());
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@MinLength(1)
currentPassword: string;
@IsString()
@MinLength(8)
newPassword: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class HandoffDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
audienceApp?: string;
}
+10
View File
@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
}
+47
View File
@@ -0,0 +1,47 @@
import { Body, Controller, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
import { DownloadsService } from './downloads.service';
@Controller('api/downloads')
export class DownloadsController {
constructor(private readonly downloadsService: DownloadsService) {}
@Get()
list(
@Query('appKey') appKey = 'all',
@Query('contextKey') contextKey = 'general',
) {
return this.downloadsService.list(appKey, contextKey);
}
@Post()
create(@Body() body: CreateDownloadFileDto, @Req() request: Request) {
return this.downloadsService.create(body, this.getBearerToken(request));
}
@Get(':id/file')
async download(
@Param('id') id: string,
@Req() request: Request,
@Res() response: Response,
) {
const result = await this.downloadsService.download(id, {
accessToken: this.getBearerToken(request),
userAgent: request.headers['user-agent'],
ipAddress: request.ip,
});
response.setHeader('Content-Type', result.contentType);
response.setHeader('Content-Disposition', result.contentDisposition);
response.setHeader('Cache-Control', 'no-store');
if (result.contentLength) response.setHeader('Content-Length', result.contentLength);
response.send(result.body);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { DownloadsController } from './downloads.controller';
import { DownloadsService } from './downloads.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [DownloadsController],
providers: [DownloadsService],
})
export class DownloadsModule {}
+169
View File
@@ -0,0 +1,169 @@
import {
BadGatewayException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
type AuthMeResponse = {
user?: {
id: string;
};
};
type DownloadRequestMeta = {
accessToken?: string;
userAgent?: string;
ipAddress?: string;
};
@Injectable()
export class DownloadsService {
constructor(
private readonly authService: AuthService,
private readonly prisma: PrismaService,
) {}
async list(appKey: string, contextKey: string) {
const files = await this.prisma.downloadFile.findMany({
where: {
appKey: appKey.trim() || 'all',
contextKey: contextKey.trim() || 'general',
isActive: true,
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return {
files: files.map((file) => ({
id: file.id,
appKey: file.appKey,
contextKey: file.contextKey,
title: file.title,
description: file.description,
fileName: file.fileName,
contentType: file.contentType ?? inferContentType(file.fileName, file.sourceUrl),
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
uploadedByAppKey: file.uploadedByAppKey,
sortOrder: file.sortOrder,
downloadUrl: `/api/downloads/${file.id}/file`,
createdAt: file.createdAt.toISOString(),
})),
};
}
async create(input: CreateDownloadFileDto, accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
const authUserId = await this.getAuthUserId(accessToken);
const contentType = input.contentType?.trim() || inferContentType(input.fileName, input.sourceUrl);
const fileSizeBytes = await this.readRemoteFileSize(input.sourceUrl).catch(() => null);
const file = await this.prisma.downloadFile.create({
data: {
appKey: input.appKey.trim(),
contextKey: input.contextKey?.trim() || 'general',
title: input.title.trim(),
description: input.description?.trim() || null,
fileName: input.fileName.trim(),
sourceUrl: input.sourceUrl.trim(),
contentType,
fileSizeBytes,
uploadedByAppKey: input.uploadedByAppKey?.trim() || input.appKey.trim(),
uploadedByAuthId: authUserId,
sortOrder: input.sortOrder ?? 0,
isActive: input.isActive ?? true,
},
});
return {
file: {
...file,
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
},
};
}
async download(id: string, meta: DownloadRequestMeta) {
const file = await this.prisma.downloadFile.findFirst({
where: {
id,
isActive: true,
},
});
if (!file) throw new NotFoundException('Download file not found');
const remoteResponse = await fetch(file.sourceUrl).catch(() => null);
if (!remoteResponse) throw new BadGatewayException('Download source is unavailable');
if (!remoteResponse.ok) {
throw new BadGatewayException({
message: 'Download source request failed',
statusCode: remoteResponse.status,
});
}
const authUserId = meta.accessToken
? await this.getAuthUserId(meta.accessToken).catch(() => null)
: null;
await this.prisma.downloadEvent.create({
data: {
fileId: file.id,
authUserId,
appKey: file.appKey,
userAgent: meta.userAgent ?? null,
ipAddress: meta.ipAddress ?? null,
},
});
const contentType =
file.contentType ||
inferContentType(file.fileName, file.sourceUrl) ||
remoteResponse.headers.get('content-type') ||
'application/octet-stream';
const contentLength = remoteResponse.headers.get('content-length') ?? file.fileSizeBytes?.toString() ?? null;
return {
contentType,
contentDisposition: buildContentDisposition(file.fileName),
contentLength,
body: Buffer.from(await remoteResponse.arrayBuffer()),
};
}
private async getAuthUserId(accessToken: string): Promise<string> {
const response = (await this.authService.me(accessToken)) as AuthMeResponse;
if (!response.user?.id) throw new UnauthorizedException('Invalid bearer token');
return response.user.id;
}
private async readRemoteFileSize(sourceUrl: string): Promise<bigint | null> {
const response = await fetch(sourceUrl, { method: 'HEAD' });
if (!response.ok) return null;
const contentLength = response.headers.get('content-length');
return contentLength && /^\d+$/.test(contentLength) ? BigInt(contentLength) : null;
}
}
function inferContentType(fileName: string, sourceUrl: string): string | null {
const path = `${fileName || sourceUrl}`.split('?')[0]?.toLowerCase() ?? '';
if (path.endsWith('.pdf')) return 'application/pdf';
if (path.endsWith('.zip')) return 'application/zip';
if (path.endsWith('.json')) return 'application/json';
if (path.endsWith('.txt')) return 'text/plain; charset=utf-8';
if (path.endsWith('.png')) return 'image/png';
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
if (path.endsWith('.webp')) return 'image/webp';
return null;
}
function buildContentDisposition(fileName: string): string {
const fallback = fileName.replace(/[^\w. -]/g, '_') || 'download';
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
}
@@ -0,0 +1,47 @@
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class CreateDownloadFileDto {
@IsString()
@MaxLength(80)
appKey: string;
@IsOptional()
@IsString()
@MaxLength(120)
contextKey?: string;
@IsString()
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@IsString()
@MaxLength(255)
fileName: string;
@IsUrl({ require_protocol: true })
@MaxLength(2000)
sourceUrl: string;
@IsOptional()
@IsString()
@MaxLength(120)
contentType?: string;
@IsOptional()
@IsString()
@MaxLength(80)
uploadedByAppKey?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
+47
View File
@@ -0,0 +1,47 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { raw } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 4200);
const corsOrigin = configService.get<string>('CORS_ORIGIN', '*');
const configuredOrigins = corsOrigin
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const isLocalDevelopmentOrigin = (origin: string): boolean => {
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1'].includes(url.hostname);
} catch {
return false;
}
};
app.enableCors({
origin: (origin, callback) => {
if (!origin || corsOrigin === '*' || configuredOrigins.includes(origin) || isLocalDevelopmentOrigin(origin)) {
callback(null, true);
return;
}
callback(new Error(`CORS origin not allowed: ${origin}`));
},
credentials: true,
});
app.use('/api/auth/profile/avatar', raw({ type: ['image/webp', 'image/png', 'image/jpeg'], limit: '80kb' }));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(port);
}
void bootstrap();
+17
View File
@@ -0,0 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common';
import { NoticesService } from './notices.service';
@Controller('api/notices')
export class NoticesController {
constructor(private readonly noticesService: NoticesService) {}
@Get('login')
getLoginNotice() {
return this.noticesService.getLoginNotice();
}
@Get('apps/:appKey')
getAppNotices(@Param('appKey') appKey: string) {
return this.noticesService.getAppNotices(appKey);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { NoticesController } from './notices.controller';
import { NoticesService } from './notices.service';
@Module({
imports: [PrismaModule],
controllers: [NoticesController],
providers: [NoticesService],
})
export class NoticesModule {}
+73
View File
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class NoticesService {
constructor(private readonly prisma: PrismaService) {}
async getLoginNotice() {
const now = new Date();
const notice = await this.prisma.launcherNotice.findFirst({
where: {
appKey: 'Launcher',
isActive: true,
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
title: true,
body: true,
linkUrl: true,
},
});
return { notice };
}
async getAppNotices(appKey: string) {
const now = new Date();
const notices = await this.prisma.launcherNotice.findMany({
where: {
appKey,
isActive: true,
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
title: true,
body: true,
linkUrl: true,
createdAt: true,
},
});
return {
notices: notices.map((notice) => ({
...notice,
date: formatNoticeDate(notice.createdAt),
})),
};
}
}
function formatNoticeDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`;
}
@@ -0,0 +1,34 @@
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class CreateNotificationDto {
@IsString()
@MaxLength(80)
appKey: string;
@IsOptional()
@IsString()
@MaxLength(80)
audience?: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsString()
@MaxLength(200)
title: string;
@IsString()
@MaxLength(1000)
body: string;
@IsOptional()
@IsUrl({ require_protocol: true })
@MaxLength(2000)
linkUrl?: string;
@IsOptional()
@IsInt()
priority?: number;
}
@@ -0,0 +1,58 @@
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { CreateNotificationDto } from './dto/create-notification.dto';
import { NotificationsService } from './notifications.service';
type ReadBody = {
notificationIds?: string[];
appKey?: string;
audience?: string;
query?: string;
};
@Controller('api/notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
list(
@Query('appKey') appKey = 'all',
@Query('audience') audience = 'all',
@Query('page') page = '1',
@Query('pageSize') pageSize = '50',
@Query('query') query = '',
@Query('read') read = 'all',
@Req() request: Request,
) {
return this.notificationsService.list({
appKey,
audience,
page,
pageSize,
query,
read,
accessToken: this.getBearerToken(request),
});
}
@Post()
create(@Body() body: CreateNotificationDto, @Req() request: Request) {
return this.notificationsService.create(body, this.getBearerToken(request));
}
@Post(':id/read')
markRead(@Param('id') id: string, @Req() request: Request) {
return this.notificationsService.markRead([id], this.getBearerToken(request));
}
@Post('read-all')
markReadAll(@Body() body: ReadBody, @Req() request: Request) {
return this.notificationsService.markReadAll(body, this.getBearerToken(request));
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from '../prisma/prisma.module';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@Module({
imports: [ConfigModule, PrismaModule],
controllers: [NotificationsController],
providers: [NotificationsService],
})
export class NotificationsModule {}
+288
View File
@@ -0,0 +1,288 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { CreateNotificationDto } from './dto/create-notification.dto';
type AuthMeResponse = {
user?: {
id: string;
};
};
type ListInput = {
appKey: string;
audience: string;
page?: string;
pageSize?: string;
query?: string;
read?: string;
accessToken?: string;
};
type MarkAllReadInput = {
notificationIds?: string[];
appKey?: string;
audience?: string;
query?: string;
};
const NOTIFICATION_CATEGORY_GROUP = 'NOTIFICATION_CATEGORY';
@Injectable()
export class NotificationsService {
private readonly authServerUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
}
async list(input: ListInput) {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const appKey = input.appKey.trim() || 'all';
const audience = input.audience.trim() || 'all';
const page = Math.max(1, Number.parseInt(input.page ?? '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, Number.parseInt(input.pageSize ?? '50', 10) || 50));
const query = input.query?.trim() ?? '';
const readFilter = input.read === 'read' || input.read === 'unread' ? input.read : 'all';
const authUserId = input.accessToken ? await this.getAuthUserId(input.accessToken).catch(() => null) : null;
const notifications = await this.prisma.notification.findMany({
where: {
isActive: true,
appKey: { in: ['all', appKey] },
audience: { in: ['all', audience] },
createdAt: {
gte: oneDayAgo,
lte: now,
},
...(query
? {
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
}
: {}),
AND: [
{
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
],
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
appKey: true,
audience: true,
category: true,
title: true,
body: true,
linkUrl: true,
createdAt: true,
reads: authUserId
? {
where: { authUserId },
select: { readAt: true },
take: 1,
}
: false,
},
});
const categoryCodes = [...new Set(notifications.map((notification) => notification.category))];
const commonCodes = categoryCodes.length
? await this.prisma.commonCode.findMany({
where: {
groupKey: NOTIFICATION_CATEGORY_GROUP,
code: { in: categoryCodes },
isActive: true,
},
select: {
code: true,
label: true,
},
})
: [];
const categoryLabelByCode = new Map(commonCodes.map((code) => [code.code, code.label]));
const mappedNotifications = notifications
.map((notification) => ({
id: notification.id,
appKey: notification.appKey,
audience: notification.audience,
category: notification.category,
categoryLabel: categoryLabelByCode.get(notification.category) ?? notification.category,
title: notification.title,
body: notification.body,
linkUrl: notification.linkUrl,
createdAt: notification.createdAt.toISOString(),
date: formatNotificationDate(notification.createdAt),
read: authUserId ? notification.reads.length > 0 : false,
}))
.filter((notification) => {
if (readFilter === 'read') return notification.read;
if (readFilter === 'unread') return !notification.read;
return true;
})
.sort((left, right) => {
if (left.read !== right.read) return left.read ? 1 : -1;
return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
});
const total = mappedNotifications.length;
const unreadCount = mappedNotifications.filter((notification) => !notification.read).length;
const start = (page - 1) * pageSize;
return {
notifications: mappedNotifications.slice(start, start + pageSize),
total,
page,
pageSize,
unreadCount,
retentionHours: 24,
};
}
async create(input: CreateNotificationDto, accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
await this.getAuthUserId(accessToken);
const notification = await this.prisma.notification.create({
data: {
appKey: input.appKey.trim(),
audience: input.audience?.trim() || 'all',
category: input.category?.trim() || 'notice',
title: input.title.trim(),
body: input.body.trim(),
linkUrl: input.linkUrl?.trim() || null,
priority: input.priority ?? 0,
isActive: true,
},
});
return {
notification: {
id: notification.id,
appKey: notification.appKey,
audience: notification.audience,
category: notification.category,
title: notification.title,
body: notification.body,
linkUrl: notification.linkUrl,
createdAt: notification.createdAt.toISOString(),
date: formatNotificationDate(notification.createdAt),
read: false,
},
};
}
async markRead(notificationIds: string[], accessToken: string | undefined) {
if (!notificationIds.length) return { ok: true, readCount: 0 };
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
const authUserId = await this.getAuthUserId(accessToken);
const ids = [...new Set(notificationIds)];
await this.prisma.$transaction(
ids.map((notificationId) =>
this.prisma.notificationRead.upsert({
where: {
notificationId_authUserId: {
notificationId,
authUserId,
},
},
create: {
notificationId,
authUserId,
},
update: {
readAt: new Date(),
},
}),
),
);
return { ok: true, readCount: ids.length };
}
async markReadAll(input: MarkAllReadInput, accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
if (input.notificationIds?.length) {
return this.markRead(input.notificationIds, accessToken);
}
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const appKey = input.appKey?.trim() || 'all';
const audience = input.audience?.trim() || 'all';
const query = input.query?.trim() ?? '';
const notifications = await this.prisma.notification.findMany({
where: {
isActive: true,
appKey: { in: ['all', appKey] },
audience: { in: ['all', audience] },
createdAt: {
gte: oneDayAgo,
lte: now,
},
...(query
? {
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
}
: {}),
AND: [
{
OR: [
{ startsAt: null, endsAt: null },
{ startsAt: { lte: now }, endsAt: null },
{ startsAt: null, endsAt: { gte: now } },
{ startsAt: { lte: now }, endsAt: { gte: now } },
],
},
],
},
select: {
id: true,
},
});
return this.markRead(
notifications.map((notification) => notification.id),
accessToken,
);
}
private async getAuthUserId(accessToken: string): Promise<string> {
const response = await fetch(`${this.authServerUrl}/auth/me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new UnauthorizedException('Invalid bearer token');
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null;
if (!payload?.user?.id) throw new UnauthorizedException('Invalid bearer token');
return payload.user.id;
}
}
function formatNotificationDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`;
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
@@ -0,0 +1,98 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export class ProvisioningDeviceDto {
@IsString()
@IsOptional()
hostname?: string;
@IsString()
@IsOptional()
os?: string;
@IsString()
@IsOptional()
arch?: string;
@IsString()
@IsOptional()
kernel?: string;
@IsString()
@IsOptional()
distro?: string;
@IsString()
@IsOptional()
usageType?: string;
@IsString()
@IsOptional()
sshUser?: string;
@IsString()
@IsOptional()
workspaceRoot?: string;
@IsString()
@IsOptional()
publicSshKey?: string;
}
export class CreateProvisioningDto {
@IsEmail()
account: string;
@IsString()
@IsNotEmpty()
password: string;
@IsString()
@IsNotEmpty()
hostname: string;
@IsString()
@IsOptional()
os?: string;
@IsString()
@IsOptional()
arch?: string;
@IsString()
@IsOptional()
kernel?: string;
@IsString()
@IsOptional()
distro?: string;
@IsString()
@IsOptional()
usageType?: string;
@IsString()
@IsOptional()
sshUser?: string;
@IsString()
@IsOptional()
workspaceRoot?: string;
@IsString()
@IsOptional()
publicSshKey?: string;
@IsObject()
@ValidateNested()
@Type(() => ProvisioningDeviceDto)
@IsOptional()
device?: ProvisioningDeviceDto;
}
@@ -0,0 +1,36 @@
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
import { ProvisioningService } from './provisioning.service';
import type { ProvisioningResponse } from './provisioning.service';
@Controller('api/provision')
export class ProvisioningController {
constructor(
private readonly provisioningService: ProvisioningService,
private readonly configService: ConfigService,
) {}
@Post()
createProvisioning(
@Body() createProvisioningDto: CreateProvisioningDto,
): Promise<ProvisioningResponse> {
return this.provisioningService.createProvisioning(createProvisioningDto);
}
@Post('ssh-keys/lookup')
listSshKeys(
@Headers('x-lms-gateway-token') token: string | undefined,
@Body() body: { emails?: string[] },
) {
const expectedToken =
this.configService.get<string>('LMS_GATEWAY_TOKEN') ??
this.configService.get<string>('PLATFORM_GATEWAY_TOKEN') ??
'';
if (!expectedToken || token !== expectedToken) {
throw new UnauthorizedException('invalid_gateway_token');
}
return this.provisioningService.listLauncherSshKeys(body.emails ?? []);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ProvisioningController } from './provisioning.controller';
import { ProvisioningService } from './provisioning.service';
@Module({
imports: [PrismaModule],
controllers: [ProvisioningController],
providers: [ProvisioningService],
})
export class ProvisioningModule {}
+622
View File
@@ -0,0 +1,622 @@
import {
BadGatewayException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { createHash } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
export type ProvisioningResponse = {
installTailscale: boolean;
tailscaleAuthKey: string;
tailscaleLoginServer: string;
tailscaleHostname: string;
tailscaleExtraArgs: string;
provisioningMode: 'personal' | 'class';
agentGatewayToken: string;
};
type AuthLoginResponse = {
user?: {
id: string;
email: string;
name?: string | null;
role?: string;
roles?: string[];
};
};
@Injectable()
export class ProvisioningService {
private readonly authServerUrl: string;
private readonly headscaleBaseUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
this.headscaleBaseUrl = this.configService
.get<string>(
'HEADSCALE_BASE_URL',
this.configService.get<string>(
'TAILSCALE_LOGIN_SERVER',
'https://atnmy-hs.duckdns.org',
),
)
.replace(/\/$/, '');
}
async createProvisioning(
provisioningDto: CreateProvisioningDto,
): Promise<ProvisioningResponse> {
const authUser = await this.authenticateAccount(
provisioningDto.account,
provisioningDto.password,
);
const headscaleUser = this.toHeadscaleUserName(authUser.email);
const provisioningMode = this.normalizeProvisioningMode(
provisioningDto.usageType ?? provisioningDto.device?.usageType,
);
if (provisioningMode === 'class' && !this.isInstructor(authUser)) {
throw new ForbiddenException('Instructor role required for class device');
}
const aclTags =
provisioningMode === 'class' ? this.getClassDeviceAclTags() : [];
const tailscaleHostname = this.toTailscaleHostname(
provisioningDto.hostname || 'agent',
headscaleUser,
);
await this.recordProvisionedDevice(
provisioningDto,
authUser,
tailscaleHostname,
provisioningMode,
);
await this.recordLauncherSshKey(provisioningDto, authUser);
return {
installTailscale: true,
tailscaleAuthKey: await this.createTailscaleAuthKey(
headscaleUser,
aclTags,
),
tailscaleLoginServer: this.configService.get<string>(
'TAILSCALE_LOGIN_SERVER',
this.headscaleBaseUrl,
),
tailscaleHostname,
tailscaleExtraArgs: this.configService.get<string>(
'TAILSCALE_EXTRA_ARGS',
'--accept-routes',
),
provisioningMode,
agentGatewayToken: this.configService.get<string>(
'AGENT_GATEWAY_TOKEN',
'',
),
};
}
async listLauncherSshKeys(emails: string[]) {
const normalizedEmails = Array.from(
new Set(
emails
.map((email) => email.trim().toLowerCase())
.filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)),
),
);
if (!normalizedEmails.length) return { keysByEmail: {} };
const keys = await this.prisma.launcherSshKey.findMany({
where: { accountEmail: { in: normalizedEmails } },
orderBy: { updatedAt: 'desc' },
select: {
accountEmail: true,
publicKey: true,
keyType: true,
label: true,
updatedAt: true,
},
});
const keysByEmail: Record<string, Array<{
publicKey: string;
keyType: string | null;
label: string | null;
updatedAt: string;
}>> = {};
for (const key of keys) {
const bucket = keysByEmail[key.accountEmail] ?? [];
bucket.push({
publicKey: key.publicKey,
keyType: key.keyType,
label: key.label,
updatedAt: key.updatedAt.toISOString(),
});
keysByEmail[key.accountEmail] = bucket;
}
return { keysByEmail };
}
private async recordLauncherSshKey(
provisioningDto: CreateProvisioningDto,
authUser: NonNullable<AuthLoginResponse['user']>,
) {
const publicKey = this.normalizePublicSshKey(
provisioningDto.publicSshKey || provisioningDto.device?.publicSshKey,
);
if (!publicKey) return;
const publicKeyHash = createHash('sha256').update(publicKey).digest('hex');
const keyType = publicKey.split(/\s+/, 1)[0] || null;
await this.prisma.launcherSshKey.upsert({
where: {
accountEmail_publicKeyHash: {
accountEmail: authUser.email,
publicKeyHash,
},
},
create: {
accountEmail: authUser.email,
authUserId: authUser.id,
publicKey,
publicKeyHash,
keyType,
label: 'launcher',
lastProvisionedAt: new Date(),
},
update: {
authUserId: authUser.id,
publicKey,
keyType,
label: 'launcher',
lastProvisionedAt: new Date(),
},
});
}
private async recordProvisionedDevice(
provisioningDto: CreateProvisioningDto,
authUser: NonNullable<AuthLoginResponse['user']>,
tailscaleHostname: string,
provisioningMode: 'personal' | 'class',
) {
const hostname = this.cleanString(
provisioningDto.hostname || provisioningDto.device?.hostname || 'agent',
) ?? 'agent';
const sshUser = this.cleanString(
provisioningDto.sshUser || provisioningDto.device?.sshUser,
);
const workspaceRoot = this.cleanString(
provisioningDto.workspaceRoot || provisioningDto.device?.workspaceRoot,
);
const now = new Date();
await this.prisma.provisionedDevice.upsert({
where: {
accountEmail_tailscaleHostname: {
accountEmail: authUser.email,
tailscaleHostname,
},
},
create: {
accountEmail: authUser.email,
authUserId: authUser.id,
hostname,
tailscaleHostname,
sshUser,
workspaceRoot,
usageType: provisioningMode,
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
arch: this.cleanString(
provisioningDto.arch || provisioningDto.device?.arch,
),
kernel: this.cleanString(
provisioningDto.kernel || provisioningDto.device?.kernel,
),
distro: this.cleanString(
provisioningDto.distro || provisioningDto.device?.distro,
),
device: this.cleanJson(provisioningDto.device),
installedAt: now,
lastProvisionedAt: now,
},
update: {
authUserId: authUser.id,
hostname,
sshUser,
workspaceRoot,
usageType: provisioningMode,
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
arch: this.cleanString(
provisioningDto.arch || provisioningDto.device?.arch,
),
kernel: this.cleanString(
provisioningDto.kernel || provisioningDto.device?.kernel,
),
distro: this.cleanString(
provisioningDto.distro || provisioningDto.device?.distro,
),
device: this.cleanJson(provisioningDto.device),
lastProvisionedAt: now,
},
});
}
private cleanString(value?: string | null) {
const normalized = value?.trim();
return normalized || null;
}
private normalizePublicSshKey(value?: string | null) {
const normalized = value?.trim().replace(/\s+/g, ' ');
if (!normalized) return null;
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(normalized)) {
return null;
}
return normalized;
}
private cleanJson(value: unknown): Prisma.InputJsonValue | undefined {
if (value === undefined || value === null) return undefined;
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private async authenticateAccount(
email: string,
password: string,
): Promise<NonNullable<AuthLoginResponse['user']>> {
let response: globalThis.Response;
try {
response = await fetch(`${this.authServerUrl}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
} catch {
throw new BadGatewayException('Auth server is unavailable');
}
const payload = (await response.json().catch(() => null)) as
| AuthLoginResponse
| null;
if (response.status === 401) {
throw new UnauthorizedException('Invalid account or password');
}
if (!response.ok || !payload?.user?.email) {
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: response.status,
error: payload,
});
}
return payload.user;
}
private async createTailscaleAuthKey(
headscaleUser: string,
aclTags: string[],
): Promise<string> {
const headscaleApiKey = this.configService.get<string>('HEADSCALE_API_KEY');
if (!headscaleApiKey) {
throw new BadGatewayException('HEADSCALE_API_KEY is not configured');
}
const headscaleUserId = await this.ensureHeadscaleUser(
headscaleUser,
headscaleApiKey,
);
return this.createHeadscalePreAuthKey(
headscaleUserId,
headscaleApiKey,
aclTags,
);
}
private async ensureHeadscaleUser(
userName: string,
apiKey: string,
): Promise<number> {
const response = await this.headscaleRequest('/api/v1/user', apiKey, {
method: 'POST',
body: { name: userName },
});
const payload = await this.readResponsePayload(response);
if (!response.ok) {
if (response.status === 409 || this.isAlreadyExistsResponse(payload)) {
return this.findHeadscaleUserId(userName, apiKey);
}
throw new BadGatewayException(
this.formatHeadscaleError(
'Headscale user provisioning failed',
response.status,
payload,
),
);
}
const createdUserId = this.findUserId(payload, userName);
return createdUserId ?? this.findHeadscaleUserId(userName, apiKey);
}
private async createHeadscalePreAuthKey(
userId: number,
apiKey: string,
aclTags: string[],
): Promise<string> {
const expiresInSeconds = this.configService.get<number>(
'TAILSCALE_PREAUTH_KEY_EXPIRATION_SECONDS',
60 * 60 * 24,
);
const expiration = new Date(
Date.now() + expiresInSeconds * 1000,
).toISOString();
const { response, payload } = await this.createHeadscalePreAuthKeyRequest(
userId,
apiKey,
expiration,
aclTags,
);
if (!response.ok) {
if (this.isAlreadyExistsResponse(payload)) {
const retry = await this.createHeadscalePreAuthKeyRequest(
userId,
apiKey,
new Date(Date.now() + (expiresInSeconds + 30) * 1000).toISOString(),
aclTags,
);
if (retry.response.ok) {
const retryKey = this.findAuthKey(retry.payload);
if (retryKey) return retryKey;
}
}
throw new BadGatewayException(
this.formatHeadscaleError(
'Headscale pre-auth key provisioning failed',
response.status,
payload,
),
);
}
const key = this.findAuthKey(payload);
if (!key) {
throw new BadGatewayException('Headscale response did not include a key');
}
return key;
}
private async createHeadscalePreAuthKeyRequest(
userId: number,
apiKey: string,
expiration: string,
aclTags: string[],
) {
const response = await this.headscaleRequest('/api/v1/preauthkey', apiKey, {
method: 'POST',
body: {
user: userId,
reusable: false,
ephemeral: false,
expiration,
aclTags,
},
});
return {
response,
payload: await this.readResponsePayload(response),
};
}
private async headscaleRequest(
path: string,
apiKey: string,
options: { method: 'GET' | 'POST'; body?: unknown },
) {
try {
return await fetch(`${this.headscaleBaseUrl}${path}`, {
method: options.method,
headers: {
authorization: `Bearer ${apiKey}`,
...(options.body ? { 'content-type': 'application/json' } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new BadGatewayException('Headscale server is unavailable');
}
}
private async readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text().catch(() => '');
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
private formatHeadscaleError(
message: string,
statusCode: number,
payload: unknown,
): string {
const detail = this.formatPayloadMessage(payload);
return detail
? `${message}: HTTP ${statusCode} ${detail}`
: `${message}: HTTP ${statusCode}`;
}
private formatPayloadMessage(payload: unknown): string {
if (!payload) return '';
if (typeof payload === 'string') return payload;
if (Array.isArray(payload)) {
return payload
.map((item) => this.formatPayloadMessage(item))
.filter(Boolean)
.join(', ');
}
if (typeof payload !== 'object') return String(payload);
const record = payload as Record<string, unknown>;
for (const key of ['message', 'error', 'details']) {
const message = this.formatPayloadMessage(record[key]);
if (message) return message;
}
try {
return JSON.stringify(payload);
} catch {
return '';
}
}
private async findHeadscaleUserId(
userName: string,
apiKey: string,
): Promise<number> {
const listResponse = await this.headscaleRequest('/api/v1/user', apiKey, {
method: 'GET',
});
const listPayload = (await listResponse.json().catch(() => null)) as unknown;
const listedUserId = this.findUserId(listPayload, userName);
if (listedUserId) return listedUserId;
const detailResponse = await this.headscaleRequest(
`/api/v1/user/${encodeURIComponent(userName)}`,
apiKey,
{ method: 'GET' },
);
const detailPayload = (await detailResponse
.json()
.catch(() => null)) as unknown;
const detailUserId = this.findUserId(detailPayload, userName);
if (detailUserId) return detailUserId;
throw new BadGatewayException({
message: 'Headscale user id was not found',
error: { user: userName },
});
}
private findAuthKey(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (typeof record.key === 'string') return record.key;
for (const value of Object.values(record)) {
const nestedKey = this.findAuthKey(value);
if (nestedKey) return nestedKey;
}
return null;
}
private isAlreadyExistsResponse(payload: unknown) {
const serialized =
typeof payload === 'string' ? payload : JSON.stringify(payload ?? {});
return /already exists|exists|duplicate|unique constraint/i.test(
serialized,
);
}
private findUserId(payload: unknown, userName: string): number | null {
if (!payload || typeof payload !== 'object') return null;
if (Array.isArray(payload)) {
for (const item of payload) {
const userId = this.findUserId(item, userName);
if (userId) return userId;
}
return null;
}
const record = payload as Record<string, unknown>;
const recordNames = ['name', 'username', 'email']
.map((key) => (typeof record[key] === 'string' ? String(record[key]) : ''))
.filter(Boolean);
const normalizedRecordNames = recordNames.map((name) =>
this.toHeadscaleUserName(name),
);
const normalizedUserName = this.toHeadscaleUserName(userName);
if (
normalizedRecordNames.includes(normalizedUserName) &&
(typeof record.id === 'number' || typeof record.id === 'string')
) {
const parsedId = Number(record.id);
return Number.isFinite(parsedId) && parsedId > 0 ? parsedId : null;
}
for (const value of Object.values(record)) {
const userId = this.findUserId(value, userName);
if (userId) return userId;
}
return null;
}
private toHeadscaleUserName(email: string) {
return email
.trim()
.toLowerCase()
.replace(/@/g, '-at-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 63);
}
private normalizeProvisioningMode(value?: string | null): 'personal' | 'class' {
const normalized = String(value ?? '').trim().toLowerCase();
return ['class', 'classroom', 'education', 'educational', 'training', 'use for class'].includes(normalized)
? 'class'
: 'personal';
}
private isInstructor(user: NonNullable<AuthLoginResponse['user']>) {
const roles = new Set(
[user.role, ...(user.roles ?? [])]
.filter(Boolean)
.map((role) => String(role).toUpperCase()),
);
return ['INSTRUCTOR', 'LMS_ADMIN', 'ADMIN'].some((role) =>
roles.has(role),
);
}
private getClassDeviceAclTags() {
return this.configService
.get<string>('HEADSCALE_CLASS_DEVICE_TAGS', '')
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => (tag.startsWith('tag:') ? tag : `tag:${tag}`));
}
private toTailscaleHostname(hostname: string, headscaleUser: string) {
const normalizedHost = hostname
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `${headscaleUser}-${normalizedHost || 'agent'}`.slice(0, 63);
}
}