Add device-certificate mTLS auth, live position tracking, and API docs

Introduces a CA/PKI module so field devices can authenticate to Mosquitto
over TLS (8883) with per-device client certificates (CN = serial number)
instead of a shared password, with matching Devices/MQTT-Certs UI. Adds
live transmitter position tracking alongside logged points, an MQTTS
transport option in the simulator for exercising the real cert-auth path,
and Swagger API docs at /api/docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ulhub
2026-07-18 01:40:12 +00:00
parent f1c94e9279
commit 842cb23e1f
57 changed files with 2283 additions and 67 deletions

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/auth.guard';
@@ -8,6 +9,8 @@ import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto } from './dto/api-keys.dto';
// JWT-only by design: an API key must not be able to mint or revoke API keys
@ApiTags('api-keys')
@ApiBearerAuth('jwt')
@Controller('orgs/:orgId/api-keys')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('ORG_ADMIN')

View File

@@ -1,17 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { API_KEY_SCOPES, ApiKeyScope } from '../../auth/principal';
export class CreateApiKeyDto {
@ApiProperty({ example: 'GIS export' })
@IsString()
@IsNotEmpty()
@MaxLength(120)
name: string;
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsIn(API_KEY_SCOPES, { each: true })
scopes: ApiKeyScope[];
@ApiProperty({ required: false, format: 'date-time' })
@IsOptional()
@IsDateString()
expiresAt?: string;

View File

@@ -1,6 +1,8 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AppService } from './app.service';
@ApiTags('app')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

View File

@@ -13,6 +13,7 @@ import { RealtimeModule } from './realtime/realtime.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SimModule } from './sim/sim.module';
import { DeviceStatusModule } from './device-status/device-status.module';
import { CertificatesModule } from './certificates/certificates.module';
@Module({
imports: [
@@ -27,6 +28,7 @@ import { DeviceStatusModule } from './device-status/device-status.module';
ApiKeysModule,
SimModule,
DeviceStatusModule,
CertificatesModule,
],
controllers: [AppController, StatusController],
providers: [AppService],

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Get, HttpCode, Post, Res, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { AuthService } from './auth.service';
import { CurrentPrincipal } from './decorators/current-user.decorator';
@@ -20,10 +21,12 @@ function setAuthCookie(res: Response, token: string) {
});
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// Public: creates a new organization with the registrant as ORG_ADMIN.
@Post('register')
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const { token, user, memberships } = await this.authService.register(dto);
@@ -42,6 +45,7 @@ export class AuthController {
@Post('logout')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('jwt')
logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie(AUTH_COOKIE, { path: '/' });
return { ok: true };
@@ -49,6 +53,7 @@ export class AuthController {
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('jwt')
me(@CurrentPrincipal() principal: UserPrincipal) {
return this.authService.me(principal.userId);
}

View File

@@ -1,9 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty()
@IsEmail()
email: string;
@ApiProperty()
@IsString()
@MinLength(1)
password: string;

View File

@@ -1,19 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
export class RegisterDto {
@ApiProperty({ example: 'brent@example.com' })
@IsEmail()
email: string;
@ApiProperty({ minLength: 8, maxLength: 72 })
@IsString()
@MinLength(8)
@MaxLength(72)
password: string;
@ApiProperty({ example: 'Brent Perteet' })
@IsString()
@IsNotEmpty()
@MaxLength(120)
name: string;
@ApiProperty({ description: 'A new organization is created with you as its admin', example: 'Umagul' })
@IsString()
@IsNotEmpty()
@MaxLength(120)

View File

@@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Header, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/auth.guard';
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CertificatesService } from './certificates.service';
import { ProvisionMqttCertDto } from './dto/certificates.dto';
import { RequireAnyOrgAdminGuard } from './guards/require-any-org-admin.guard';
// Global broker/CA management — not org-nested, since the CA is broker-wide
// (mirrors serialNumber already being globally unique across orgs). JWT-only,
// same precedent as api-keys: credential-minting endpoints never accept an
// API key.
@ApiTags('certificates')
@ApiBearerAuth('jwt')
@Controller('certificates')
@UseGuards(JwtAuthGuard, RequireAnyOrgAdminGuard)
export class CertificatesController {
constructor(private readonly certificatesService: CertificatesService) {}
@Get('ca')
caStatus() {
return this.certificatesService.getCaStatus();
}
@Post('ca/init')
initCa() {
return this.certificatesService.initCa();
}
@Post('mqtt/provision')
provisionMqttCert(@Body() dto: ProvisionMqttCertDto) {
return this.certificatesService.provisionMqttCert(dto);
}
@Get('ca/download')
@Header('Content-Type', 'application/x-pem-file')
@Header('Content-Disposition', 'attachment; filename="ca.crt"')
downloadCa() {
return this.certificatesService.downloadCaCert();
}
}
// Per-device certificate issuance, nested under the org's devices like the
// rest of the domain. Same guard stack as devices create/disable.
@ApiTags('certificates')
@ApiBearerAuth('jwt')
@Controller('orgs/:orgId/devices/:deviceId/certificate')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('ORG_ADMIN')
export class DeviceCertificatesController {
constructor(private readonly certificatesService: CertificatesService) {}
@Post()
issue(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
return this.certificatesService.issueDeviceCertificate(orgId, deviceId);
}
@Get()
get(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
return this.certificatesService.getDeviceCertificate(orgId, deviceId);
}
@Get('cert')
@Header('Content-Type', 'application/x-pem-file')
@Header('Content-Disposition', 'attachment; filename="device.crt"')
downloadCert(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
return this.certificatesService.downloadDeviceCert(orgId, deviceId);
}
@Get('key')
@Header('Content-Type', 'application/x-pem-file')
@Header('Content-Disposition', 'attachment; filename="device.key"')
downloadKey(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
return this.certificatesService.downloadDeviceKey(orgId, deviceId);
}
@Delete()
revoke(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
return this.certificatesService.revokeDeviceCertificate(orgId, deviceId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CertificatesController, DeviceCertificatesController } from './certificates.controller';
import { CertificatesService } from './certificates.service';
import { PkiService } from './pki.service';
import { RequireAnyOrgAdminGuard } from './guards/require-any-org-admin.guard';
@Module({
controllers: [CertificatesController, DeviceCertificatesController],
providers: [CertificatesService, PkiService, RequireAnyOrgAdminGuard],
exports: [PkiService],
})
export class CertificatesModule {}

View File

@@ -0,0 +1,113 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PkiService } from './pki.service';
import { ProvisionMqttCertDto } from './dto/certificates.dto';
const CERT_SELECT = {
id: true,
serialNumber: true,
commonName: true,
fingerprint: true,
issuedAt: true,
expiresAt: true,
};
@Injectable()
export class CertificatesService {
constructor(
private readonly prisma: PrismaService,
private readonly pki: PkiService,
) {}
getCaStatus() {
return this.pki.caStatus();
}
async initCa() {
await this.pki.initCa();
return this.pki.caStatus();
}
async provisionMqttCert(dto: ProvisionMqttCertDto) {
await this.pki.provisionServerCert(dto.hostname);
return { ok: true };
}
async downloadCaCert() {
return this.pki.caCertPem();
}
async issueDeviceCertificate(orgId: string, deviceId: string) {
const device = await this.getDevice(orgId, deviceId);
if (!device.serialNumber) {
throw new BadRequestException('Device needs a serial number before a certificate can be issued');
}
const existing = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
if (existing) {
throw new ConflictException('Device already has a certificate — revoke it before issuing a new one');
}
const issued = await this.pki.issueDeviceCert(device.serialNumber);
const record = await this.prisma.deviceCertificate.create({
data: {
deviceId,
serialNumber: device.serialNumber,
commonName: device.serialNumber.toUpperCase(),
certificatePem: issued.certificatePem,
privateKeyPem: issued.privateKeyPem,
fingerprint: issued.fingerprint,
expiresAt: issued.expiresAt,
},
select: CERT_SELECT,
});
return record;
}
async getDeviceCertificate(orgId: string, deviceId: string) {
await this.getDevice(orgId, deviceId);
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId }, select: CERT_SELECT });
if (!cert) {
throw new NotFoundException('Device has no certificate');
}
return cert;
}
async downloadDeviceCert(orgId: string, deviceId: string): Promise<string> {
await this.getDevice(orgId, deviceId);
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
if (!cert) {
throw new NotFoundException('Device has no certificate');
}
return cert.certificatePem;
}
async downloadDeviceKey(orgId: string, deviceId: string): Promise<string> {
await this.getDevice(orgId, deviceId);
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
if (!cert) {
throw new NotFoundException('Device has no certificate');
}
return cert.privateKeyPem;
}
async revokeDeviceCertificate(orgId: string, deviceId: string) {
await this.getDevice(orgId, deviceId);
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId } });
if (!cert) {
throw new NotFoundException('Device has no certificate');
}
// Hard delete: this is DB bookkeeping only, not broker-enforced (no
// CRL/OCSP) — the device's existing cert still authenticates until it
// expires. See README known gaps.
await this.prisma.deviceCertificate.delete({ where: { deviceId } });
return { ok: true };
}
private async getDevice(orgId: string, deviceId: string) {
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
if (!device) {
throw new NotFoundException('Device not found');
}
return device;
}
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class ProvisionMqttCertDto {
@ApiProperty({ example: 'mqtt.example.com', description: 'Hostname/CN for the broker server certificate' })
@IsString()
@IsNotEmpty()
@Matches(/^[a-zA-Z0-9.-]{1,253}$/, {
message: 'hostname must be 1-253 chars of letters, digits, dot, or dash',
})
hostname: string;
}

