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,5 +1,8 @@
FROM node:20-alpine
# Needed by certificates/pki.service.ts to shell out for CA/device cert generation.
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package.json package-lock.json* ./

View File

@@ -1,4 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
}

View File

@@ -15,6 +15,7 @@
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-ws": "^10.0.0",
"@nestjs/swagger": "^7.4.2",
"@nestjs/websockets": "^10.0.0",
"@prisma/client": "^6.10.0",
"bcryptjs": "^2.4.3",
@@ -408,6 +409,12 @@
"node": ">=8"
}
},
"node_modules/@microsoft/tsdoc": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT"
},
"node_modules/@nestjs/cli": {
"version": "10.4.9",
"dev": true,
@@ -541,6 +548,26 @@
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/mapped-types": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz",
"integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"class-transformer": "^0.4.0 || ^0.5.0",
"class-validator": "^0.13.0 || ^0.14.0",
"reflect-metadata": "^0.1.12 || ^0.2.0"
},
"peerDependenciesMeta": {
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
}
}
},
"node_modules/@nestjs/passport": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz",
@@ -698,6 +725,57 @@
"dev": true,
"license": "MIT"
},
"node_modules/@nestjs/swagger": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz",
"integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "^0.15.0",
"@nestjs/mapped-types": "2.0.5",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "3.3.0",
"swagger-ui-dist": "5.17.14"
},
"peerDependencies": {
"@fastify/static": "^6.0.0 || ^7.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0",
"class-transformer": "*",
"class-validator": "*",
"reflect-metadata": "^0.1.12 || ^0.2.0"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
},
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
}
}
},
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@nestjs/swagger/node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/@nestjs/testing": {
"version": "10.4.22",
"dev": true,
@@ -1450,7 +1528,6 @@
},
"node_modules/argparse": {
"version": "2.0.1",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-flatten": {
@@ -4815,6 +4892,12 @@
"node": ">=8"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.17.14",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",
"integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==",
"license": "Apache-2.0"
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"dev": true,

View File

@@ -20,6 +20,7 @@
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-ws": "^10.0.0",
"@nestjs/swagger": "^7.4.2",
"@nestjs/websockets": "^10.0.0",
"@prisma/client": "^6.10.0",
"bcryptjs": "^2.4.3",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "devices" ADD COLUMN "lastPosition" JSONB,
ADD COLUMN "lastPositionAt" TIMESTAMPTZ(6);

View File

@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "device_certificates" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"serialNumber" TEXT NOT NULL,
"commonName" TEXT NOT NULL,
"certificatePem" TEXT NOT NULL,
"privateKeyPem" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"issuedAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "device_certificates_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "device_certificates_deviceId_key" ON "device_certificates"("deviceId");
-- AddForeignKey
ALTER TABLE "device_certificates" ADD CONSTRAINT "device_certificates_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "devices"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -142,15 +142,45 @@ model Device {
// fetches this via GET /api/devices/:serial/status to show on its own screen.
disabledReason String?
lastSeenAt DateTime? @db.Timestamptz(6)
// Most recent known position, from either a "status" ping (live-only, never
// persisted as a LocatePoint) or a "log" point — whichever is newest. Lets
// the devices page show current position without waiting for a log write.
lastPosition Json?
lastPositionAt DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
points LocatePoint[]
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
points LocatePoint[]
certificate DeviceCertificate?
@@map("devices")
}
// A client certificate issued to a device for mTLS auth on the broker's 8883
// listener; the cert's CN (= serialNumber) becomes the MQTT username. The CA
// key/server key never touch the DB (see PkiService) — only device
// certs/keys are stored here, mirroring how the reference implementation
// (MQTT_DEVICE_AUTH.md) does it. One active cert per device; issuing a new
// one requires deleting this row first. No revokedAt: deleting the row is
// the only "revoke" action there is, and it isn't broker-enforced either way
// (no CRL/OCSP), so a soft-delete flag would misleadingly imply otherwise.
model DeviceCertificate {
id String @id @default(cuid())
deviceId String @unique
serialNumber String
commonName String
certificatePem String @db.Text
privateKeyPem String @db.Text
fingerprint String
issuedAt DateTime @default(now()) @db.Timestamptz(6)
expiresAt DateTime @db.Timestamptz(6)
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@map("device_certificates")
}
model LocatePoint {
id BigInt @id @default(autoincrement())
jobId String

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()