View File

@@ -0,0 +1,27 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Principal } from '../../auth/principal';
// The CA/broker-cert routes are global (not nested under /orgs/:orgId/...),
// so OrgRolesGuard doesn't apply — it hard-requires an :orgId route param.
// Gate: any authenticated user with an ORG_ADMIN membership in *some* org.
@Injectable()
export class RequireAnyOrgAdminGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const principal: Principal | undefined = req.user;
if (!principal || principal.type !== 'user') {
throw new ForbiddenException('Organization admin required');
}
const membership = await this.prisma.orgMembership.findFirst({
where: { userId: principal.userId, role: 'ORG_ADMIN' },
});
if (!membership) {
throw new ForbiddenException('Requires ORG_ADMIN in at least one organization');
}
return true;
}
}

View File

@@ -0,0 +1,215 @@
import { execFile } from 'child_process';
import { existsSync } from 'fs';
import { chmod, mkdir, mkdtemp, readFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import { BadRequestException, ConflictException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
const execFileAsync = promisify(execFile);
const CA_KEY = 'ca.key';
const CA_CERT = 'ca.crt';
const SERVER_KEY = 'server.key';
const SERVER_CERT = 'server.crt';
const CA_DAYS = 3650;
const DEVICE_CERT_DAYS = 3650;
// CN charsets are validated here (not just at the DTO boundary) because they
// are interpolated into openssl's "-subj" string, where a stray "/" or "="
// could inject extra subject fields (e.g. "/CN=x/OU=admin") even though
// execFile's argv-array form already rules out shell injection.
const HOSTNAME_PATTERN = /^[a-zA-Z0-9.-]{1,253}$/;
const SERIAL_PATTERN = /^[A-Z0-9-]{1,64}$/;
export interface CaStatus {
initialized: boolean;
fingerprint?: string;
expiresAt?: Date;
}
export interface IssuedCert {
certificatePem: string;
privateKeyPem: string;
fingerprint: string;
expiresAt: Date;
}
// Acts as the root CA for MQTT device client certificates: shells out to the
// openssl CLI (added to backend/Dockerfile via apk) rather than a JS crypto
// library. The CA/server key pair live only on disk (bind-mounted into the
// mosquitto container at /mosquitto/certs) and are never written to the DB.
@Injectable()
export class PkiService {
private readonly certsDir = process.env.MQTT_CERTS_DIR || '/mosquitto-certs';
private path(name: string): string {
return join(this.certsDir, name);
}
private caExists(): boolean {
return existsSync(this.path(CA_KEY)) && existsSync(this.path(CA_CERT));
}
async caStatus(): Promise<CaStatus> {
if (!this.caExists()) {
return { initialized: false };
}
const [fingerprint, expiresAt] = await Promise.all([
this.fingerprintOf(this.path(CA_CERT)),
this.expiryOf(this.path(CA_CERT)),
]);
return { initialized: true, fingerprint, expiresAt };
}
async initCa(): Promise<void> {
if (this.caExists()) {
throw new ConflictException('CA already initialized');
}
await mkdir(this.certsDir, { recursive: true });
await this.run([
'req',
'-x509',
'-newkey',
'rsa:4096',
'-sha256',
'-days',
String(CA_DAYS),
'-nodes',
'-keyout',
this.path(CA_KEY),
'-out',
this.path(CA_CERT),
'-subj',
'/CN=UlHub Device CA',
]);
}
async provisionServerCert(hostname: string): Promise<void> {
if (!this.caExists()) {
throw new NotFoundException('Initialize the CA before provisioning a broker certificate');
}
if (!HOSTNAME_PATTERN.test(hostname)) {
throw new BadRequestException('Invalid hostname');
}
const dir = await mkdtemp(join(tmpdir(), 'ulhub-mqtt-server-'));
try {
const csrPath = join(dir, 'server.csr');
await this.run([
'req',
'-new',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
this.path(SERVER_KEY),
'-out',
csrPath,
'-subj',
`/CN=${hostname}`,
]);
await this.run([
'x509',
'-req',
'-in',
csrPath,
'-CA',
this.path(CA_CERT),
'-CAkey',
this.path(CA_KEY),
'-CAcreateserial',
'-out',
this.path(SERVER_CERT),
'-days',
String(CA_DAYS),
'-sha256',
]);
// Mosquitto runs as its own non-root "mosquitto" user in-container;
// openssl writes -keyout as 0600 (owner-only), which that user can't
// read. The CA key itself stays 0600 (only this service ever reads
// it) — only the broker's own key needs to be world-readable, mirroring
// the reference implementation's documented reasoning exactly.
await chmod(this.path(SERVER_KEY), 0o644);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async caCertPem(): Promise<string> {
if (!this.caExists()) {
throw new NotFoundException('CA not initialized');
}
return readFile(this.path(CA_CERT), 'utf8');
}
async issueDeviceCert(serialNumber: string): Promise<IssuedCert> {
if (!this.caExists()) {
throw new NotFoundException('Initialize the CA before issuing device certificates');
}
const cn = serialNumber.toUpperCase();
if (!SERIAL_PATTERN.test(cn)) {
throw new BadRequestException(
'Device serial number must be 1-64 chars of A-Z, 0-9, or hyphen to be used as a certificate CN',
);
}
const dir = await mkdtemp(join(tmpdir(), 'ulhub-device-cert-'));
try {
const keyPath = join(dir, 'device.key');
const csrPath = join(dir, 'device.csr');
const certPath = join(dir, 'device.crt');
await this.run(['req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]);
await this.run([
'x509',
'-req',
'-in',
csrPath,
'-CA',
this.path(CA_CERT),
'-CAkey',
this.path(CA_KEY),
'-CAcreateserial',
'-out',
certPath,
'-days',
String(DEVICE_CERT_DAYS),
'-sha256',
]);
const [certificatePem, privateKeyPem, fingerprint, expiresAt] = await Promise.all([
readFile(certPath, 'utf8'),
readFile(keyPath, 'utf8'),
this.fingerprintOf(certPath),
this.expiryOf(certPath),
]);
return { certificatePem, privateKeyPem, fingerprint, expiresAt };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
private async fingerprintOf(certPath: string): Promise<string> {
const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']);
const match = stdout.match(/Fingerprint=([0-9A-Fa-f:]+)/);
return match ? match[1] : stdout.trim();
}
private async expiryOf(certPath: string): Promise<Date> {
const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-enddate']);
const match = stdout.match(/notAfter=(.+)/);
if (!match) {
throw new InternalServerErrorException('Could not read certificate expiry');
}
return new Date(match[1].trim());
}
private async run(args: string[]): Promise<{ stdout: string; stderr: string }> {
try {
return await execFileAsync('openssl', args);
} catch (err: any) {
throw new InternalServerErrorException(`openssl ${args[0]} failed: ${err.stderr || err.message}`);
}
}
}

View File

@@ -1,10 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { DeviceStatusService } from './device-status.service';
// Public and unauthenticated by design: a field device checks in by serial
// number alone (the same trust model already used for devices/<serial>/log
// MQTT ingestion) before any human has logged it into an org. The response
// only ever reveals a boolean + a short admin-written reason string.
@ApiTags('device-status (public)')
@Controller('devices')
export class DeviceStatusController {
constructor(private readonly deviceStatusService: DeviceStatusService) {}

View File

@@ -0,0 +1,27 @@
// Most recent known position for a device, from whichever source is newest —
// a live "status" ping (never persisted as a LocatePoint) or a persisted
// "log" point. Stored as JSON on Device.lastPosition.
export interface DevicePositionSnapshot {
lat: number;
lng: number;
altitude: number | null;
utilityType: string;
fixType: string;
hAccuracy: number | null;
vAccuracy: number | null;
satellites: number | null;
hdop: number | null;
depth: number | null;
frequencyHz: number | null;
currentMa: number | null;
signalDb: number | null;
gainDb: number | null;
locateMode: string | null;
phaseDeg: number | null;
compassDeg: number | null;
distortionPct: number | null;
recordedAt: string;
jobId: string;
jobTicketNumber: string;
jobTitle: string;
}

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequireScopes } from '../auth/decorators/scopes.decorator';
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
@@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard';
import { DevicesService } from './devices.service';
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
@ApiTags('devices')
@ApiBearerAuth('jwt')
@ApiSecurity('apiKey')
@Controller('orgs/:orgId/devices')
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
export class DevicesController {
@@ -25,6 +29,12 @@ export class DevicesController {
return this.devicesService.create(orgId, dto);
}
@Get(':deviceId/location')
@RequireScopes('devices:read')
getLocation(@Param('orgId') orgId: string, @Param('deviceId') deviceId: string) {
return this.devicesService.getLocation(orgId, deviceId);
}
@Patch(':deviceId')
@Roles('ORG_ADMIN')
update(

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { RealtimeModule } from '../realtime/realtime.module';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
@Module({
imports: [RealtimeModule],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],

View File

@@ -1,10 +1,16 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { toPointDto } from '../points/points.service';
import { RealtimeService } from '../realtime/realtime.service';
import { DevicePositionSnapshot } from './device-position';
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
@Injectable()
export class DevicesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly realtime: RealtimeService,
) {}
list(orgId: string) {
return this.prisma.device.findMany({
@@ -55,7 +61,7 @@ export class DevicesService {
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
await this.get(orgId, deviceId);
const { disabledReason, ...rest } = dto;
return this.prisma.device.update({
const device = await this.prisma.device.update({
where: { id: deviceId },
data: {
...rest,
@@ -64,6 +70,14 @@ export class DevicesService {
...(dto.isActive === false && { disabledReason: disabledReason ?? null }),
},
});
this.realtime.publish(`org:${orgId}:devices`, {
type: 'device',
orgId,
deviceId,
isActive: device.isActive,
disabledReason: device.disabledReason,
});
return device;
}
async remove(orgId: string, deviceId: string) {
@@ -72,6 +86,54 @@ export class DevicesService {
return { ok: true };
}
async getLocation(orgId: string, deviceId: string) {
const device = await this.get(orgId, deviceId);
const point = await this.prisma.locatePoint.findFirst({
where: { deviceId },
orderBy: { recordedAt: 'desc' },
include: { job: { select: { id: true, ticketNumber: true, title: true } } },
});
// A live "status" ping is never persisted as a LocatePoint, so the most
// recent position may only exist on the device's lastPosition snapshot —
// compare timestamps and use whichever source is actually newer.
const live = device.lastPosition as unknown as DevicePositionSnapshot | null;
if (live && device.lastPositionAt && (!point || device.lastPositionAt > point.recordedAt)) {
return {
point: {
id: `live-${deviceId}`,
lat: live.lat,
lng: live.lng,
altitude: live.altitude,
utilityType: live.utilityType,
fixType: live.fixType,
sequence: null,
recordedAt: live.recordedAt,
hAccuracy: live.hAccuracy,
vAccuracy: live.vAccuracy,
satellites: live.satellites,
hdop: live.hdop,
depth: live.depth,
frequencyHz: live.frequencyHz,
currentMa: live.currentMa,
signalDb: live.signalDb,
gainDb: live.gainDb,
locateMode: live.locateMode,
phaseDeg: live.phaseDeg,
compassDeg: live.compassDeg,
distortionPct: live.distortionPct,
},
job: { id: live.jobId, ticketNumber: live.jobTicketNumber, title: live.jobTitle },
};
}
if (!point) {
return { point: null, job: null };
}
const { job, ...rest } = point;
return { point: toPointDto(rest), job };
}
private async get(orgId: string, deviceId: string) {
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
if (!device) {

View File

@@ -1,6 +1,8 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class CreateDeviceDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(120)
@@ -8,6 +10,10 @@ export class CreateDeviceDto {
// Broker username (or future TLS cert CN) if this device connects to MQTT
// itself; locators relayed by a gateway need only a serial number.
@ApiPropertyOptional({
description: 'Broker username, only if this device connects to MQTT itself',
pattern: '^[a-zA-Z0-9._-]{3,64}$',
})
@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
@@ -15,6 +21,7 @@ export class CreateDeviceDto {
})
mqttUsername?: string;
@ApiPropertyOptional({ description: 'Locator serial number, globally unique' })
@IsOptional()
@IsString()
@MaxLength(64)
@@ -22,23 +29,27 @@ export class CreateDeviceDto {
}
export class UpdateDeviceDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(120)
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
serialNumber?: string;
@ApiPropertyOptional({ description: 'Set false to remotely disable the device' })
@IsOptional()
@IsBoolean()
isActive?: boolean;
// Only meaningful when disabling (isActive: false); cleared automatically
// on re-enable regardless of what's passed here.
@ApiPropertyOptional({ description: 'Only used when isActive: false; cleared automatically on re-enable' })
@IsOptional()
@IsString()
@MaxLength(500)

View File

@@ -1,4 +1,5 @@
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
@@ -18,98 +19,120 @@ import {
ValidateNested,
} from 'class-validator';
// Field names here are terse (lat/lng/alt/hAcc/...) to keep MQTT payloads
// small; this same shape is reused as the sim tool's REST request body.
export class MqttPointDto {
@ApiProperty({ minimum: -90, maximum: 90 })
@IsNumber()
@Min(-90)
@Max(90)
lat: number;
@ApiProperty({ minimum: -180, maximum: 180 })
@IsNumber()
@Min(-180)
@Max(180)
lng: number;
@ApiPropertyOptional({ description: 'Altitude, meters' })
@IsOptional()
@IsNumber()
alt?: number;
@ApiPropertyOptional({ enum: GpsFixType })
@IsOptional()
@IsEnum(GpsFixType)
fix?: GpsFixType;
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
hAcc?: number;
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
depth?: number;
@ApiPropertyOptional({ enum: UtilityType })
@IsOptional()
@IsEnum(UtilityType)
utility?: UtilityType;
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
@IsOptional()
@IsInt()
seq?: number;
// GPS quality
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
vAcc?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
sats?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
hdop?: number;
// Locator receiver telemetry
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
freqHz?: number;
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
currentMa?: number;
@ApiPropertyOptional({ description: 'Signal strength, dB' })
@IsOptional()
@IsNumber()
signalDb?: number;
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
@IsOptional()
@IsNumber()
gainDb?: number;
@ApiPropertyOptional({ enum: LocateMode })
@IsOptional()
@IsEnum(LocateMode)
mode?: LocateMode;
@ApiPropertyOptional({ description: 'Degrees' })
@IsOptional()
@IsNumber()
phaseDeg?: number;
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(360)
compassDeg?: number;
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
distortionPct?: number;
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
@IsDateString()
ts: string;
}
@@ -149,9 +172,11 @@ export type MqttLogMessageType = (typeof MQTT_LOG_MESSAGE_TYPES)[number];
// what happens to it: "log" persists a LocatePoint; "status" is an ephemeral
// current-position update, broadcast live but never written to the DB.
export class MqttLogMessageDto extends MqttPointDto {
@ApiProperty({ enum: MQTT_LOG_MESSAGE_TYPES, description: '"log" persists a point; "status" is live-only' })
@IsIn(MQTT_LOG_MESSAGE_TYPES)
type: MqttLogMessageType;
@ApiProperty({ description: 'Job this reading belongs to; also supplies the organization' })
@IsString()
@IsNotEmpty()
jobId: string;

View File

@@ -1,12 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { Device } from '@prisma/client';
import { Device, Prisma } from '@prisma/client';
import { DevicePositionSnapshot } from '../devices/device-position';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
@Injectable()
export class LocatorRegistryService {
private readonly logger = new Logger(LocatorRegistryService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly realtime: RealtimeService,
) {}
// Serial numbers are globally unique, so a bare serial identifies a locator
// regardless of which org's data pipeline saw it first. Unknown serials are
@@ -34,4 +39,52 @@ export class LocatorRegistryService {
return raced;
}
}
// Records the freshest known position for a device (whether from a live
// "status" ping or a persisted "log" point) and broadcasts it to anyone
// watching the org's devices page, so it updates without a manual refresh.
async recordPosition(orgId: string, deviceId: string, snapshot: DevicePositionSnapshot) {
const lastSeenAt = new Date();
await this.prisma.device
.update({
where: { id: deviceId },
data: {
lastPosition: snapshot as unknown as Prisma.InputJsonValue,
lastPositionAt: new Date(snapshot.recordedAt),
lastSeenAt,
},
})
.catch(() => undefined);
this.realtime.publish(`org:${orgId}:devices`, {
type: 'device',
orgId,
deviceId,
lastSeenAt: lastSeenAt.toISOString(),
position: {
id: `live-${deviceId}`,
lat: snapshot.lat,
lng: snapshot.lng,
altitude: snapshot.altitude,
utilityType: snapshot.utilityType,
fixType: snapshot.fixType,
sequence: null,
recordedAt: snapshot.recordedAt,
hAccuracy: snapshot.hAccuracy,
vAccuracy: snapshot.vAccuracy,
satellites: snapshot.satellites,
hdop: snapshot.hdop,
depth: snapshot.depth,
frequencyHz: snapshot.frequencyHz,
currentMa: snapshot.currentMa,
signalDb: snapshot.signalDb,
gainDb: snapshot.gainDb,
locateMode: snapshot.locateMode,
phaseDeg: snapshot.phaseDeg,
compassDeg: snapshot.compassDeg,
distortionPct: snapshot.distortionPct,
},
job: { id: snapshot.jobId, ticketNumber: snapshot.jobTicketNumber, title: snapshot.jobTitle },
});
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DevicePositionSnapshot } from '../devices/device-position';
import { toPointDto } from '../points/points.service';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -43,6 +44,32 @@ export class LogIngestService {
return;
}
const snapshot: DevicePositionSnapshot = {
lat: msg.lat,
lng: msg.lng,
altitude: msg.alt ?? null,
fixType: msg.fix ?? 'NONE',
utilityType: msg.utility ?? 'UNKNOWN',
hAccuracy: msg.hAcc ?? null,
vAccuracy: msg.vAcc ?? null,
satellites: msg.sats ?? null,
hdop: msg.hdop ?? null,
depth: msg.depth ?? null,
frequencyHz: msg.freqHz ?? null,
currentMa: msg.currentMa ?? null,
signalDb: msg.signalDb ?? null,
gainDb: msg.gainDb ?? null,
locateMode: msg.mode ?? null,
phaseDeg: msg.phaseDeg ?? null,
compassDeg: msg.compassDeg ?? null,
distortionPct: msg.distortionPct ?? null,
recordedAt: msg.ts,
jobId: job.id,
jobTicketNumber: job.ticketNumber,
jobTitle: job.title,
};
await this.locatorRegistry.recordPosition(job.orgId, locator.id, snapshot);
if (msg.type === 'status') {
this.realtime.publish(`job:${job.id}`, {
type: 'status',

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Device, Job } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DevicePositionSnapshot } from '../devices/device-position';
import { toPointDto } from '../points/points.service';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -72,6 +73,34 @@ export class PointsIngestService {
jobId: job.id,
points: points.map(toPointDto),
});
const latest = msg.points.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
const snapshot: DevicePositionSnapshot = {
lat: latest.lat,
lng: latest.lng,
altitude: latest.alt ?? null,
fixType: latest.fix ?? 'NONE',
utilityType: latest.utility ?? 'UNKNOWN',
hAccuracy: latest.hAcc ?? null,
vAccuracy: latest.vAcc ?? null,
satellites: latest.sats ?? null,
hdop: latest.hdop ?? null,
depth: latest.depth ?? null,
frequencyHz: latest.freqHz ?? null,
currentMa: latest.currentMa ?? null,
signalDb: latest.signalDb ?? null,
gainDb: latest.gainDb ?? null,
locateMode: latest.mode ?? null,
phaseDeg: latest.phaseDeg ?? null,
compassDeg: latest.compassDeg ?? null,
distortionPct: latest.distortionPct ?? null,
recordedAt: latest.ts,
jobId: job.id,
jobTicketNumber: job.ticketNumber,
jobTitle: job.title,
};
await this.locatorRegistry.recordPosition(device.orgId, locator.id, snapshot);
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
}

View File

@@ -1,4 +1,5 @@
import { JobStatus } from '@prisma/client';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDateString,
@@ -13,82 +14,99 @@ import {
} from 'class-validator';
export class CreateJobDto {
@ApiProperty({ example: 'TKT-2026-0001', description: 'Unique within the org' })
@IsString()
@IsNotEmpty()
@MaxLength(64)
ticketNumber: string;
@ApiProperty({ example: 'Gas line locate - Main St' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@ApiPropertyOptional({ example: '100 Main St' })
@IsOptional()
@IsString()
@MaxLength(400)
address?: string;
@ApiPropertyOptional({ enum: JobStatus })
@IsOptional()
@IsEnum(JobStatus)
status?: JobStatus;
@ApiPropertyOptional({ description: 'User id to assign the job to' })
@IsOptional()
@IsString()
assignedToId?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
dueAt?: string;
}
export class UpdateJobDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(200)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(400)
address?: string;
@ApiPropertyOptional({ enum: JobStatus })
@IsOptional()
@IsEnum(JobStatus)
status?: JobStatus;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
assignedToId?: string | null;
@ApiPropertyOptional({ format: 'date-time', nullable: true })
@IsOptional()
@IsDateString()
dueAt?: string | null;
}
export class QueryJobsDto {
@ApiPropertyOptional({ enum: JobStatus })
@IsOptional()
@IsEnum(JobStatus)
status?: JobStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedToId?: string;
@ApiPropertyOptional({ description: 'Search ticket number, title, and address' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 200, default: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -96,6 +114,7 @@ export class QueryJobsDto {
@Max(200)
limit?: number;
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequireScopes } from '../auth/decorators/scopes.decorator';
@@ -9,6 +10,9 @@ import { Principal } from '../auth/principal';
import { CreateJobDto, QueryJobsDto, UpdateJobDto } from './dto/jobs.dto';
import { JobsService } from './jobs.service';
@ApiTags('jobs')
@ApiBearerAuth('jwt')
@ApiSecurity('apiKey')
@Controller('orgs/:orgId/jobs')
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
export class JobsController {

View File

@@ -1,6 +1,7 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WsAdapter } from '@nestjs/platform-ws';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
@@ -10,6 +11,25 @@ async function bootstrap() {
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useWebSocketAdapter(new WsAdapter(app));
const swaggerConfig = new DocumentBuilder()
.setTitle('UlHub API')
.setDescription(
'Utility-locating platform API: organizations, jobs, locate points, devices, and realtime data. ' +
'Click Authorize and supply a Bearer token (from POST /auth/login) or an org-scoped X-API-Key to try requests.',
)
.setVersion('1.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'jwt')
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'apiKey')
.build();
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, swaggerDocument, {
// "Try it out" must not silently ride on the browser's ulhub_token login
// cookie — force it to only use what's explicitly entered via Authorize
// (Bearer token or X-API-Key), same as any other API client.
swaggerOptions: { withCredentials: false },
});
await app.listen(3001, '0.0.0.0');
}

View File

@@ -1,7 +1,9 @@
import { OrgRole } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class UpdateOrgDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(120)
@@ -9,14 +11,17 @@ export class UpdateOrgDto {
}
export class AddMemberDto {
@ApiProperty({ description: 'Must belong to an existing user (they must have registered already)' })
@IsEmail()
email: string;
@ApiProperty({ enum: OrgRole })
@IsEnum(OrgRole)
role: OrgRole;
}
export class UpdateMemberDto {
@ApiProperty({ enum: OrgRole })
@IsEnum(OrgRole)
role: OrgRole;
}

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { CurrentPrincipal } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard, UserOrApiKeyGuard } from '../auth/guards/auth.guard';
@@ -8,12 +9,14 @@ import { UserPrincipal } from '../auth/principal';
import { AddMemberDto, UpdateMemberDto, UpdateOrgDto } from './dto/orgs.dto';
import { OrgsService } from './orgs.service';
@ApiTags('orgs')
@Controller('orgs')
export class OrgsController {
constructor(private readonly orgsService: OrgsService) {}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('jwt')
listMine(@CurrentPrincipal() principal: UserPrincipal) {
return this.orgsService.listForUser(principal.userId);
}
@@ -21,12 +24,15 @@ export class OrgsController {
@Patch(':orgId')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('ORG_ADMIN')
@ApiBearerAuth('jwt')
rename(@Param('orgId') orgId: string, @Body() dto: UpdateOrgDto) {
return this.orgsService.rename(orgId, dto.name);
}
@Get(':orgId/members')
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
@ApiBearerAuth('jwt')
@ApiSecurity('apiKey')
listMembers(@Param('orgId') orgId: string) {
return this.orgsService.listMembers(orgId);
}
@@ -34,6 +40,7 @@ export class OrgsController {
@Post(':orgId/members')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('ORG_ADMIN')
@ApiBearerAuth('jwt')
addMember(@Param('orgId') orgId: string, @Body() dto: AddMemberDto) {
return this.orgsService.addMember(orgId, dto.email, dto.role);
}
@@ -41,6 +48,7 @@ export class OrgsController {
@Patch(':orgId/members/:userId')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('ORG_ADMIN')
@ApiBearerAuth('jwt')
updateMember(
@Param('orgId') orgId: string,
@Param('userId') userId: string,
@@ -52,6 +60,7 @@ export class OrgsController {
@Delete(':orgId/members/:userId')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('ORG_ADMIN')
@ApiBearerAuth('jwt')
removeMember(@Param('orgId') orgId: string, @Param('userId') userId: string) {
return this.orgsService.removeMember(orgId, userId);
}

View File

@@ -1,4 +1,5 @@
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDateString,
@@ -13,116 +14,138 @@ import {
} from 'class-validator';
export class CreatePointDto {
@ApiProperty({ minimum: -90, maximum: 90 })
@IsNumber()
@Min(-90)
@Max(90)
lat: number;
@ApiProperty({ minimum: -180, maximum: 180 })
@IsNumber()
@Min(-180)
@Max(180)
lng: number;
@ApiPropertyOptional({ description: 'Meters' })
@IsOptional()
@IsNumber()
altitude?: number;
@ApiPropertyOptional({ enum: GpsFixType })
@IsOptional()
@IsEnum(GpsFixType)
fixType?: GpsFixType;
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
hAccuracy?: number;
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
depth?: number;
@ApiPropertyOptional({ enum: UtilityType })
@IsOptional()
@IsEnum(UtilityType)
utilityType?: UtilityType;
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
@IsOptional()
@IsInt()
sequence?: number;
// GPS quality
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
vAccuracy?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
satellites?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
hdop?: number;
// Locator receiver telemetry
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
frequencyHz?: number;
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
currentMa?: number;
@ApiPropertyOptional({ description: 'Signal strength, dB' })
@IsOptional()
@IsNumber()
signalDb?: number;
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
@IsOptional()
@IsNumber()
gainDb?: number;
@ApiPropertyOptional({ enum: LocateMode })
@IsOptional()
@IsEnum(LocateMode)
locateMode?: LocateMode;
@ApiPropertyOptional({ description: 'Degrees' })
@IsOptional()
@IsNumber()
phaseDeg?: number;
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(360)
compassDeg?: number;
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
distortionPct?: number;
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
@IsDateString()
recordedAt: string;
}
export class QueryPointsDto {
// recordedAt cursor: return points recorded strictly after this instant
@ApiPropertyOptional({ format: 'date-time', description: 'Return points recorded strictly after this instant' })
@IsOptional()
@IsDateString()
after?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
to?: string;
// minLng,minLat,maxLng,maxLat
@ApiPropertyOptional({ description: 'minLng,minLat,maxLng,maxLat', example: '-96.85,33.14,-96.82,33.16' })
@IsOptional()
@IsString()
@Matches(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/, {
@@ -130,6 +153,7 @@ export class QueryPointsDto {
})
bbox?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 10000, default: 5000 })
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequireScopes } from '../auth/decorators/scopes.decorator';
import { UserOrApiKeyGuard } from '../auth/guards/auth.guard';
@@ -7,6 +8,9 @@ import { ScopesGuard } from '../auth/guards/scopes.guard';
import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
import { PointsService } from './points.service';
@ApiTags('points')
@ApiBearerAuth('jwt')
@ApiSecurity('apiKey')
@Controller('orgs/:orgId')
@UseGuards(UserOrApiKeyGuard, OrgRolesGuard, ScopesGuard)
export class PointsController {

View File

@@ -82,8 +82,8 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
return;
}
const jobId = msg.channel.startsWith('job:') ? msg.channel.slice(4) : null;
if (!jobId || !(await this.canAccessJob(this.users.get(client), jobId))) {
const allowed = await this.canAccessChannel(this.users.get(client), msg.channel);
if (!allowed) {
client.send(JSON.stringify({ type: 'error', reason: `cannot subscribe to ${msg.channel}` }));
return;
}
@@ -92,16 +92,25 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
client.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
}
private async canAccessJob(userId: string | undefined, jobId: string): Promise<boolean> {
private async canAccessChannel(userId: string | undefined, channel: string): Promise<boolean> {
if (!userId) {
return false;
}
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
if (!job) {
return false;
if (channel.startsWith('job:')) {
const jobId = channel.slice(4);
const job = await this.prisma.job.findUnique({ where: { id: jobId }, select: { orgId: true } });
return job ? this.isMember(job.orgId, userId) : false;
}
const devicesMatch = /^org:(.+):devices$/.exec(channel);
if (devicesMatch) {
return this.isMember(devicesMatch[1], userId);
}
return false;
}
private async isMember(orgId: string, userId: string): Promise<boolean> {
const membership = await this.prisma.orgMembership.findUnique({
where: { orgId_userId: { orgId: job.orgId, userId } },
where: { orgId_userId: { orgId, userId } },
});
return membership !== null;
}

View File

@@ -1,12 +1,27 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { MqttLogMessageDto } from '../../ingest/dto/mqtt-messages.dto';
export type SimTransport = 'relay' | 'mqtts';
// What the simulator UI sends the backend: a devices/<serial>/log payload
// (type: "log" persists a point, "status" is a live-only position update)
// plus "serial", which lives in the topic rather than the wire payload.
export class SimPublishPointDto extends MqttLogMessageDto {
@ApiProperty({ description: 'Locator serial number; identifies the device via devices/<serial>/log' })
@IsString()
@IsNotEmpty()
@MaxLength(64)
serial: string;
@ApiPropertyOptional({
description:
'"relay" (default) publishes via the backend\'s own privileged broker connection. ' +
'"mqtts" instead connects to port 8883 and authenticates as this serial\'s own issued ' +
'client certificate, exercising the real device-auth + ACL path.',
enum: ['relay', 'mqtts'],
})
@IsOptional()
@IsIn(['relay', 'mqtts'])
transport?: SimTransport;
}

View File

@@ -0,0 +1,97 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy } from '@nestjs/common';
import { connect, MqttClient } from 'mqtt';
import { PkiService } from '../certificates/pki.service';
import { PrismaService } from '../prisma/prisma.service';
// Publishes as a real field device would: connects to the 8883 TLS listener
// and authenticates with the device's own issued client certificate (CN =
// serial number), rather than going through the backend's own privileged
// broker connection (see MqttClientService). This is what actually exercises
// the mTLS handshake + devices/%u/# ACL scoping end-to-end, so a simulated
// device is only ever as trusted as a real one.
@Injectable()
export class SimMqttsService implements OnModuleDestroy {
private readonly logger = new Logger(SimMqttsService.name);
private readonly clients = new Map<string, Promise<MqttClient>>();
constructor(
private readonly prisma: PrismaService,
private readonly pki: PkiService,
) {}
async publish(orgId: string, serial: string, topic: string, payload: object): Promise<void> {
const client = await this.clientFor(orgId, serial);
await new Promise<void>((resolve, reject) => {
client.publish(topic, JSON.stringify(payload), { qos: 1 }, (err) => (err ? reject(err) : resolve()));
});
}
private clientFor(orgId: string, serial: string): Promise<MqttClient> {
const existing = this.clients.get(serial);
if (existing) {
return existing;
}
const created = this.connectAsDevice(orgId, serial).catch((err) => {
this.clients.delete(serial);
throw err;
});
this.clients.set(serial, created);
return created;
}
private async connectAsDevice(orgId: string, serial: string): Promise<MqttClient> {
const device = await this.prisma.device.findFirst({ where: { orgId, serialNumber: serial } });
if (!device) {
throw new NotFoundException(
`No device with serial "${serial}" in this organization — create one on the Devices page first`,
);
}
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId: device.id } });
if (!cert) {
throw new BadRequestException(
`Device "${serial}" has no certificate issued — issue one from the Devices page, then retry`,
);
}
const ca = await this.pki.caCertPem();
const host = process.env.MQTT_HOST || 'mosquitto';
const port = Number(process.env.MQTT_TLS_PORT || 8883);
// The broker's server cert is issued for whatever hostname an admin chose
// when provisioning it (PkiService.provisionServerCert), which may not
// match this container's docker-network hostname. Overriding just the TLS
// servername lets us verify the chain + name against what it was actually
// issued for, rather than disabling verification.
const servername = process.env.MQTT_TLS_SERVERNAME || 'localhost';
return new Promise<MqttClient>((resolve, reject) => {
const client = connect({
host,
port,
protocol: 'mqtts',
servername,
ca,
cert: cert.certificatePem,
key: cert.privateKeyPem,
rejectUnauthorized: true,
connectTimeout: 8000,
clientId: `ulhub-sim-${serial}-${Math.random().toString(16).slice(2)}`,
});
const onError = (err: Error) => {
client.end(true);
reject(new BadRequestException(`MQTTS connection failed for device "${serial}": ${err.message}`));
};
client.once('error', onError);
client.once('connect', () => {
client.removeListener('error', onError);
client.on('error', (err) => this.logger.warn(`Simulated device ${serial} MQTTS error: ${err.message}`));
resolve(client);
});
});
}
async onModuleDestroy(): Promise<void> {
for (const pending of this.clients.values()) {
await pending.then((client) => client.endAsync()).catch(() => undefined);
}
}
}

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/auth.guard';
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
@@ -6,6 +7,8 @@ import { SimPublishPointDto } from './dto/sim.dto';
import { SimService } from './sim.service';
// JWT-only: this is a UI-driven testing tool, not a public integration surface.
@ApiTags('simulator')
@ApiBearerAuth('jwt')
@Controller('orgs/:orgId/sim')
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@Roles('MEMBER')

View File

@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { CertificatesModule } from '../certificates/certificates.module';
import { IngestModule } from '../ingest/ingest.module';
import { SimMqttsService } from './sim-mqtts.service';
import { SimController } from './sim.controller';
import { SimService } from './sim.service';
@Module({
imports: [IngestModule],
imports: [IngestModule, CertificatesModule],
controllers: [SimController],
providers: [SimService],
providers: [SimService, SimMqttsService],
})
export class SimModule {}

View File

@@ -2,24 +2,35 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { MqttClientService } from '../ingest/mqtt-client.service';
import { PrismaService } from '../prisma/prisma.service';
import { SimPublishPointDto } from './dto/sim.dto';
import { SimMqttsService } from './sim-mqtts.service';
@Injectable()
export class SimService {
constructor(
private readonly prisma: PrismaService,
private readonly mqttClient: MqttClientService,
private readonly simMqtts: SimMqttsService,
) {}
// Publishes onto the real broker rather than writing to the DB directly, so
// the simulator exercises the exact same ingest path a real locator would.
// "relay" (default) reuses the backend's own privileged connection; "mqtts"
// instead opens a real TLS connection authenticated with the device's own
// client certificate, so it's subject to the same mTLS handshake and
// devices/%u/# ACL a real device would be.
async publish(orgId: string, dto: SimPublishPointDto) {
const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } });
if (!job) {
throw new NotFoundException('Job not found in this organization');
}
const { serial, ...payload } = dto;
this.mqttClient.publish(`devices/${serial}/log`, payload);
return { ok: true, topic: `devices/${serial}/log` };
const { serial, transport, ...payload } = dto;
const topic = `devices/${serial}/log`;
if (transport === 'mqtts') {
await this.simMqtts.publish(orgId, serial, topic, payload);
} else {
this.mqttClient.publish(topic, payload);
}
return { ok: true, topic, transport: transport ?? 'relay' };
}
}

View File

@@ -1,5 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('app')
@Controller('status')
export class StatusController {
@Get()