This commit is contained in:
2026-06-25 12:05:53 -05:00
commit b58e50e423
141 changed files with 28190 additions and 0 deletions

57
server/auth.js Normal file
View File

@@ -0,0 +1,57 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { db } from './db.js';
import { config } from './config.js';
export function signUser(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
config.jwtSecret,
{ expiresIn: '12h' }
);
}
export function publicUser(row) {
if (!row) return null;
return {
id: row.id,
email: row.email,
displayName: row.display_name,
authProvider: row.auth_provider,
role: row.role,
theme: row.theme || 'dashtreme',
jobTitle: row.job_title || '',
phone: row.phone || '',
timezone: row.timezone || '',
avatarUrl: row.avatar_url || '',
updatedAt: row.updated_at || '',
createdAt: row.created_at
};
}
export function requireAuth(req, res, next) {
const header = req.get('authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
const payload = jwt.verify(token, config.jwtSecret);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(payload.sub);
if (!user) return res.status(401).json({ error: 'Invalid session' });
req.user = publicUser(user);
next();
} catch {
res.status(401).json({ error: 'Invalid session' });
}
}
export function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admin role required' });
next();
}
export function authenticateLocal(email, password) {
const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local');
if (!user?.password_hash) return null;
if (!bcrypt.compareSync(password, user.password_hash)) return null;
return user;
}

51
server/config.js Normal file
View File

@@ -0,0 +1,51 @@
import dotenv from 'dotenv';
import path from 'node:path';
dotenv.config({ quiet: true });
const rootDir = process.cwd();
const defaultClientOrigins = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:5173',
'http://127.0.0.1:5173'
];
const configuredClientOrigins = (process.env.CLIENT_ORIGINS || process.env.CLIENT_ORIGIN || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
export const config = {
nodeEnv: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
port: Number(process.env.PORT || 5174),
serverFqdn: process.env.SERVER_FQDN || 'http://localhost:5174',
clientOrigin: process.env.CLIENT_ORIGIN || 'http://localhost:5173',
clientOrigins: [...new Set([...configuredClientOrigins, ...defaultClientOrigins])],
databasePath: path.resolve(rootDir, process.env.DATABASE_PATH || './data/poshmanager.sqlite'),
logDir: path.resolve(rootDir, process.env.LOG_DIR || './data/logs'),
fileLockerDir: path.resolve(rootDir, process.env.FILE_LOCKER_DIR || process.env.ASSET_DIR || './FileLocker'),
// Location for bundled tools such as the Intune Win32 Content Prep Tool.
// Drop IntuneWinAppUtil.exe in ./tools (or point INTUNEWIN_UTIL_PATH elsewhere).
toolsDir: path.resolve(rootDir, process.env.TOOLS_DIR || './tools'),
intunewinUtilPath: process.env.INTUNEWIN_UTIL_PATH || '',
// Optional cross-platform packager hook. Template with {source} {setup} {output}.
intunewinBuildCommand: process.env.INTUNEWIN_BUILD_COMMAND || '',
maxAssetBytes: Number(process.env.MAX_ASSET_BYTES || 52428800),
jsonLimit: process.env.JSON_LIMIT || '60mb',
jwtSecret: process.env.JWT_SECRET || 'dev-only-change-me',
credentialStoreKey: process.env.CREDENTIAL_STORE_KEY || process.env.JWT_SECRET || 'dev-only-credential-key',
defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL || 'admin@posh.local',
defaultAdminPassword: process.env.DEFAULT_ADMIN_PASSWORD || 'change-me-now',
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
entra: {
enabled: (process.env.ENTRA_ENABLED || 'false').toLowerCase() === 'true',
tenantId: process.env.ENTRA_TENANT_ID || '',
clientId: process.env.ENTRA_CLIENT_ID || '',
clientSecret: process.env.ENTRA_CLIENT_SECRET || '',
callbackUrl: process.env.ENTRA_CALLBACK_URL || '',
passwordChangeUrl: process.env.ENTRA_PASSWORD_CHANGE_URL || 'https://mysignins.microsoft.com/security-info/password/change'
}
};

View File

@@ -0,0 +1,94 @@
import { applicationImportSchema, applicationSchema } from '../forms/schemas.js';
import {
createApplication,
deleteApplication,
listApplications,
loadApplication,
recordVersionCheck,
updateApplication
} from '../models/applicationModel.js';
import { getGraphConnection } from '../models/graphModel.js';
import { getGraphMobileApp } from '../services/graphService.js';
import { fetchLatestVersion, mapGraphAppToCatalog } from '../services/appVersionService.js';
import { runCatalogChecks } from '../services/catalogWorker.js';
export function index(req, res) {
res.json(listApplications(req.user.id));
}
export function create(req, res) {
const parsed = applicationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid application payload' });
res.status(201).json(createApplication(parsed.data, req.user.id));
}
export function show(req, res) {
const application = loadApplication(req.params.id, req.user.id);
if (!application) return res.status(404).json({ error: 'Application not found' });
res.json(application);
}
export function update(req, res) {
const parsed = applicationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid application payload' });
const application = updateApplication(req.params.id, parsed.data, req.user.id);
if (!application) return res.status(404).json({ error: 'Application not found' });
res.json(application);
}
export function destroy(req, res) {
deleteApplication(req.params.id, req.user.id);
res.status(204).end();
}
// Look up the upstream version from the configured source and store it. The
// update state (whether a newer version exists) is recomputed on read.
export async function checkUpdate(req, res) {
const application = loadApplication(req.params.id, req.user.id);
if (!application) return res.status(404).json({ error: 'Application not found' });
try {
const result = await fetchLatestVersion(application);
const updated = recordVersionCheck(req.params.id, req.user.id, {
latestKnownVersion: result.version || application.latestKnownVersion,
message: result.message || ''
});
res.json({ application: updated, check: result });
} catch (error) {
recordVersionCheck(req.params.id, req.user.id, { latestKnownVersion: application.latestKnownVersion, message: error.message });
res.status(400).json({ error: error.message });
}
}
// Run an upstream check for every auto-check application now (admin trigger).
export async function checkAll(req, res) {
try {
const summary = await runCatalogChecks();
res.json({ summary, applications: listApplications(req.user.id) });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
// Adopt an existing tenant Win32 app into the catalog (migration aid). Read-only
// Graph access; no write-back gate required.
export async function importFromTenant(req, res) {
const parsed = applicationImportSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'connectionId and graphAppId are required' });
const connection = getGraphConnection(parsed.data.connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
try {
const graphApp = await getGraphMobileApp(connection, parsed.data.graphAppId);
const mapped = mapGraphAppToCatalog(graphApp);
const application = createApplication({
...mapped,
graphConnectionId: parsed.data.connectionId,
versionSource: 'manual',
visibility: parsed.data.visibility,
groupId: parsed.data.groupId,
notes: `Adopted from Intune mobileApp ${parsed.data.graphAppId}.`
}, req.user.id);
res.status(201).json(application);
} catch (error) {
res.status(400).json({ error: error.message });
}
}

View File

@@ -0,0 +1,115 @@
import {
assetFolderSchema,
assetLinkSchema,
assetUpdateSchema,
assetUploadSchema
} from '../forms/schemas.js';
import fs from 'node:fs';
import {
createAssetFolder,
createAssetFromUpload,
createAssetLink,
deleteAsset,
deleteAssetFolder,
deleteAssetLink,
getAsset,
getAssetFile,
listAssetFolders,
listAssetLinks,
listAssets,
updateAsset,
updateAssetFolder
} from '../models/assetModel.js';
export function assetFolders(req, res) {
res.json(listAssetFolders(req.user.id));
}
export function createAssetFolderAction(req, res) {
const parsed = assetFolderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset folder payload' });
res.status(201).json(createAssetFolder(parsed.data, req.user.id));
}
export function updateAssetFolderAction(req, res) {
const parsed = assetFolderSchema.partial().safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset folder payload' });
const folder = updateAssetFolder(req.params.id, parsed.data, req.user.id);
if (!folder) return res.status(404).json({ error: 'Asset folder not found' });
res.json(folder);
}
export function deleteAssetFolderAction(req, res) {
deleteAssetFolder(req.params.id, req.user.id);
res.status(204).end();
}
export function assets(req, res) {
res.json(listAssets(req.user.id));
}
export function createAssetAction(req, res) {
const parsed = assetUploadSchema.safeParse(req.body);
if (!parsed.success) {
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
return res.status(400).json({ error: 'Invalid asset upload payload' });
}
try {
res.status(201).json(createAssetFromUpload(req.file, parsed.data, req.user.id));
} catch (error) {
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
res.status(400).json({ error: error.message || 'Asset upload failed' });
}
}
export function getAssetAction(req, res) {
const asset = getAsset(req.params.id, req.user.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
res.json(asset);
}
export function updateAssetAction(req, res) {
const parsed = assetUpdateSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset payload' });
const asset = updateAsset(req.params.id, parsed.data, req.user.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
res.json(asset);
}
export function deleteAssetAction(req, res) {
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
res.status(204).end();
}
export function viewAssetAction(req, res) {
const result = getAssetFile(req.params.id, req.user.id);
if (!result) return res.status(404).json({ error: 'Asset not found' });
res.type(result.asset.mimeType || 'application/octet-stream');
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.asset.originalName)}"`);
res.sendFile(result.filePath);
}
export function downloadAssetAction(req, res) {
const result = getAssetFile(req.params.id, req.user.id);
if (!result) return res.status(404).json({ error: 'Asset not found' });
res.download(result.filePath, result.asset.originalName);
}
export function assetLinks(req, res) {
const links = listAssetLinks(req.params.id, req.user.id);
if (!links) return res.status(404).json({ error: 'Asset not found' });
res.json(links);
}
export function createAssetLinkAction(req, res) {
const parsed = assetLinkSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset link payload' });
const link = createAssetLink(req.params.id, parsed.data, req.user.id);
if (!link) return res.status(404).json({ error: 'Asset not found' });
res.status(201).json(link);
}
export function deleteAssetLinkAction(req, res) {
if (!deleteAssetLink(req.params.linkId, req.user.id)) return res.status(404).json({ error: 'Asset link not found' });
res.status(204).end();
}

View File

@@ -0,0 +1,81 @@
import { authenticateLocal, publicUser, signUser } from '../auth.js';
import { loginSchema, passwordChangeSchema, profileSchema, themeSchema } from '../forms/schemas.js';
import { changePassword, updateProfile, updateTheme } from '../models/profileModel.js';
import { findOrProvisionEntraUser } from '../models/userModel.js';
import {
buildAuthorizeUrl,
exchangeCodeForToken,
fetchEntraProfile,
isEntraLoginConfigured,
signState,
verifyState
} from '../services/entraService.js';
import { getSetting } from '../models/settingsModel.js';
import { config } from '../config.js';
import { logger } from '../services/logger.js';
export function login(req, res) {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Valid email and password are required' });
const user = authenticateLocal(parsed.data.email, parsed.data.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
res.json({ token: signUser(user), user: publicUser(user) });
}
export function me(req, res) {
res.json({ user: req.user });
}
// Step 1: redirect the browser to the Microsoft sign-in page. 404 when Entra
// login is not fully configured so the client cleanly falls back to local auth.
export function entraLogin(req, res) {
if (!isEntraLoginConfigured()) return res.status(404).json({ error: 'Entra login is not enabled' });
const state = signState({ nonce: Date.now() });
return res.redirect(buildAuthorizeUrl(state));
}
// Step 2: Microsoft redirects back here with an authorization code. We validate
// the signed state, exchange the code, provision the user, and hand the SPA a
// POSHManager JWT via the app URL.
export async function entraCallback(req, res) {
const appBase = (getSetting('server_fqdn') || config.serverFqdn || '').replace(/\/+$/, '');
if (!isEntraLoginConfigured()) return res.status(404).json({ error: 'Entra login is not enabled' });
try {
if (req.query.error) throw new Error(String(req.query.error_description || req.query.error));
verifyState(String(req.query.state || ''));
const tokens = await exchangeCodeForToken(String(req.query.code || ''));
const profile = await fetchEntraProfile(tokens.access_token);
const user = findOrProvisionEntraUser(profile);
const token = signUser(user);
return res.redirect(`${appBase}/?entraToken=${encodeURIComponent(token)}`);
} catch (error) {
logger.error('entra callback failed', { error: error.message });
return res.redirect(`${appBase}/?entraError=${encodeURIComponent(error.message)}`);
}
}
export function profile(req, res) {
const parsed = profileSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Valid profile information is required' });
try {
res.json({ user: updateProfile(req.user.id, parsed.data) });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function theme(req, res) {
const parsed = themeSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Valid theme id is required' });
res.json({ user: updateTheme(req.user.id, parsed.data.theme) });
}
export function password(req, res) {
const parsed = passwordChangeSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'New password must be at least 8 characters' });
try {
res.json(changePassword(req.user.id, parsed.data));
} catch (error) {
res.status(400).json({ error: error.message });
}
}

View File

@@ -0,0 +1,17 @@
import { config } from '../config.js';
import { getBootstrap } from '../models/bootstrapModel.js';
import { getSetting } from '../models/settingsModel.js';
import { isEntraLoginConfigured } from '../services/entraService.js';
export function health(req, res) {
res.json({
ok: true,
nodeEnv: config.nodeEnv,
serverFqdn: getSetting('server_fqdn') || config.serverFqdn,
entraLoginEnabled: isEntraLoginConfigured()
});
}
export function bootstrap(req, res) {
res.json(getBootstrap(req.user.id));
}

View File

@@ -0,0 +1,23 @@
import { credentialSchema } from '../forms/schemas.js';
import { createCredential, deleteCredential, listCredentials, updateCredential } from '../models/credentialModel.js';
export function index(req, res) {
res.json(listCredentials(req.user.id));
}
export function create(req, res) {
const parsed = credentialSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid credential payload' });
res.status(201).json(createCredential(parsed.data, req.user.id));
}
export function update(req, res) {
const credential = updateCredential(req.params.id, req.body, req.user.id);
if (!credential) return res.status(404).json({ error: 'Credential not found' });
res.json(credential);
}
export function destroy(req, res) {
deleteCredential(req.params.id, req.user.id);
res.status(204).end();
}

View File

@@ -0,0 +1,60 @@
import { decideChangeRequest, getChangeRequest, listChangeRequests } from '../models/changeRequestModel.js';
import { getConnectionGovernance, intuneReportingOverview, listGraphAudit } from '../models/graphModel.js';
import { loadIntuneDeployment } from '../models/psadtModel.js';
import { canApprove } from '../services/graphRbac.js';
export function changeRequests(req, res) {
res.json(listChangeRequests(req.user.id, { status: req.query.status, isAdmin: req.user.role === 'admin' }));
}
// Admins can always decide; otherwise the user must be a named approver on the
// request's Graph connection.
function authorizedApprover(req, request) {
if (req.user.role === 'admin') return true;
const governance = request.connectionId ? getConnectionGovernance(request.connectionId) : null;
return canApprove({ approverIds: governance?.approverIds || [] }, req.user);
}
function decide(req, res, status) {
const request = getChangeRequest(req.params.id);
if (!request || request.status !== 'pending') return res.status(404).json({ error: 'Pending change request not found' });
if (!authorizedApprover(req, request)) return res.status(403).json({ error: 'You are not an authorized approver for this connection.' });
const decided = decideChangeRequest(req.params.id, {
status,
approverUserId: req.user.id,
approverEmail: req.user.email,
reason: req.body?.reason || ''
});
if (!decided) return res.status(404).json({ error: 'Pending change request not found' });
res.json(decided);
}
export function approveChangeRequest(req, res) {
decide(req, res, 'approved');
}
export function rejectChangeRequest(req, res) {
decide(req, res, 'rejected');
}
export function reportingOverview(req, res) {
res.json(intuneReportingOverview(req.user.id, req.user.role === 'admin'));
}
function toCsv(rows) {
const headers = ['createdAt', 'action', 'status', 'actorEmail', 'targetGraphId', 'message'];
const escape = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = [headers.join(',')];
for (const row of rows) lines.push(headers.map((h) => escape(row[h])).join(','));
return lines.join('\n');
}
// CSV export of the tenant write-back audit trail for one deployment.
export function auditExport(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const rows = listGraphAudit({ deploymentId: deployment.id, limit: 1000 });
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="audit-${deployment.id}.csv"`);
res.send(toCsv(rows));
}

View File

@@ -0,0 +1,65 @@
import { graphConnectionSchema } from '../forms/schemas.js';
import { createGraphConnection, deleteGraphConnection, getGraphConnection, listGraphConnections, recordGraphConnectionTest, updateGraphConnection } from '../models/graphModel.js';
import { listGraphMobileApps, testGraphConnection } from '../services/graphService.js';
export function index(req, res) {
res.json(listGraphConnections(req.user.id));
}
// Governance flags (tenant write-back, approval requirement) are administrative.
// On create, non-admins always get read-only. On update, non-admins keep the
// existing flag values so they can never grant or silently downgrade them.
export function create(req, res) {
const parsed = graphConnectionSchema.safeParse(req.body);
if (!parsed.success || !parsed.data.clientSecret) return res.status(400).json({ error: 'Valid Graph connection payload with client secret is required' });
if (req.user.role !== 'admin') {
parsed.data.allowWrite = false;
parsed.data.requireApproval = false;
parsed.data.publisherIds = [];
parsed.data.approverIds = [];
}
res.status(201).json(createGraphConnection(parsed.data, req.user.id));
}
export function update(req, res) {
const parsed = graphConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph connection payload' });
if (req.user.role !== 'admin') {
const existing = getGraphConnection(req.params.id, req.user.id);
parsed.data.allowWrite = Boolean(existing?.allowWrite);
parsed.data.requireApproval = Boolean(existing?.requireApproval);
parsed.data.publisherIds = existing?.publisherIds || [];
parsed.data.approverIds = existing?.approverIds || [];
}
const connection = updateGraphConnection(req.params.id, parsed.data, req.user.id);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
res.json(connection);
}
export function destroy(req, res) {
deleteGraphConnection(req.params.id, req.user.id);
res.status(204).end();
}
export async function test(req, res) {
const connection = getGraphConnection(req.params.id, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
try {
const result = await testGraphConnection(connection);
recordGraphConnectionTest(req.params.id, 'success', result.message);
res.json(result);
} catch (error) {
recordGraphConnectionTest(req.params.id, 'failed', error.message);
res.status(400).json({ ok: false, error: error.message });
}
}
export async function mobileApps(req, res) {
const connection = getGraphConnection(req.params.id, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
try {
res.json(await listGraphMobileApps(connection, String(req.query.q || '')));
} catch (error) {
res.status(400).json({ error: error.message });
}
}

View File

@@ -0,0 +1,12 @@
import { groupSchema } from '../forms/schemas.js';
import { createGroup, listGroups } from '../models/groupModel.js';
export function index(req, res) {
res.json(listGroups());
}
export function create(req, res) {
const parsed = groupSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid group payload' });
res.status(201).json(createGroup(parsed.data));
}

View File

@@ -0,0 +1,14 @@
import { getHelpArticle, listHelp } from '../models/helpModel.js';
export function index(req, res) {
res.json(listHelp({
query: String(req.query.q || ''),
category: String(req.query.category || '')
}));
}
export function show(req, res) {
const article = getHelpArticle(req.params.id);
if (!article) return res.status(404).json({ error: 'Help article not found' });
res.json(article);
}

View File

@@ -0,0 +1,23 @@
import { hostSchema } from '../forms/schemas.js';
import { createHost, deleteHost, listHosts, updateHost } from '../models/hostModel.js';
export function index(req, res) {
res.json(listHosts(req.user.id));
}
export function create(req, res) {
const parsed = hostSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid host payload' });
res.status(201).json(createHost(parsed.data, req.user.id));
}
export function update(req, res) {
const host = updateHost(req.params.id, req.body, req.user.id);
if (!host) return res.status(404).json({ error: 'Host not found' });
res.json(host);
}
export function destroy(req, res) {
deleteHost(req.params.id, req.user.id);
res.status(204).end();
}

View File

@@ -0,0 +1,11 @@
import { getJob, listJobs } from '../models/jobModel.js';
export function index(req, res) {
res.json(listJobs(req.user.id, req.user.role === 'admin'));
}
export function show(req, res) {
const job = getJob(req.params.id, req.user.id, req.user.role === 'admin');
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json(job);
}

View File

@@ -0,0 +1,101 @@
import { folderSchema, scriptTestRunSchema } from '../forms/schemas.js';
import {
cloneScript,
createFolder,
createScript,
deleteFolder,
deleteScript,
getScript,
listFolders,
listScripts,
listScriptVersions,
scriptUsage,
updateFolder,
updateScript
} from '../models/libraryModel.js';
import { camelJob } from '../models/mappers.js';
import { executeScriptTest } from '../services/powershellRunner.js';
export function folders(req, res) {
res.json(listFolders(req.user.id));
}
export function createFolderAction(req, res) {
const parsed = folderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid folder payload' });
res.status(201).json(createFolder(parsed.data, req.user.id));
}
export function updateFolderAction(req, res) {
const folder = updateFolder(req.params.id, req.body, req.user.id);
if (!folder) return res.status(404).json({ error: 'Folder not found' });
res.json(folder);
}
export function deleteFolderAction(req, res) {
deleteFolder(req.params.id, req.user.id);
res.status(204).end();
}
export function scripts(req, res) {
res.json(listScripts(req.user.id));
}
export function createScriptAction(req, res) {
// Model normalization creates the script row and initial version in one backend transaction path.
res.status(201).json(createScript(req.body, req.user.id));
}
export function cloneScriptAction(req, res) {
const script = cloneScript(req.params.id, req.body || {}, req.user.id);
if (!script) return res.status(404).json({ error: 'Script not found' });
res.status(201).json(script);
}
export function getScriptAction(req, res) {
const script = getScript(req.params.id, req.user.id);
if (!script) return res.status(404).json({ error: 'Script not found' });
res.json(script);
}
export function updateScriptAction(req, res) {
// Updating script content also appends a version record for audit and recovery.
const script = updateScript(req.params.id, req.body, req.user.id);
if (!script) return res.status(404).json({ error: 'Script not found' });
res.json(script);
}
export function deleteScriptAction(req, res) {
const usage = scriptUsage(req.params.id, req.user.id);
if (!usage) return res.status(404).json({ error: 'Script not found' });
if (usage.total && req.query.force !== 'true') {
return res.status(409).json({
error: 'Script is assigned to RunPlans or Intune deployments. Confirm deletion with force=true.',
usage
});
}
deleteScript(req.params.id, req.user.id);
res.status(204).end();
}
export function scriptUsageAction(req, res) {
const usage = scriptUsage(req.params.id, req.user.id);
if (!usage) return res.status(404).json({ error: 'Script not found' });
res.json(usage);
}
export function testRunScriptAction(req, res) {
const parsed = scriptTestRunSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' });
try {
res.status(202).json(camelJob(executeScriptTest(req.params.id, parsed.data, req.user.id)));
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function scriptVersions(req, res) {
const versions = listScriptVersions(req.params.id, req.user.id);
if (!versions) return res.status(404).json({ error: 'Script not found' });
res.json(versions);
}

View File

@@ -0,0 +1,5 @@
import { readSystemLog } from '../services/logger.js';
export function systemLogs(req, res) {
res.json(readSystemLog(Number(req.query.limit || 300)));
}

View File

@@ -0,0 +1,20 @@
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
import { buildDetectionRule } from '../services/detectionRuleService.js';
export function installerTypes(req, res) {
res.json(listInstallerTechnologies());
}
export function analyze(req, res) {
const fileName = String(req.body?.fileName || '').trim();
if (!fileName) return res.status(400).json({ error: 'fileName is required' });
res.json(analyzeInstaller({ fileName, productCode: req.body?.productCode || '' }));
}
export function detection(req, res) {
try {
res.json(buildDetectionRule(req.body || {}));
} catch (error) {
res.status(400).json({ error: error.message });
}
}

View File

@@ -0,0 +1,549 @@
import { graphDeploymentLinkSchema, graphDeploymentSyncSchema, intuneDeploymentSchema, psadtMigrationSchema, psadtProfileSchema, psadtValidationSchema } from '../forms/schemas.js';
import { createSyncRun, finishSyncRun, getGraphConnection, listDeploymentStatuses, listDeploymentSyncRuns, listGraphAudit, recordGraphAudit, upsertDeploymentStatuses } from '../models/graphModel.js';
import { createScript, getScript } from '../models/libraryModel.js';
import {
createIntuneDeployment,
createPsadtProfile,
deleteIntuneDeployment,
deletePsadtProfile,
getPsadtCatalog,
listIntuneDeployments,
listPsadtProfiles,
loadIntuneDeployment,
loadPsadtProfile,
markDeploymentPublished,
renderPsadtProfile,
updateIntuneDeployment,
updatePsadtProfile
} from '../models/psadtModel.js';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { createChangeRequest, findApprovedRequest, findPendingRequest, markRequestExecuted } from '../models/changeRequestModel.js';
import { buildIntuneWin, builderAvailability } from '../services/intuneWinBuilder.js';
import { assignWin32App, getWin32AppState, setWin32AppRelationships, syncIntuneDeploymentStatus, uploadWin32Content, upsertWin32App } from '../services/graphService.js';
import { buildAssignmentsPayload, buildRelationshipsPayload, buildWin32LobAppPayload } from '../services/intunePublishService.js';
import { computeDrift } from '../services/intuneDriftService.js';
import { canPublish } from '../services/graphRbac.js';
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
import { parseIntunewin } from '../services/intunewinParser.js';
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
import { planPsadtMigration } from '../services/psadtMigrationService.js';
export function catalog(req, res) {
res.json(getPsadtCatalog());
}
export function intuneIndex(req, res) {
res.json(listIntuneDeployments(req.user.id));
}
export function intuneCreate(req, res) {
const parsed = intuneDeploymentSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' });
res.status(201).json(createIntuneDeployment(parsed.data, req.user.id));
}
export function intuneShow(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
res.json(deployment);
}
export function intuneUpdate(req, res) {
const parsed = intuneDeploymentSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' });
const deployment = updateIntuneDeployment(req.params.id, parsed.data, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
res.json(deployment);
}
export function intuneDestroy(req, res) {
deleteIntuneDeployment(req.params.id, req.user.id);
res.status(204).end();
}
export function intuneGraphStatus(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const statuses = listDeploymentStatuses(deployment.id);
const syncRuns = listDeploymentSyncRuns(deployment.id);
res.json({
deployment,
summary: summarizeInstallStatuses(statuses),
failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode),
statuses,
syncRuns
});
}
export function intuneGraphLink(req, res) {
const parsed = graphDeploymentLinkSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Valid connectionId and graphAppId are required' });
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const updated = updateIntuneDeployment(req.params.id, {
...deployment,
graphConnectionId: parsed.data.connectionId || deployment.graphConnectionId || null,
graphAppId: parsed.data.graphAppId
}, req.user.id);
res.json(updated);
}
export async function intuneGraphSync(req, res) {
const parsed = graphDeploymentSyncSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph sync payload' });
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const connectionId = parsed.data.connectionId || deployment.graphConnectionId;
const graphAppId = parsed.data.graphAppId || deployment.graphAppId;
if (!connectionId || !graphAppId) return res.status(400).json({ error: 'Graph connection and Intune mobile app id are required' });
const connection = getGraphConnection(connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
const runId = createSyncRun(deployment.id, connectionId, graphAppId);
try {
const result = await syncIntuneDeploymentStatus(connection, graphAppId, parsed.data.mode);
upsertDeploymentStatuses(deployment.id, connectionId, graphAppId, result.rows);
finishSyncRun(runId, 'success', `Synced ${result.rows.length} status rows from ${result.sources.join(', ') || 'Graph'}`, result.summary);
const updated = updateIntuneDeployment(req.params.id, {
...deployment,
graphConnectionId: connectionId,
graphAppId
}, req.user.id);
const statuses = listDeploymentStatuses(deployment.id);
res.json({
deployment: updated,
summary: summarizeInstallStatuses(statuses),
failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode),
statuses,
syncRuns: listDeploymentSyncRuns(deployment.id)
});
} catch (error) {
finishSyncRun(runId, 'failed', error.message, {});
res.status(400).json({ error: error.message, syncRuns: listDeploymentSyncRuns(deployment.id) });
}
}
// Shared resolver for tenant-changing Graph actions: loads the visible
// deployment and a write-enabled Graph connection (with secret), or sends the
// appropriate error and returns null.
function resolveWriteContext(req, res) {
const parsed = graphDeploymentSyncSchema.safeParse(req.body || {});
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) { res.status(404).json({ error: 'Intune deployment not found' }); return null; }
const connectionId = (parsed.success && parsed.data.connectionId) || deployment.graphConnectionId;
if (!connectionId) { res.status(400).json({ error: 'A Graph connection is required for tenant write-back' }); return null; }
const connection = getGraphConnection(connectionId, req.user.id, true);
if (!connection) { res.status(404).json({ error: 'Graph connection not found' }); return null; }
if (!connection.allow_write) {
res.status(403).json({ error: 'This Graph connection is read-only. An admin must enable write-back first.' });
return null;
}
if (!canPublish(connection, req.user)) {
res.status(403).json({ error: 'You are not an authorized publisher for this Graph connection.' });
return null;
}
return { deployment, connection, connectionId };
}
// Governance gate: when a connection requires approval, a tenant-changing action
// only proceeds if an approved (not-yet-executed) change request exists for it.
// Otherwise a pending request is created and the caller is told to wait. Returns
// { proceed, requestId } — when proceed is false a 202 response has been sent.
function ensureApproved(ctx, action, req, res) {
if (!ctx.connection.require_approval) return { proceed: true, requestId: null };
const approved = findApprovedRequest(ctx.deployment.id, action);
if (approved) return { proceed: true, requestId: approved.id };
const request = findPendingRequest(ctx.deployment.id, action) || createChangeRequest({
deploymentId: ctx.deployment.id,
connectionId: ctx.connectionId,
action,
requestedBy: req.user.id,
requesterEmail: req.user.email
});
res.status(202).json({ status: 'approval-required', message: 'This action requires approval before it runs.', request });
return { proceed: false };
}
// Phase 1: publish (create/update) the win32LobApp metadata in the tenant from
// a deployment plan. Every attempt is audit-logged with actor, request, response.
export async function intuneGraphPublish(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const approval = ensureApproved(ctx, 'publish', req, res);
if (!approval.proceed) return;
const { deployment, connection, connectionId } = ctx;
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const { payload, warnings } = buildWin32LobAppPayload(deployment, profile);
const auditBase = {
connectionId,
deploymentId: deployment.id,
actorUserId: req.user.id,
actorEmail: req.user.email,
action: deployment.graphAppId ? 'win32app.update' : 'win32app.create',
request: payload
};
try {
const result = await upsertWin32App(connection, payload, deployment.graphAppId);
const updated = updateIntuneDeployment(req.params.id, {
...deployment,
graphConnectionId: connectionId,
graphAppId: result.id,
status: 'published'
}, req.user.id);
if (approval.requestId) markRequestExecuted(approval.requestId);
markDeploymentPublished(deployment.id);
recordGraphAudit({ ...auditBase, targetGraphId: result.id, status: 'success', message: result.updated ? 'Updated win32LobApp' : 'Created win32LobApp', response: result.raw || { id: result.id } });
res.json({ deployment: updated, graphAppId: result.id, warnings });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message, warnings });
}
}
// Phase 3: replace the app's assignments (rings, filters, exclusions) in tenant.
export async function intuneGraphAssign(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before assigning it.' });
const approval = ensureApproved(ctx, 'assign', req, res);
if (!approval.proceed) return;
const { assignments, warnings } = buildAssignmentsPayload(deployment);
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.assign', targetGraphId: deployment.graphAppId, request: { mobileAppAssignments: assignments }
};
try {
const result = await assignWin32App(connection, deployment.graphAppId, assignments);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: `Assigned ${result.count} target(s)`, response: result });
res.json({ deployment, assigned: result.count, warnings });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message, warnings });
}
}
// Phase 4: replace the app's supersedence/dependency relationships in tenant.
export async function intuneGraphRelationships(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before setting relationships.' });
const approval = ensureApproved(ctx, 'relationships', req, res);
if (!approval.proceed) return;
let body;
try {
body = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId);
} catch (error) {
return res.status(400).json({ error: error.message, validation: error.validation || [] });
}
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.relationships', targetGraphId: deployment.graphAppId, request: body
};
try {
const result = await setWin32AppRelationships(connection, deployment.graphAppId, body.relationships);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: `Set ${result.count} relationship(s)`, response: result });
res.json({ deployment, relationships: result.count });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message });
}
}
// Phase 2: upload the .intunewin content for a published app. The package is an
// Asset Library file (assetId); we parse it locally for the encrypted payload +
// encryption info, then run the Graph content-version/Azure-block-upload/commit
// flow. NOTE: validated end to end only against a live tenant.
export async function intuneGraphUploadContent(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before uploading content.' });
const approval = ensureApproved(ctx, 'upload-content', req, res);
if (!approval.proceed) return;
const assetId = req.body?.assetId;
if (!assetId) return res.status(400).json({ error: 'assetId of the .intunewin package is required' });
const fileRef = getAssetFile(assetId, req.user.id);
if (!fileRef) return res.status(404).json({ error: 'Asset not found' });
let parsed;
try {
parsed = parseIntunewin(fs.readFileSync(fileRef.filePath));
} catch (error) {
return res.status(400).json({ error: `Could not read .intunewin package: ${error.message}` });
}
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.content-upload', targetGraphId: deployment.graphAppId,
request: { assetId, setupFile: parsed.setupFile, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize }
};
try {
const result = await uploadWin32Content(connection, deployment.graphAppId, parsed);
updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: fileRef.asset.originalName, status: 'published' }, req.user.id);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: `Committed content version ${result.contentVersionId}`, response: result });
res.json({ deployment: loadIntuneDeployment(req.params.id, req.user.id), content: result });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message });
}
}
// Reconcile: re-apply the full plan (metadata, assignments, relationships) to the
// tenant to clear drift. Gated + approval-aware + audited as one action.
export async function intuneGraphReconcile(req, res) {
const ctx = resolveWriteContext(req, res);
if (!ctx) return;
const { deployment, connection, connectionId } = ctx;
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before reconciling.' });
const approval = ensureApproved(ctx, 'reconcile', req, res);
if (!approval.proceed) return;
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const { payload } = buildWin32LobAppPayload(deployment, profile);
const { assignments } = buildAssignmentsPayload(deployment);
let relationshipsBody = { relationships: [] };
try {
relationshipsBody = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId);
} catch { /* invalid relationships are skipped; drift will still flag them */ }
const auditBase = {
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
action: 'win32app.reconcile', targetGraphId: deployment.graphAppId, request: { app: payload, assignments, relationships: relationshipsBody.relationships }
};
try {
await upsertWin32App(connection, payload, deployment.graphAppId);
await assignWin32App(connection, deployment.graphAppId, assignments);
await setWin32AppRelationships(connection, deployment.graphAppId, relationshipsBody.relationships);
if (approval.requestId) markRequestExecuted(approval.requestId);
recordGraphAudit({ ...auditBase, status: 'success', message: 'Re-applied plan to tenant' });
res.json({ deployment, reconciled: true });
} catch (error) {
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
res.status(400).json({ error: error.message });
}
}
// Report whether this host can build .intunewin packages (Windows + tool, or an
// external packager hook), so the UI can enable/disable the Build action.
export function intuneBuilderStatus(req, res) {
res.json(builderAvailability());
}
// Build a .intunewin from a source folder using IntuneWinAppUtil (or the
// configured packager) and ingest the result into the Asset Library, linking it
// to the deployment for content upload.
export async function intuneBuild(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const sourceFolder = req.body?.sourceFolder || deployment.sourceFolder;
const setupFile = req.body?.setupFile;
if (!sourceFolder) return res.status(400).json({ error: 'A source folder is required (set it on the deployment or pass sourceFolder).' });
if (!setupFile) return res.status(400).json({ error: 'A setup file (entry installer relative to the source folder) is required.' });
const outputDir = path.join(config.toolsDir, '_build', deployment.id);
try {
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir });
const stats = fs.statSync(outputFile);
const asset = createAssetFromUpload(
{ path: outputFile, originalname: path.basename(outputFile), mimetype: 'application/octet-stream', size: stats.size },
{ name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: path.basename(outputFile) },
req.user.id
);
const updated = updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: asset.originalName }, req.user.id);
res.status(201).json({ asset, deployment: updated });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
// New version: clone a plan into the next version, linked to the same catalog
// application, ready to build/publish. Does not touch the tenant.
export function intuneNewVersion(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const clone = cloneForNextVersion(deployment, { version: req.body?.version, applicationId: req.body?.applicationId });
res.status(201).json(createIntuneDeployment(clone, req.user.id));
}
// Promote a rollout ring to a new intent (e.g. Broad available -> required).
// Updates the plan; the operator then re-assigns to push it to the tenant.
export function intunePromote(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const ring = req.body?.ring;
const intent = req.body?.intent;
if (!ring || !['available', 'required', 'uninstall', 'exclude'].includes(intent)) {
return res.status(400).json({ error: 'A ring and a valid intent (available/required/uninstall/exclude) are required' });
}
const { assignments, changed } = setRingIntent(deployment.assignments, ring, intent);
if (!changed) return res.status(400).json({ error: `No assignment ring named "${ring}" to promote.` });
const updated = updateIntuneDeployment(req.params.id, { ...deployment, assignments }, req.user.id);
res.json(updated);
}
// Drift check: compare the stored plan against the live tenant app. Read-only,
// so it needs a Graph connection (with secret) but not the write gate.
export async function intuneGraphDrift(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
if (!deployment.graphAppId) return res.status(400).json({ error: 'This deployment has not been published to a tenant yet.' });
const connectionId = req.body?.connectionId || req.query?.connectionId || deployment.graphConnectionId;
if (!connectionId) return res.status(400).json({ error: 'A Graph connection is required to check drift' });
const connection = getGraphConnection(connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const { payload: expectedApp } = buildWin32LobAppPayload(deployment, profile);
const { assignments: expectedAssignments } = buildAssignmentsPayload(deployment);
let expectedRelationships = [];
try {
expectedRelationships = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId).relationships;
} catch {
expectedRelationships = [];
}
try {
const live = await getWin32AppState(connection, deployment.graphAppId);
const drift = computeDrift({
expectedApp,
liveApp: live.app,
expectedAssignments,
liveAssignments: live.assignments,
expectedRelationships,
liveRelationships: live.relationships
});
res.json({ deployment: { id: deployment.id, name: deployment.name, graphAppId: deployment.graphAppId }, ...drift });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function intuneGraphAudit(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
res.json(listGraphAudit({ deploymentId: deployment.id }));
}
export function index(req, res) {
res.json(listPsadtProfiles(req.user.id));
}
export function create(req, res) {
const parsed = psadtProfileSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT profile payload' });
res.status(201).json(createPsadtProfile(parsed.data, req.user.id));
}
export function show(req, res) {
const profile = loadPsadtProfile(req.params.id, req.user.id);
if (!profile) return res.status(404).json({ error: 'PSADT profile not found' });
res.json(profile);
}
export function update(req, res) {
const profile = updatePsadtProfile(req.params.id, req.body, req.user.id);
if (!profile) return res.status(404).json({ error: 'PSADT profile not found' });
res.json(profile);
}
export function destroy(req, res) {
deletePsadtProfile(req.params.id, req.user.id);
res.status(204).end();
}
export function render(req, res) {
const rendered = renderPsadtProfile(req.params.id, req.user.id);
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
res.json(rendered);
}
export function rules(req, res) {
res.json(validationRuleCatalog());
}
export function validate(req, res) {
const parsed = psadtValidationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT validation payload' });
let content = parsed.data.content || '';
const context = { platform: parsed.data.platform };
if (parsed.data.scriptId) {
const script = getScript(parsed.data.scriptId, req.user.id);
if (!script) return res.status(404).json({ error: 'Script not found' });
content = script.content || '';
context.scriptId = script.id;
context.scriptName = script.name;
}
if (parsed.data.profileId) {
const rendered = renderPsadtProfile(parsed.data.profileId, req.user.id);
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
content = rendered.script;
context.profileId = rendered.profile.id;
context.profileName = rendered.profile.name;
context.requireAdmin = rendered.profile.requireAdmin;
}
res.json(validatePsadtScript(content, context));
}
export function migrationPlan(req, res) {
const parsed = psadtMigrationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' });
const { content, script } = resolveMigrationSource(parsed.data, req.user.id);
if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' });
res.json({
sourceScript: script ? { id: script.id, name: script.name } : null,
...planPsadtMigration(content, { targetVersion: parsed.data.targetVersion })
});
}
export function migrationApply(req, res) {
const parsed = psadtMigrationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' });
const { content, script } = resolveMigrationSource(parsed.data, req.user.id);
if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' });
const plan = planPsadtMigration(content, { targetVersion: parsed.data.targetVersion });
let migratedScript = null;
if (parsed.data.createScript) {
migratedScript = createScript({
folderId: script?.folderId || null,
name: `${script?.name || 'Migrated PSADT Script'} - ${parsed.data.nameSuffix}`,
description: `Migrated to PSADT ${plan.targetVersion}${script ? ` from ${script.name}` : ''}. Review manual migration findings before production deployment.`,
content: plan.migratedContent,
visibility: script?.visibility || 'personal',
groupId: script?.groupId || null
}, req.user.id);
}
res.json({ ...plan, sourceScript: script ? { id: script.id, name: script.name } : null, migratedScript });
}
function resolveMigrationSource(payload, userId) {
if (payload.scriptId) {
const script = getScript(payload.scriptId, userId);
return { script, content: script?.content || '' };
}
return { script: null, content: payload.content || '' };
}
function summarizeInstallStatuses(statuses = []) {
const summary = { total: statuses.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0, devices: 0, users: 0 };
for (const row of statuses) {
if (row.scope === 'user') summary.users += 1;
else summary.devices += 1;
const key = row.installState || 'unknown';
if (summary[key] == null) summary.unknown += 1;
else summary[key] += 1;
}
return summary;
}

View File

@@ -0,0 +1,39 @@
import { camelJob } from '../models/mappers.js';
import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js';
import { executeRunPlan } from '../services/powershellRunner.js';
export function index(req, res) {
res.json(listRunPlans(req.user.id));
}
export function create(req, res) {
res.status(201).json(createRunPlan(req.body, req.user.id));
}
export function show(req, res) {
const runplan = loadVisibleRunPlan(req.params.id, req.user.id);
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
res.json(runplan);
}
export function update(req, res) {
const runplan = updateRunPlan(req.params.id, req.body, req.user.id);
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
res.json(runplan);
}
export function destroy(req, res) {
deleteRunPlan(req.params.id, req.user.id);
res.status(204).end();
}
export function execute(req, res) {
// Execution remains an API concern; Vue only requests a RunPlan launch.
const runplan = loadVisibleRunPlan(req.params.id, req.user.id);
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
try {
res.status(202).json(camelJob(executeRunPlan(req.params.id, req.user.id)));
} catch (error) {
res.status(400).json({ error: error.message });
}
}

View File

@@ -0,0 +1,9 @@
import { settingsPayload, updateSettings } from '../models/settingsModel.js';
export function listSettings(req, res) {
res.json(settingsPayload());
}
export function saveSettings(req, res) {
res.json(updateSettings(req.body));
}

View File

@@ -0,0 +1,12 @@
import { userSchema } from '../forms/schemas.js';
import { createUser, listUsers } from '../models/userModel.js';
export function index(req, res) {
res.json(listUsers());
}
export function create(req, res) {
const parsed = userSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid user payload' });
res.status(201).json(createUser(parsed.data));
}

View File

@@ -0,0 +1,33 @@
import { customVariableSchema } from '../forms/schemas.js';
import {
createCustomVariable,
deleteCustomVariable,
listCustomVariables,
listVariableCatalog,
updateCustomVariable
} from '../models/variableModel.js';
export function catalog(req, res) {
res.json(listVariableCatalog(req.user.id));
}
export function customIndex(req, res) {
res.json(listCustomVariables(req.user.id));
}
export function createCustom(req, res) {
const parsed = customVariableSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid variable payload' });
res.status(201).json(createCustomVariable(parsed.data, req.user.id));
}
export function updateCustom(req, res) {
const variable = updateCustomVariable(req.params.id, req.body, req.user.id);
if (!variable) return res.status(404).json({ error: 'Variable not found' });
res.json(variable);
}
export function destroyCustom(req, res) {
deleteCustomVariable(req.params.id, req.user.id);
res.status(204).end();
}

532
server/db.js Normal file
View File

@@ -0,0 +1,532 @@
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { config } from './config.js';
import { settingDefinitions } from './settingsRegistry.js';
fs.mkdirSync(path.dirname(config.databasePath), { recursive: true });
fs.mkdirSync(config.fileLockerDir, { recursive: true });
fs.mkdirSync(config.toolsDir, { recursive: true });
export const db = new DatabaseSync(config.databasePath);
db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;');
export function id(prefix) {
return `${prefix}_${nanoid(12)}`;
}
export function now() {
return new Date().toISOString();
}
export function json(value, fallback = []) {
if (value == null || value === '') return JSON.stringify(fallback);
return JSON.stringify(value);
}
export function parseJson(value, fallback = []) {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
function ensureColumn(table, column, definition) {
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
if (!columns.some((row) => row.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
export function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'local',
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
username TEXT,
secret_cipher TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hosts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fqdn TEXT,
address TEXT NOT NULL,
os_family TEXT NOT NULL DEFAULT 'windows',
transport TEXT NOT NULL DEFAULT 'winrm',
port INTEGER,
credential_id TEXT REFERENCES credentials(id) ON DELETE SET NULL,
tags TEXT NOT NULL DEFAULT '[]',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scripts (
id TEXT PRIMARY KEY,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_folders (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES asset_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
folder_id TEXT REFERENCES asset_folders(id) ON DELETE SET NULL,
name TEXT NOT NULL,
original_name TEXT NOT NULL,
description TEXT,
kind TEXT NOT NULL DEFAULT 'generic',
mime_type TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
checksum TEXT NOT NULL,
storage_path TEXT NOT NULL,
package_path TEXT,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_links (
id TEXT PRIMARY KEY,
asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
link_role TEXT NOT NULL DEFAULT 'reference',
package_path TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS custom_variables (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'Custom',
value_type TEXT NOT NULL DEFAULT 'string',
secret_cipher TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
sensitive INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS psadt_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
app_vendor TEXT,
app_name TEXT NOT NULL,
app_version TEXT,
app_arch TEXT,
app_lang TEXT,
app_revision TEXT,
toolkit_version TEXT NOT NULL DEFAULT 'v4',
template_version TEXT NOT NULL DEFAULT 'v4',
deployment_type TEXT NOT NULL DEFAULT 'Install',
deploy_mode TEXT NOT NULL DEFAULT 'Interactive',
require_admin INTEGER NOT NULL DEFAULT 1,
zero_config INTEGER NOT NULL DEFAULT 0,
suppress_reboot INTEGER NOT NULL DEFAULT 0,
close_processes TEXT NOT NULL DEFAULT '[]',
install_tasks TEXT NOT NULL DEFAULT '[]',
ui_plan TEXT NOT NULL DEFAULT '{}',
config_plan TEXT NOT NULL DEFAULT '{}',
admx_plan TEXT NOT NULL DEFAULT '{}',
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS intune_deployments (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
profile_id TEXT REFERENCES psadt_profiles(id) ON DELETE SET NULL,
script_id TEXT REFERENCES scripts(id) ON DELETE SET NULL,
source_folder TEXT,
intunewin_file TEXT,
app_type TEXT NOT NULL DEFAULT 'Windows app (Win32)',
command_style TEXT NOT NULL DEFAULT 'v4',
install_behavior TEXT NOT NULL DEFAULT 'system',
restart_behavior TEXT NOT NULL DEFAULT 'return-code',
ui_mode TEXT NOT NULL DEFAULT 'native-v4',
requirements TEXT NOT NULL DEFAULT '{}',
install_command TEXT NOT NULL,
uninstall_command TEXT NOT NULL,
detection_type TEXT NOT NULL DEFAULT 'custom-script',
detection_rule TEXT NOT NULL DEFAULT '',
return_codes TEXT NOT NULL DEFAULT '[]',
assignments TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'draft',
graph_app_id TEXT,
notes TEXT,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS graph_connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
tenant_id TEXT NOT NULL,
client_id TEXT NOT NULL,
secret_cipher TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
cloud TEXT NOT NULL DEFAULT 'global',
graph_base_url TEXT NOT NULL DEFAULT 'https://graph.microsoft.com',
authority_host TEXT NOT NULL DEFAULT 'https://login.microsoftonline.com',
default_api_version TEXT NOT NULL DEFAULT 'v1.0',
enabled INTEGER NOT NULL DEFAULT 1,
last_test_status TEXT,
last_test_message TEXT,
last_test_at TEXT,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS intune_deployment_statuses (
id TEXT PRIMARY KEY,
deployment_id TEXT NOT NULL REFERENCES intune_deployments(id) ON DELETE CASCADE,
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
graph_app_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'device',
principal_id TEXT,
principal_name TEXT,
device_id TEXT,
device_name TEXT,
user_id TEXT,
user_principal_name TEXT,
install_state TEXT,
install_state_detail TEXT,
error_code TEXT,
error_description TEXT,
last_sync_datetime TEXT,
raw_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS intune_deployment_sync_runs (
id TEXT PRIMARY KEY,
deployment_id TEXT NOT NULL REFERENCES intune_deployments(id) ON DELETE CASCADE,
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
graph_app_id TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
summary TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
ended_at TEXT
);
CREATE TABLE IF NOT EXISTS applications (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
vendor TEXT,
publisher TEXT,
current_version TEXT,
latest_known_version TEXT,
version_source TEXT NOT NULL DEFAULT 'manual',
version_source_ref TEXT,
graph_connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
current_graph_app_id TEXT,
auto_check INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_check_message TEXT,
notes TEXT,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS change_requests (
id TEXT PRIMARY KEY,
deployment_id TEXT REFERENCES intune_deployments(id) ON DELETE CASCADE,
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
action TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
requester_email TEXT,
approver_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
approver_email TEXT,
reason TEXT,
decided_at TEXT,
executed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS graph_audit_log (
id TEXT PRIMARY KEY,
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
deployment_id TEXT REFERENCES intune_deployments(id) ON DELETE SET NULL,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
actor_email TEXT,
action TEXT NOT NULL,
target_graph_id TEXT,
status TEXT NOT NULL,
message TEXT,
request_json TEXT NOT NULL DEFAULT '{}',
response_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS script_versions (
id TEXT PRIMARY KEY,
script_id TEXT NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
content TEXT NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS runplans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
script_id TEXT NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
visibility TEXT NOT NULL DEFAULT 'personal',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
parallel INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS runplan_hosts (
runplan_id TEXT NOT NULL REFERENCES runplans(id) ON DELETE CASCADE,
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
PRIMARY KEY (runplan_id, host_id)
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
script_id TEXT REFERENCES scripts(id) ON DELETE SET NULL,
status TEXT NOT NULL,
triggered_by TEXT REFERENCES users(id) ON DELETE SET NULL,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_hosts (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
host_id TEXT REFERENCES hosts(id) ON DELETE SET NULL,
status TEXT NOT NULL,
exit_code INTEGER,
started_at TEXT,
ended_at TEXT
);
CREATE TABLE IF NOT EXISTS job_logs (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
host_id TEXT REFERENCES hosts(id) ON DELETE SET NULL,
stream TEXT NOT NULL,
line TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'db',
updated_at TEXT NOT NULL
);
`);
ensureColumn('users', 'theme', "TEXT NOT NULL DEFAULT 'dashtreme'");
ensureColumn('users', 'job_title', 'TEXT');
ensureColumn('users', 'phone', 'TEXT');
ensureColumn('users', 'timezone', 'TEXT');
ensureColumn('users', 'avatar_url', 'TEXT');
ensureColumn('users', 'updated_at', 'TEXT');
ensureColumn('intune_deployments', 'source_folder', 'TEXT');
ensureColumn('intune_deployments', 'intunewin_file', 'TEXT');
ensureColumn('intune_deployments', 'command_style', "TEXT NOT NULL DEFAULT 'v4'");
ensureColumn('intune_deployments', 'install_behavior', "TEXT NOT NULL DEFAULT 'system'");
ensureColumn('intune_deployments', 'restart_behavior', "TEXT NOT NULL DEFAULT 'return-code'");
ensureColumn('intune_deployments', 'ui_mode', "TEXT NOT NULL DEFAULT 'native-v4'");
ensureColumn('intune_deployments', 'requirements', "TEXT NOT NULL DEFAULT '{}'");
ensureColumn('intune_deployments', 'graph_app_id', 'TEXT');
ensureColumn('intune_deployments', 'graph_connection_id', 'TEXT');
ensureColumn('intune_deployments', 'relationships', "TEXT NOT NULL DEFAULT '[]'");
ensureColumn('intune_deployments', 'application_id', 'TEXT REFERENCES applications(id) ON DELETE SET NULL');
// Auto-promote: after the soak window elapses, advance a rollout ring's intent.
ensureColumn('intune_deployments', 'auto_promote_after_hours', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn('intune_deployments', 'auto_promote_ring', 'TEXT');
ensureColumn('intune_deployments', 'auto_promote_intent', "TEXT NOT NULL DEFAULT 'required'");
ensureColumn('intune_deployments', 'soak_started_at', 'TEXT');
ensureColumn('intune_deployments', 'promoted_at', 'TEXT');
// Host visibility scoping. Existing rows default to 'shared' so prior global
// hosts remain visible to everyone; new hosts can be personal/group-scoped.
ensureColumn('hosts', 'visibility', "TEXT NOT NULL DEFAULT 'shared'");
ensureColumn('hosts', 'owner_user_id', 'TEXT REFERENCES users(id) ON DELETE SET NULL');
ensureColumn('hosts', 'group_id', 'TEXT REFERENCES groups(id) ON DELETE SET NULL');
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
// read-only connections can never be used to change a tenant by accident.
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
// When set, tenant-changing actions through this connection require an
// approved change request before they run.
ensureColumn('graph_connections', 'require_approval', 'INTEGER NOT NULL DEFAULT 0');
// Optional named-RBAC lists (JSON arrays of user ids). Empty = no restriction
// beyond the allow_write/admin gates.
ensureColumn('graph_connections', 'publisher_ids', "TEXT NOT NULL DEFAULT '[]'");
ensureColumn('graph_connections', 'approver_ids', "TEXT NOT NULL DEFAULT '[]'");
}
export function seed() {
const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count;
if (userCount === 0) {
const adminId = id('usr');
db.prepare(`
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at)
VALUES (?, ?, ?, ?, 'local', 'admin', ?)
`).run(
adminId,
config.defaultAdminEmail.toLowerCase(),
config.defaultAdminName,
bcrypt.hashSync(config.defaultAdminPassword, 12),
now()
);
const groupId = id('grp');
db.prepare('INSERT INTO groups (id, name, description, created_at) VALUES (?, ?, ?, ?)').run(
groupId,
'Operations',
'Default shared operations group',
now()
);
db.prepare('INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)').run(adminId, groupId);
const folderId = id('fld');
db.prepare(`
INSERT INTO folders (id, parent_id, name, visibility, owner_user_id, group_id, created_at, updated_at)
VALUES (?, NULL, 'Examples', 'shared', ?, NULL, ?, ?)
`).run(folderId, adminId, now(), now());
const scriptId = id('scr');
const sample = [
'Write-Output "POSHManager sample run"',
'Write-Output "Target host: $env:POSHM_HOST_NAME"',
'Get-Date'
].join('\n');
db.prepare(`
INSERT INTO scripts (id, folder_id, name, description, content, visibility, owner_user_id, group_id, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'shared', ?, NULL, 1, ?, ?)
`).run(scriptId, folderId, 'Sample Health Check.ps1', 'Simple smoke-test script for local execution.', sample, adminId, now(), now());
db.prepare(`
INSERT INTO script_versions (id, script_id, version, content, created_by, created_at)
VALUES (?, ?, 1, ?, ?, ?)
`).run(id('ver'), scriptId, sample, adminId, now());
}
// Env-pinned settings are refreshed from the environment on every boot and
// marked source='env' (locked in the UI). Unpinned settings are seeded once
// as editable defaults so admin edits in the Config screen survive restarts.
const pinned = db.prepare(`
INSERT INTO settings (key, value, source, updated_at)
VALUES (?, ?, 'env', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, source = 'env', updated_at = excluded.updated_at
`);
const defaulted = db.prepare(`
INSERT INTO settings (key, value, source, updated_at)
VALUES (?, ?, 'default', ?)
ON CONFLICT(key) DO NOTHING
`);
for (const def of settingDefinitions()) {
if (def.pinned) pinned.run(def.key, def.value, now());
else defaulted.run(def.key, def.value, now());
}
}
export function visibleClause(alias = '') {
const p = alias ? `${alias}.` : '';
return `(${p}visibility = 'shared' OR ${p}owner_user_id = ? OR ${p}group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?))`;
}

331
server/forms/schemas.js Normal file
View File

@@ -0,0 +1,331 @@
import { z } from 'zod';
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
});
export const profileSchema = z.object({
email: z.string().email().optional(),
displayName: z.string().min(1).max(120),
jobTitle: z.string().max(120).optional().default(''),
phone: z.string().max(60).optional().default(''),
timezone: z.string().max(80).optional().default(''),
avatarUrl: z.string().url().or(z.literal('')).optional().default('')
});
export const themeSchema = z.object({
theme: z.string().min(1).max(48).regex(/^[a-z0-9_-]+$/)
});
export const passwordChangeSchema = z.object({
currentPassword: z.string().optional().default(''),
newPassword: z.string().optional().default('')
});
export const visibilitySchema = z.enum(['personal', 'shared', 'group']);
export const userSchema = z.object({
email: z.string().email(),
displayName: z.string().min(1),
password: z.string().min(8).optional(),
role: z.enum(['admin', 'user']).default('user'),
groupIds: z.array(z.string()).default([])
});
export const groupSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
userIds: z.array(z.string()).default([])
});
export const credentialSchema = z.object({
name: z.string().min(1),
kind: z.enum(['username_password', 'api_key']),
username: z.string().optional(),
secret: z.string().min(1),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const hostSchema = z.object({
name: z.string().min(1),
fqdn: z.string().optional(),
address: z.string().min(1),
osFamily: z.string().default('windows'),
transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
port: z.number().int().nullable().optional(),
credentialId: z.string().nullable().optional(),
tags: z.array(z.string()).default([]),
notes: z.string().optional(),
visibility: visibilitySchema.default('shared'),
groupId: z.string().nullable().optional()
});
export const folderSchema = z.object({
parentId: z.string().nullable().optional(),
name: z.string().min(1),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const assetFolderSchema = z.object({
parentId: z.string().nullable().optional(),
name: z.string().min(1).max(180),
description: z.string().max(600).optional().default(''),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const assetUploadSchema = z.object({
folderId: z.string().nullable().optional(),
name: z.string().min(1).max(220),
originalName: z.string().min(1).max(260).optional(),
description: z.string().max(1200).optional().default(''),
mimeType: z.string().max(160).optional(),
packagePath: z.string().max(500).optional().default(''),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const assetUpdateSchema = z.object({
folderId: z.string().nullable().optional(),
name: z.string().min(1).max(220).optional(),
description: z.string().max(1200).optional(),
packagePath: z.string().max(500).optional(),
visibility: visibilitySchema.optional(),
groupId: z.string().nullable().optional()
});
export const assetLinkSchema = z.object({
targetType: z.enum(['script', 'runplan', 'psadt_profile', 'intune_deployment']).default('script'),
targetId: z.string().min(1),
linkRole: z.enum(['reference', 'package-file', 'installer', 'support-file', 'icon', 'detection-script']).default('reference'),
packagePath: z.string().max(500).optional().default('')
});
export const variableNameSchema = z.string().min(1).max(80).regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
export const customVariableSchema = z.object({
name: variableNameSchema,
description: z.string().max(500).optional().default(''),
category: z.string().min(1).max(80).optional().default('Custom'),
value: z.string().optional().default(''),
valueType: z.enum(['string', 'number', 'boolean', 'json']).default('string'),
sensitive: z.boolean().or(z.number()).optional().default(false),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const psadtProfileSchema = z.object({
name: z.string().min(1).max(160),
appVendor: z.string().max(120).optional().default(''),
appName: z.string().max(160).optional().default(''),
appVersion: z.string().max(80).optional().default(''),
appArch: z.enum(['x64', 'x86', 'neutral']).optional().default('x64'),
appLang: z.string().max(40).optional().default('EN'),
appRevision: z.string().max(40).optional().default('01'),
toolkitVersion: z.enum(['v4', 'v3-compatible']).optional().default('v4'),
templateVersion: z.enum(['v4', 'v3']).optional().default('v4'),
deploymentType: z.enum(['Install', 'Uninstall', 'Repair']).optional().default('Install'),
deployMode: z.enum(['Auto', 'Interactive', 'Silent', 'NonInteractive']).optional().default('Auto'),
requireAdmin: z.boolean().or(z.number()).optional().default(true),
zeroConfig: z.boolean().or(z.number()).optional().default(false),
suppressReboot: z.boolean().or(z.number()).optional().default(false),
closeProcesses: z.array(z.object({
name: z.string().min(1),
description: z.string().optional().default('')
})).optional().default([]),
installTasks: z.array(z.object({
type: z.enum(['exe', 'msi', 'msp', 'script']).default('exe'),
phase: z.enum(['Pre-Install', 'Install', 'Post-Install', 'Pre-Uninstall', 'Uninstall', 'Post-Uninstall', 'Pre-Repair', 'Repair', 'Post-Repair']).default('Install'),
filePath: z.string().min(1),
arguments: z.string().optional().default(''),
secureArguments: z.boolean().or(z.number()).optional().default(false)
})).optional().default([]),
uiPlan: z.record(z.string(), z.any()).optional().default({}),
configPlan: z.record(z.string(), z.any()).optional().default({}),
admxPlan: z.record(z.string(), z.any()).optional().default({}),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const psadtValidationSchema = z.object({
content: z.string().optional().default(''),
scriptId: z.string().optional(),
profileId: z.string().optional(),
platform: z.enum(['intune', 'configmgr', 'gpo', 'manual']).optional().default('intune')
});
export const psadtMigrationSchema = z.object({
content: z.string().optional().default(''),
scriptId: z.string().optional(),
targetVersion: z.string().optional().default('4.2.0'),
createScript: z.boolean().or(z.number()).optional().default(false),
nameSuffix: z.string().max(80).optional().default('PSADT 4.2 migrated')
});
export const intuneDeploymentSchema = z.object({
name: z.string().min(1).max(160),
profileId: z.string().nullable().optional(),
scriptId: z.string().nullable().optional(),
applicationId: z.string().nullable().optional(),
sourceFolder: z.string().max(500).optional().default(''),
intunewinFile: z.string().max(500).optional().default(''),
appType: z.string().min(1).max(80).optional().default('Windows app (Win32)'),
commandStyle: z.enum(['v4', 'legacy-v3']).optional().default('v4'),
installBehavior: z.enum(['system', 'user']).optional().default('system'),
restartBehavior: z.enum(['return-code', 'suppress', 'intune']).optional().default('return-code'),
uiMode: z.enum(['native-v4', 'serviceui-legacy', 'silent']).optional().default('native-v4'),
requirements: z.object({
architecture: z.enum(['x64', 'x86', 'both']).optional().default('x64'),
minOs: z.string().max(80).optional().default('Windows 10 22H2'),
diskSpaceMb: z.number().or(z.string()).optional().default(0),
runAs32Bit: z.boolean().or(z.number()).optional().default(false)
}).optional().default({}),
installCommand: z.string().max(500).optional().default(''),
uninstallCommand: z.string().max(500).optional().default(''),
detectionType: z.enum(['msi-product-code', 'file-version', 'registry', 'custom-script']).optional().default('custom-script'),
detectionRule: z.string().max(3000).optional().default(''),
returnCodes: z.array(z.object({
code: z.string().or(z.number()),
type: z.string().min(1).max(40),
meaning: z.string().max(240).optional().default('')
})).optional().default([]),
assignments: z.array(z.object({
ring: z.string().min(1).max(80),
intent: z.enum(['available', 'required', 'uninstall', 'exclude']).optional().default('required'),
groupName: z.string().max(160).optional().default(''),
groupId: z.string().max(120).optional().default(''),
groupMode: z.enum(['group', 'allDevices', 'allUsers']).optional().default('group'),
filterId: z.string().max(120).optional().default(''),
filterType: z.enum(['none', 'include', 'exclude']).optional().default('none'),
autoUpdate: z.boolean().or(z.number()).optional().default(false),
notes: z.string().max(240).optional().default('')
})).optional().default([]),
relationships: z.array(z.object({
kind: z.enum(['supersedence', 'dependency']).default('supersedence'),
targetAppId: z.string().min(1).max(160),
targetName: z.string().max(200).optional().default(''),
mode: z.string().max(40).optional().default('')
})).optional().default([]),
status: z.enum(['draft', 'ready', 'published', 'retired']).optional().default('draft'),
autoPromoteAfterHours: z.number().int().min(0).or(z.string()).optional().default(0),
autoPromoteRing: z.string().max(80).optional().default(''),
autoPromoteIntent: z.enum(['available', 'required', 'uninstall', 'exclude']).optional().default('required'),
graphAppId: z.string().max(160).optional().default(''),
graphConnectionId: z.string().nullable().optional(),
notes: z.string().max(1000).optional().default(''),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const applicationSchema = z.object({
name: z.string().min(1).max(160),
vendor: z.string().max(120).optional().default(''),
publisher: z.string().max(160).optional().default(''),
currentVersion: z.string().max(80).optional().default(''),
latestKnownVersion: z.string().max(80).optional().default(''),
versionSource: z.enum(['manual', 'url', 'winget']).optional().default('manual'),
versionSourceRef: z.string().max(500).optional().default(''),
graphConnectionId: z.string().nullable().optional(),
currentGraphAppId: z.string().max(160).optional().default(''),
autoCheck: z.boolean().or(z.number()).optional().default(false),
notes: z.string().max(1000).optional().default(''),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const applicationImportSchema = z.object({
connectionId: z.string().min(1),
graphAppId: z.string().min(1).max(160),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const graphConnectionSchema = z.object({
name: z.string().min(1).max(160),
tenantId: z.string().min(1).max(120),
clientId: z.string().min(1).max(120),
clientSecret: z.string().optional().default(''),
cloud: z.enum(['global', 'gcc', 'gcchigh', 'dod', 'china', 'custom']).optional().default('global'),
graphBaseUrl: z.string().url().refine(isAllowedGraphHost, {
message: 'graphBaseUrl must be a recognized Microsoft Graph host'
}).optional().default('https://graph.microsoft.com'),
authorityHost: z.string().url().refine(isAllowedAuthorityHost, {
message: 'authorityHost must be a recognized Microsoft login host'
}).optional().default('https://login.microsoftonline.com'),
defaultApiVersion: z.enum(['v1.0', 'beta']).optional().default('v1.0'),
enabled: z.boolean().or(z.number()).optional().default(true),
allowWrite: z.boolean().or(z.number()).optional().default(false),
requireApproval: z.boolean().or(z.number()).optional().default(false),
publisherIds: z.array(z.string()).optional().default([]),
approverIds: z.array(z.string()).optional().default([]),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional()
});
export const graphDeploymentLinkSchema = z.object({
connectionId: z.string().optional().default(''),
graphAppId: z.string().min(1).max(160)
});
export const graphDeploymentSyncSchema = z.object({
connectionId: z.string().optional().default(''),
graphAppId: z.string().optional().default(''),
mode: z.enum(['reports', 'legacy-status', 'both']).optional().default('reports')
});
const scriptPayloadSchema = z.object({
folderId: z.string().nullable().optional(),
folder_id: z.string().nullable().optional(),
name: z.string().min(1),
description: z.string().optional().default(''),
content: z.string().default(''),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional(),
group_id: z.string().nullable().optional()
});
export const scriptTestRunSchema = z.object({
hostId: z.string().min(1),
credentialId: z.string().min(1)
});
const runPlanPayloadSchema = z.object({
name: z.string().min(1),
description: z.string().optional().default(''),
scriptId: z.string().optional(),
script_id: z.string().optional(),
visibility: visibilitySchema.default('personal'),
groupId: z.string().nullable().optional(),
group_id: z.string().nullable().optional(),
parallel: z.boolean().or(z.number()).default(true),
hostIds: z.array(z.string()).optional()
});
export function normalizeScriptPayload(body) {
const parsed = scriptPayloadSchema.parse(body);
return {
folderId: parsed.folderId ?? parsed.folder_id ?? null,
name: parsed.name,
description: parsed.description || '',
content: parsed.content || '',
visibility: parsed.visibility,
groupId: parsed.visibility === 'group' ? parsed.groupId ?? parsed.group_id ?? null : null
};
}
export function normalizeRunPlanPayload(body) {
const parsed = runPlanPayloadSchema.parse(body);
return {
name: parsed.name,
description: parsed.description || '',
scriptId: parsed.scriptId || parsed.script_id,
visibility: parsed.visibility,
groupId: parsed.visibility === 'group' ? parsed.groupId ?? parsed.group_id ?? null : null,
parallel: Boolean(parsed.parallel),
hostIds: parsed.hostIds || []
};
}

66
server/index.js Normal file
View File

@@ -0,0 +1,66 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { config } from './config.js';
import { migrate, seed } from './db.js';
import { requestLogger } from './middleware/requestLogger.js';
import { apiRoutes } from './routes/index.js';
import { logger } from './services/logger.js';
import { effectiveTrustedOrigins } from './models/settingsModel.js';
import { startCatalogWorker } from './services/catalogWorker.js';
import { startPromotionWorker } from './services/promotionWorker.js';
// Fail fast in production rather than ship with well-known default secrets.
// These defaults are fine for local development but are publicly known, so a
// production deployment that kept them would have forgeable sessions and
// recoverable credential ciphertext.
function assertProductionSecrets() {
if (!config.isProduction) return;
const insecure = [];
if (!process.env.JWT_SECRET || config.jwtSecret === 'dev-only-change-me') insecure.push('JWT_SECRET');
if (!process.env.CREDENTIAL_STORE_KEY || config.credentialStoreKey === 'dev-only-credential-key') {
insecure.push('CREDENTIAL_STORE_KEY');
}
if (config.defaultAdminPassword === 'change-me-now') insecure.push('DEFAULT_ADMIN_PASSWORD');
if (insecure.length) {
logger.error('Refusing to start in production with insecure default secrets', { insecure });
throw new Error(`Set non-default values for: ${insecure.join(', ')} before starting in production.`);
}
}
assertProductionSecrets();
migrate();
seed();
const app = express();
// The API always runs behind the Vite proxy (and typically a reverse proxy in
// production), so honor X-Forwarded-* to get the real client IP for rate
// limiting and logging.
app.set('trust proxy', true);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({
origin(origin, callback) {
// Read the effective allow-list per request so admin edits to
// `trusted_origins` in the Config screen take effect without a restart.
if (!origin || effectiveTrustedOrigins().includes(origin)) return callback(null, true);
return callback(new Error(`Origin ${origin} is not allowed by CORS`));
},
credentials: true
}));
app.use(express.json({ limit: config.jsonLimit }));
app.use(requestLogger);
app.use('/api', apiRoutes);
app.use((error, req, res, next) => {
logger.error('unhandled error', { error });
res.status(500).json({ error: 'Unexpected server error' });
});
app.listen(config.port, () => {
logger.info(`POSHManager API listening on ${config.port}`);
startCatalogWorker();
startPromotionWorker();
});

View File

@@ -0,0 +1 @@
export { requireAdmin, requireAuth } from '../auth.js';

View File

@@ -0,0 +1,37 @@
// Lightweight in-memory rate limiter for sensitive endpoints such as login.
// Keeps POSHManager dependency-free for a single-process deployment. For a
// multi-replica deployment behind a load balancer, replace the Map with a
// shared store (Redis) so limits are enforced across instances.
const buckets = new Map();
export function rateLimit({ windowMs = 60_000, max = 10, keyPrefix = 'rl' } = {}) {
return function rateLimiter(req, res, next) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
const key = `${keyPrefix}:${ip}`;
const nowTs = Date.now();
const entry = buckets.get(key);
if (!entry || nowTs > entry.resetAt) {
buckets.set(key, { count: 1, resetAt: nowTs + windowMs });
return next();
}
entry.count += 1;
if (entry.count > max) {
const retryAfter = Math.max(1, Math.ceil((entry.resetAt - nowTs) / 1000));
res.setHeader('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'Too many requests. Please slow down and try again shortly.' });
}
return next();
};
}
// Periodically drop expired buckets so the Map cannot grow unbounded.
const sweep = setInterval(() => {
const nowTs = Date.now();
for (const [key, entry] of buckets) {
if (nowTs > entry.resetAt) buckets.delete(key);
}
}, 5 * 60_000);
sweep.unref?.();

View File

@@ -0,0 +1,25 @@
import { logger } from '../services/logger.js';
export function requestLogger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
logger.info('request', {
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: Date.now() - start,
ip: req.ip,
userId: req.user?.id || null,
headers: redactHeaders(req.headers)
});
});
next();
}
function redactHeaders(headers) {
const safe = { ...headers };
for (const key of ['authorization', 'cookie', 'set-cookie']) {
if (safe[key]) safe[key] = '[redacted]';
}
return safe;
}

View File

@@ -0,0 +1,32 @@
import fs from 'node:fs';
import path from 'node:path';
import multer from 'multer';
import { nanoid } from 'nanoid';
import { config } from '../config.js';
const incomingDir = path.join(config.fileLockerDir, '_incoming');
fs.mkdirSync(incomingDir, { recursive: true });
function sanitizeFileName(name) {
return String(name || 'asset.bin')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180) || 'asset.bin';
}
const storage = multer.diskStorage({
destination(req, file, callback) {
fs.mkdirSync(incomingDir, { recursive: true });
callback(null, incomingDir);
},
filename(req, file, callback) {
callback(null, `${Date.now()}-${nanoid(8)}-${sanitizeFileName(file.originalname)}`);
}
});
// Asset uploads are staged in FileLocker before the model promotes them into the typed library tree.
export const assetUpload = multer({
storage,
limits: { fileSize: config.maxAssetBytes }
});

View File

@@ -0,0 +1,148 @@
import { db, id, now, visibleClause } from '../db.js';
import { applicationSchema } from '../forms/schemas.js';
import { evaluateUpdateState } from '../services/appVersionService.js';
function camelApplication(row) {
if (!row) return null;
const base = {
id: row.id,
name: row.name,
vendor: row.vendor || '',
publisher: row.publisher || '',
currentVersion: row.current_version || '',
latestKnownVersion: row.latest_known_version || '',
versionSource: row.version_source || 'manual',
versionSourceRef: row.version_source_ref || '',
graphConnectionId: row.graph_connection_id || null,
currentGraphAppId: row.current_graph_app_id || '',
autoCheck: Boolean(row.auto_check),
lastCheckedAt: row.last_checked_at || '',
lastCheckMessage: row.last_check_message || '',
notes: row.notes || '',
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
deploymentCount: row.deployment_count ?? 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};
// Surface the computed update state so callers don't re-derive it.
base.updateState = evaluateUpdateState(base);
return base;
}
function scopedGroupId(payload) {
return payload.visibility === 'group' ? payload.groupId || null : null;
}
export function listApplications(userId) {
return db.prepare(`
SELECT a.*, g.name AS group_name,
(SELECT COUNT(*) FROM intune_deployments d WHERE d.application_id = a.id) AS deployment_count
FROM applications a
LEFT JOIN groups g ON g.id = a.group_id
WHERE ${visibleClause('a')}
ORDER BY a.name
`).all(userId, userId).map(camelApplication);
}
export function loadApplication(applicationId, userId) {
const row = db.prepare(`
SELECT a.*, g.name AS group_name,
(SELECT COUNT(*) FROM intune_deployments d WHERE d.application_id = a.id) AS deployment_count
FROM applications a
LEFT JOIN groups g ON g.id = a.group_id
WHERE a.id = ? AND ${visibleClause('a')}
`).get(applicationId, userId, userId);
return camelApplication(row);
}
export function createApplication(body, userId) {
const payload = applicationSchema.parse(body);
const applicationId = id('app');
db.prepare(`
INSERT INTO applications (
id, name, vendor, publisher, current_version, latest_known_version, version_source,
version_source_ref, graph_connection_id, current_graph_app_id, auto_check, notes,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
applicationId,
payload.name,
payload.vendor,
payload.publisher,
payload.currentVersion,
payload.latestKnownVersion,
payload.versionSource,
payload.versionSourceRef,
payload.graphConnectionId || null,
payload.currentGraphAppId,
payload.autoCheck ? 1 : 0,
payload.notes,
payload.visibility,
userId,
scopedGroupId(payload),
userId,
now(),
now()
);
return loadApplication(applicationId, userId);
}
export function updateApplication(applicationId, body, userId) {
const existing = loadApplication(applicationId, userId);
if (!existing) return null;
const payload = applicationSchema.parse({ ...existing, ...body });
db.prepare(`
UPDATE applications
SET name = ?, vendor = ?, publisher = ?, current_version = ?, latest_known_version = ?,
version_source = ?, version_source_ref = ?, graph_connection_id = ?, current_graph_app_id = ?,
auto_check = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.vendor,
payload.publisher,
payload.currentVersion,
payload.latestKnownVersion,
payload.versionSource,
payload.versionSourceRef,
payload.graphConnectionId || null,
payload.currentGraphAppId,
payload.autoCheck ? 1 : 0,
payload.notes,
payload.visibility,
scopedGroupId(payload),
now(),
applicationId
);
return loadApplication(applicationId, userId);
}
export function deleteApplication(applicationId, userId) {
db.prepare(`DELETE FROM applications WHERE id = ? AND ${visibleClause()}`).run(applicationId, userId, userId);
}
// Record the outcome of an upstream version check on the catalog entry.
export function recordVersionCheck(applicationId, userId, { latestKnownVersion, message }) {
const existing = loadApplication(applicationId, userId);
if (!existing) return null;
applyVersionCheck(applicationId, { latestKnownVersion: latestKnownVersion ?? existing.latestKnownVersion, message });
return loadApplication(applicationId, userId);
}
// System-level write used by the background worker (no visibility scope).
export function applyVersionCheck(applicationId, { latestKnownVersion, message }) {
db.prepare(`
UPDATE applications
SET latest_known_version = COALESCE(?, latest_known_version), last_checked_at = ?, last_check_message = ?, updated_at = ?
WHERE id = ?
`).run(latestKnownVersion ?? null, now(), message || '', now(), applicationId);
}
// Applications opted into automatic upstream version checks. System context.
export function listAutoCheckApplications() {
return db.prepare('SELECT * FROM applications WHERE auto_check = 1').all().map(camelApplication);
}

289
server/models/assetModel.js Normal file
View File

@@ -0,0 +1,289 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { db, id, now, visibleClause } from '../db.js';
import { camelAsset, camelAssetFolder, camelAssetLink } from './mappers.js';
function sanitizeFileName(name) {
return String(name || 'asset.bin')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180) || 'asset.bin';
}
function normalizePackagePath(value, fallbackName = '') {
const clean = String(value || fallbackName || '')
.replace(/\\/g, '/')
.split('/')
.filter((part) => part && part !== '.' && part !== '..')
.join('/');
return clean.slice(0, 500);
}
function inferKind(mimeType = '', fileName = '') {
const mime = mimeType.toLowerCase();
const ext = path.extname(fileName).toLowerCase();
if (mime.startsWith('image/')) return 'image';
if (mime.includes('zip') || ['.zip', '.intunewin', '.7z', '.rar'].includes(ext)) return 'archive';
if (['.msi', '.msp', '.exe'].includes(ext)) return 'installer';
if (['.ps1', '.psm1', '.psd1', '.cmd', '.bat'].includes(ext)) return 'script';
if (['.json', '.xml', '.yaml', '.yml', '.config', '.ini', '.reg', '.admx', '.adml'].includes(ext)) return 'config';
if (mime.startsWith('text/') || ['.txt', '.md', '.log'].includes(ext)) return 'text';
if (mime === 'application/pdf' || ['.pdf', '.doc', '.docx'].includes(ext)) return 'document';
return 'generic';
}
function lockerBucket(kind) {
const map = {
archive: 'packages',
config: 'configs',
document: 'documents',
generic: 'generic',
image: 'images',
installer: 'packages',
script: 'scripts',
text: 'documents'
};
return map[kind] || 'generic';
}
function storagePathFor(assetId, kind, originalName) {
return path.join(config.fileLockerDir, 'assets', lockerBucket(kind), assetId, sanitizeFileName(originalName));
}
function checksumFile(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function selectAssetById(assetId, userId) {
return db.prepare(`
SELECT a.*, f.name AS folder_name, g.name AS group_name
FROM assets a
LEFT JOIN asset_folders f ON f.id = a.folder_id
LEFT JOIN groups g ON g.id = a.group_id
WHERE a.id = ? AND ${visibleClause('a')}
`).get(assetId, userId, userId);
}
export function listAssetFolders(userId) {
return db.prepare(`
SELECT f.*, g.name AS group_name
FROM asset_folders f
LEFT JOIN groups g ON g.id = f.group_id
WHERE ${visibleClause('f')}
ORDER BY f.name
`).all(userId, userId).map(camelAssetFolder);
}
export function createAssetFolder(payload, userId) {
const folderId = id('afld');
db.prepare(`
INSERT INTO asset_folders (id, parent_id, name, description, visibility, owner_user_id, group_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
folderId,
payload.parentId || null,
payload.name,
payload.description || '',
payload.visibility,
userId,
payload.visibility === 'group' ? payload.groupId || null : null,
userId,
now(),
now()
);
return camelAssetFolder(db.prepare('SELECT * FROM asset_folders WHERE id = ?').get(folderId));
}
export function updateAssetFolder(folderId, payload, userId) {
const existing = db.prepare(`SELECT * FROM asset_folders WHERE id = ? AND ${visibleClause()}`).get(folderId, userId, userId);
if (!existing) return null;
db.prepare(`
UPDATE asset_folders
SET parent_id = ?, name = ?, description = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.parentId ?? existing.parent_id,
payload.name || existing.name,
payload.description ?? existing.description ?? '',
payload.visibility || existing.visibility,
(payload.visibility || existing.visibility) === 'group' ? payload.groupId ?? existing.group_id : null,
now(),
folderId
);
return camelAssetFolder(db.prepare('SELECT * FROM asset_folders WHERE id = ?').get(folderId));
}
export function deleteAssetFolder(folderId, userId) {
db.prepare(`DELETE FROM asset_folders WHERE id = ? AND ${visibleClause()}`).run(folderId, userId, userId);
}
export function listAssets(userId) {
return db.prepare(`
SELECT a.*, f.name AS folder_name, g.name AS group_name
FROM assets a
LEFT JOIN asset_folders f ON f.id = a.folder_id
LEFT JOIN groups g ON g.id = a.group_id
WHERE ${visibleClause('a')}
ORDER BY a.updated_at DESC, a.name
`).all(userId, userId).map(camelAsset);
}
export function listExecutableAssets(userId) {
return db.prepare(`
SELECT a.*, f.name AS folder_name, g.name AS group_name
FROM assets a
LEFT JOIN asset_folders f ON f.id = a.folder_id
LEFT JOIN groups g ON g.id = a.group_id
WHERE ${visibleClause('a')}
ORDER BY a.name
`).all(userId, userId).map((row) => ({
id: row.id,
name: row.name,
originalName: row.original_name,
kind: row.kind,
mimeType: row.mime_type,
sizeBytes: row.size_bytes,
checksum: row.checksum,
packagePath: row.package_path || row.original_name,
localPath: row.storage_path,
token: `{{asset:${row.id}}}`
}));
}
export function getAsset(assetId, userId) {
const row = selectAssetById(assetId, userId);
return row ? camelAsset(row) : null;
}
export function getAssetFile(assetId, userId) {
const row = selectAssetById(assetId, userId);
if (!row) return null;
const resolved = path.resolve(row.storage_path);
const fileLockerRoot = path.resolve(config.fileLockerDir);
if (!resolved.startsWith(fileLockerRoot)) throw new Error('Asset file path is outside the configured FileLocker directory');
return { asset: camelAsset(row), filePath: resolved };
}
export function createAssetFromUpload(file, payload, userId) {
if (!file?.path) throw new Error('A multipart file field named "file" is required');
const assetId = id('ast');
const originalName = sanitizeFileName(payload.originalName || file.originalname);
const mimeType = payload.mimeType || file.mimetype || 'application/octet-stream';
const kind = inferKind(mimeType, originalName);
const storagePath = storagePathFor(assetId, kind, originalName);
const checksum = checksumFile(file.path);
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
fs.renameSync(file.path, storagePath);
db.prepare(`
INSERT INTO assets (
id, folder_id, name, original_name, description, kind, mime_type, size_bytes, checksum, storage_path, package_path,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assetId,
payload.folderId || null,
payload.name || originalName,
originalName,
payload.description || '',
kind,
mimeType,
file.size,
checksum,
storagePath,
normalizePackagePath(payload.packagePath, originalName),
payload.visibility,
userId,
payload.visibility === 'group' ? payload.groupId || null : null,
userId,
now(),
now()
);
return getAsset(assetId, userId);
}
export function updateAsset(assetId, payload, userId) {
const existing = db.prepare(`SELECT * FROM assets WHERE id = ? AND ${visibleClause()}`).get(assetId, userId, userId);
if (!existing) return null;
const visibility = payload.visibility || existing.visibility;
db.prepare(`
UPDATE assets
SET folder_id = ?, name = ?, description = ?, package_path = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.folderId ?? existing.folder_id,
payload.name || existing.name,
payload.description ?? existing.description ?? '',
normalizePackagePath(payload.packagePath ?? existing.package_path, existing.original_name),
visibility,
visibility === 'group' ? payload.groupId ?? existing.group_id : null,
now(),
assetId
);
return getAsset(assetId, userId);
}
export function deleteAsset(assetId, userId) {
const row = db.prepare(`SELECT * FROM assets WHERE id = ? AND ${visibleClause()}`).get(assetId, userId, userId);
if (!row) return false;
db.prepare('DELETE FROM assets WHERE id = ?').run(assetId);
try {
fs.rmSync(row.storage_path, { force: true });
} catch {
// Metadata deletion should not fail because an external cleanup already removed the blob.
}
return true;
}
export function listAssetLinks(assetId, userId) {
const asset = selectAssetById(assetId, userId);
if (!asset) return null;
return db.prepare(`
SELECT l.*, a.name AS asset_name
FROM asset_links l
LEFT JOIN assets a ON a.id = l.asset_id
WHERE l.asset_id = ?
ORDER BY l.created_at DESC
`).all(assetId).map(camelAssetLink);
}
export function createAssetLink(assetId, payload, userId) {
const asset = selectAssetById(assetId, userId);
if (!asset) return null;
const linkId = id('alnk');
db.prepare(`
INSERT INTO asset_links (id, asset_id, target_type, target_id, link_role, package_path, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
linkId,
assetId,
payload.targetType,
payload.targetId,
payload.linkRole,
normalizePackagePath(payload.packagePath || asset.package_path || asset.original_name),
userId,
now()
);
return camelAssetLink(db.prepare(`
SELECT l.*, a.name AS asset_name
FROM asset_links l
LEFT JOIN assets a ON a.id = l.asset_id
WHERE l.id = ?
`).get(linkId));
}
export function deleteAssetLink(linkId, userId) {
const link = db.prepare(`
SELECT l.id
FROM asset_links l
INNER JOIN assets a ON a.id = l.asset_id
WHERE l.id = ? AND ${visibleClause('a')}
`).get(linkId, userId, userId);
if (!link) return false;
db.prepare('DELETE FROM asset_links WHERE id = ?').run(linkId);
return true;
}

23
server/models/bootstrapModel.js vendored Normal file
View File

@@ -0,0 +1,23 @@
import { db, visibleClause } from '../db.js';
import { config } from '../config.js';
import { settingsPayload } from './settingsModel.js';
export function getBootstrap(userId) {
return {
summary: {
scripts: db.prepare(`SELECT COUNT(*) AS count FROM scripts WHERE ${visibleClause()}`).get(userId, userId).count,
hosts: db.prepare(`SELECT COUNT(*) AS count FROM hosts WHERE ${visibleClause()}`).get(userId, userId).count,
runplans: db.prepare(`SELECT COUNT(*) AS count FROM runplans WHERE ${visibleClause()}`).get(userId, userId).count,
jobs: db.prepare(`
SELECT COUNT(*) AS count FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
WHERE j.triggered_by = ?
OR r.visibility = 'shared'
OR r.owner_user_id = ?
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
`).get(userId, userId, userId).count
},
settings: settingsPayload(),
entra: { enabled: config.entra.enabled, configured: Boolean(config.entra.tenantId && config.entra.clientId) }
};
}

View File

@@ -0,0 +1,89 @@
import { db, id, now, visibleClause } from '../db.js';
function camelChangeRequest(row) {
if (!row) return null;
return {
id: row.id,
deploymentId: row.deployment_id,
deploymentName: row.deployment_name || null,
connectionId: row.connection_id,
action: row.action,
status: row.status,
requestedBy: row.requested_by,
requesterEmail: row.requester_email || '',
approverUserId: row.approver_user_id,
approverEmail: row.approver_email || '',
reason: row.reason || '',
decidedAt: row.decided_at || '',
executedAt: row.executed_at || '',
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function createChangeRequest({ deploymentId, connectionId, action, requestedBy, requesterEmail }) {
const requestId = id('chg');
db.prepare(`
INSERT INTO change_requests (id, deployment_id, connection_id, action, status, requested_by, requester_email, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
`).run(requestId, deploymentId, connectionId || null, action, requestedBy || null, requesterEmail || '', now(), now());
return camelChangeRequest(db.prepare('SELECT * FROM change_requests WHERE id = ?').get(requestId));
}
export function findApprovedRequest(deploymentId, action) {
const row = db.prepare(`
SELECT * FROM change_requests
WHERE deployment_id = ? AND action = ? AND status = 'approved'
ORDER BY decided_at DESC LIMIT 1
`).get(deploymentId, action);
return camelChangeRequest(row);
}
export function findPendingRequest(deploymentId, action) {
const row = db.prepare(`
SELECT * FROM change_requests
WHERE deployment_id = ? AND action = ? AND status = 'pending'
ORDER BY created_at DESC LIMIT 1
`).get(deploymentId, action);
return camelChangeRequest(row);
}
export function getChangeRequest(requestId) {
return camelChangeRequest(db.prepare('SELECT * FROM change_requests WHERE id = ?').get(requestId));
}
// Requests are scoped to deployments the user can see; admins see all.
export function listChangeRequests(userId, { status, isAdmin = false } = {}) {
const where = [];
const params = [];
if (!isAdmin) {
where.push(`d.id IS NOT NULL AND ${visibleClause('d')}`);
params.push(userId, userId);
}
if (status) { where.push('c.status = ?'); params.push(status); }
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
return db.prepare(`
SELECT c.*, d.name AS deployment_name
FROM change_requests c
LEFT JOIN intune_deployments d ON d.id = c.deployment_id
${clause}
ORDER BY c.created_at DESC
LIMIT 200
`).all(...params).map(camelChangeRequest);
}
export function decideChangeRequest(requestId, { status, approverUserId, approverEmail, reason }) {
const existing = db.prepare('SELECT * FROM change_requests WHERE id = ?').get(requestId);
if (!existing || existing.status !== 'pending') return null;
db.prepare(`
UPDATE change_requests
SET status = ?, approver_user_id = ?, approver_email = ?, reason = ?, decided_at = ?, updated_at = ?
WHERE id = ?
`).run(status, approverUserId || null, approverEmail || '', reason || '', now(), now(), requestId);
return getChangeRequest(requestId);
}
export function markRequestExecuted(requestId) {
db.prepare(`UPDATE change_requests SET status = 'executed', executed_at = ?, updated_at = ? WHERE id = ?`)
.run(now(), now(), requestId);
}

View File

@@ -0,0 +1,71 @@
import { db, id, now, visibleClause } from '../db.js';
import { encryptSecret } from '../services/cryptoStore.js';
import { camelCredential } from './mappers.js';
export function listCredentials(userId) {
return db.prepare(`
SELECT c.*, g.name AS group_name
FROM credentials c
LEFT JOIN groups g ON g.id = c.group_id
WHERE ${visibleClause('c')}
ORDER BY c.name
`).all(userId, userId).map(camelCredential);
}
export function createCredential(payload, userId) {
const encrypted = encryptSecret(payload.secret);
const credentialId = id('cred');
db.prepare(`
INSERT INTO credentials
(id, name, kind, username, secret_cipher, secret_iv, secret_tag, visibility, owner_user_id, group_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
credentialId,
payload.name,
payload.kind,
payload.username || '',
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.visibility,
userId,
payload.visibility === 'group' ? payload.groupId || null : null,
userId,
now(),
now()
);
return camelCredential(db.prepare('SELECT * FROM credentials WHERE id = ?').get(credentialId));
}
export function updateCredential(credentialId, payload, userId) {
const existing = db.prepare(`SELECT * FROM credentials WHERE id = ? AND ${visibleClause()}`).get(credentialId, userId, userId);
if (!existing) return null;
const encrypted = payload.secret ? encryptSecret(payload.secret) : {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
};
const visibility = payload.visibility || existing.visibility;
db.prepare(`
UPDATE credentials
SET name = ?, kind = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name || existing.name,
payload.kind || existing.kind,
payload.username ?? existing.username,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
visibility,
visibility === 'group' ? payload.groupId || existing.group_id : null,
now(),
credentialId
);
return camelCredential(db.prepare('SELECT * FROM credentials WHERE id = ?').get(credentialId));
}
export function deleteCredential(credentialId, userId) {
db.prepare(`DELETE FROM credentials WHERE id = ? AND ${visibleClause()}`).run(credentialId, userId, userId);
}

357
server/models/graphModel.js Normal file
View File

@@ -0,0 +1,357 @@
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
import { graphConnectionSchema } from '../forms/schemas.js';
import { camelGraphConnection, camelIntuneDeploymentStatus, camelIntuneDeploymentSyncRun } from './mappers.js';
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
function normalizeConnection(body, existing = {}) {
const parsed = graphConnectionSchema.parse({ ...existing, ...body });
const cloudDefaults = cloudEndpointDefaults(parsed.cloud);
return {
...parsed,
graphBaseUrl: body.graphBaseUrl || existing.graphBaseUrl || cloudDefaults.graphBaseUrl,
authorityHost: body.authorityHost || existing.authorityHost || cloudDefaults.authorityHost,
enabled: Boolean(parsed.enabled),
allowWrite: Boolean(parsed.allowWrite),
requireApproval: Boolean(parsed.requireApproval),
publisherIds: Array.isArray(parsed.publisherIds) ? parsed.publisherIds : [],
approverIds: Array.isArray(parsed.approverIds) ? parsed.approverIds : [],
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
export function cloudEndpointDefaults(cloud = 'global') {
const endpoints = {
global: { graphBaseUrl: 'https://graph.microsoft.com', authorityHost: 'https://login.microsoftonline.com' },
gcc: { graphBaseUrl: 'https://graph.microsoft.com', authorityHost: 'https://login.microsoftonline.com' },
gcchigh: { graphBaseUrl: 'https://graph.microsoft.us', authorityHost: 'https://login.microsoftonline.us' },
dod: { graphBaseUrl: 'https://dod-graph.microsoft.us', authorityHost: 'https://login.microsoftonline.us' },
china: { graphBaseUrl: 'https://microsoftgraph.chinacloudapi.cn', authorityHost: 'https://login.chinacloudapi.cn' }
};
return endpoints[cloud] || endpoints.global;
}
export function listGraphConnections(userId) {
return db.prepare(`
SELECT c.*, g.name AS group_name
FROM graph_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE ${visibleClause('c')}
ORDER BY c.name
`).all(userId, userId).map(camelGraphConnection);
}
export function getGraphConnection(connectionId, userId, includeSecret = false) {
const row = db.prepare(`
SELECT c.*, g.name AS group_name
FROM graph_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE c.id = ? AND ${visibleClause('c')}
`).get(connectionId, userId, userId);
if (!row) return null;
return includeSecret ? { ...row, clientSecret: decryptSecret(row) } : camelGraphConnection(row);
}
export function createGraphConnection(body, userId) {
const payload = normalizeConnection(body);
const encrypted = encryptSecret(payload.clientSecret);
const connectionId = id('graph');
db.prepare(`
INSERT INTO graph_connections (
id, name, tenant_id, client_id, secret_cipher, secret_iv, secret_tag, cloud,
graph_base_url, authority_host, default_api_version, enabled, allow_write, require_approval,
publisher_ids, approver_ids, visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
connectionId,
payload.name,
payload.tenantId,
payload.clientId,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.cloud,
payload.graphBaseUrl,
payload.authorityHost,
payload.defaultApiVersion,
payload.enabled ? 1 : 0,
payload.allowWrite ? 1 : 0,
payload.requireApproval ? 1 : 0,
json(payload.publisherIds, []),
json(payload.approverIds, []),
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return getGraphConnection(connectionId, userId);
}
export function updateGraphConnection(connectionId, body, userId) {
const existing = getGraphConnection(connectionId, userId, true);
if (!existing) return null;
const payload = normalizeConnection(body, {
name: existing.name,
tenantId: existing.tenant_id,
clientId: existing.client_id,
cloud: existing.cloud,
graphBaseUrl: existing.graph_base_url,
authorityHost: existing.authority_host,
defaultApiVersion: existing.default_api_version,
enabled: Boolean(existing.enabled),
allowWrite: Boolean(existing.allow_write),
requireApproval: Boolean(existing.require_approval),
publisherIds: parseJson(existing.publisher_ids, []),
approverIds: parseJson(existing.approver_ids, []),
visibility: existing.visibility,
groupId: existing.group_id
});
const encrypted = payload.clientSecret ? encryptSecret(payload.clientSecret) : {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
};
db.prepare(`
UPDATE graph_connections
SET name = ?, tenant_id = ?, client_id = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
cloud = ?, graph_base_url = ?, authority_host = ?, default_api_version = ?, enabled = ?, allow_write = ?, require_approval = ?,
publisher_ids = ?, approver_ids = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.tenantId,
payload.clientId,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.cloud,
payload.graphBaseUrl,
payload.authorityHost,
payload.defaultApiVersion,
payload.enabled ? 1 : 0,
payload.allowWrite ? 1 : 0,
payload.requireApproval ? 1 : 0,
json(payload.publisherIds, []),
json(payload.approverIds, []),
payload.visibility,
payload.groupId,
now(),
connectionId
);
return getGraphConnection(connectionId, userId);
}
export function deleteGraphConnection(connectionId, userId) {
db.prepare(`DELETE FROM graph_connections WHERE id = ? AND ${visibleClause()}`).run(connectionId, userId, userId);
}
// Immutable record of every tenant-changing Graph action (publish, assign,
// relationship edits). Stores the actor, request payload, and Graph response so
// changes are auditable and replayable.
export function recordGraphAudit(entry) {
const auditId = id('gaud');
db.prepare(`
INSERT INTO graph_audit_log (
id, connection_id, deployment_id, actor_user_id, actor_email, action,
target_graph_id, status, message, request_json, response_json, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
auditId,
entry.connectionId || null,
entry.deploymentId || null,
entry.actorUserId || null,
entry.actorEmail || '',
entry.action,
entry.targetGraphId || '',
entry.status,
entry.message || '',
json(entry.request || {}, {}),
json(entry.response || {}, {}),
now()
);
return auditId;
}
export function listGraphAudit({ deploymentId, connectionId, limit = 100 } = {}) {
const clauses = [];
const params = [];
if (deploymentId) { clauses.push('deployment_id = ?'); params.push(deploymentId); }
if (connectionId) { clauses.push('connection_id = ?'); params.push(connectionId); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
return db.prepare(`
SELECT * FROM graph_audit_log ${where} ORDER BY created_at DESC LIMIT ?
`).all(...params, Number(limit) || 100).map((row) => ({
id: row.id,
connectionId: row.connection_id,
deploymentId: row.deployment_id,
actorUserId: row.actor_user_id,
actorEmail: row.actor_email || '',
action: row.action,
targetGraphId: row.target_graph_id || '',
status: row.status,
message: row.message || '',
request: parseJson(row.request_json, {}),
response: parseJson(row.response_json, {}),
createdAt: row.created_at
}));
}
// Read just the governance lists for a connection, without visibility scoping,
// so designated approvers (who may not "own" the connection) can be authorized.
// Full connection row + decrypted secret without visibility scope, for the
// background auto-promote worker (system context).
export function getGraphConnectionSystem(connectionId) {
const row = db.prepare('SELECT * FROM graph_connections WHERE id = ?').get(connectionId);
if (!row) return null;
return { ...row, clientSecret: decryptSecret(row) };
}
export function getConnectionGovernance(connectionId) {
const row = db.prepare('SELECT publisher_ids, approver_ids, require_approval FROM graph_connections WHERE id = ?').get(connectionId);
if (!row) return null;
return {
publisherIds: parseJson(row.publisher_ids, []),
approverIds: parseJson(row.approver_ids, []),
requireApproval: Boolean(row.require_approval)
};
}
export function recordGraphConnectionTest(connectionId, status, message) {
db.prepare(`
UPDATE graph_connections
SET last_test_status = ?, last_test_message = ?, last_test_at = ?, updated_at = ?
WHERE id = ?
`).run(status, message, now(), now(), connectionId);
}
export function upsertDeploymentStatuses(deploymentId, connectionId, graphAppId, rows = []) {
const timestamp = now();
const stmt = db.prepare(`
INSERT INTO intune_deployment_statuses (
id, deployment_id, connection_id, graph_app_id, scope, principal_id, principal_name,
device_id, device_name, user_id, user_principal_name, install_state, install_state_detail,
error_code, error_description, last_sync_datetime, raw_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
db.prepare('DELETE FROM intune_deployment_statuses WHERE deployment_id = ? AND connection_id = ? AND graph_app_id = ?')
.run(deploymentId, connectionId, graphAppId);
for (const row of rows) {
stmt.run(
id('ids'),
deploymentId,
connectionId,
graphAppId,
row.scope || 'device',
row.principalId || '',
row.principalName || '',
row.deviceId || '',
row.deviceName || '',
row.userId || '',
row.userPrincipalName || '',
row.installState || 'unknown',
row.installStateDetail || '',
row.errorCode == null ? '' : String(row.errorCode),
row.errorDescription || '',
row.lastSyncDateTime || '',
json(row.raw || {}, {}),
timestamp,
timestamp
);
}
}
export function createSyncRun(deploymentId, connectionId, graphAppId) {
const runId = id('sync');
db.prepare(`
INSERT INTO intune_deployment_sync_runs (id, deployment_id, connection_id, graph_app_id, status, started_at, summary)
VALUES (?, ?, ?, ?, 'running', ?, '{}')
`).run(runId, deploymentId, connectionId, graphAppId, now());
return runId;
}
export function finishSyncRun(runId, status, message, summary = {}) {
db.prepare(`
UPDATE intune_deployment_sync_runs
SET status = ?, message = ?, summary = ?, ended_at = ?
WHERE id = ?
`).run(status, message || '', json(summary, {}), now(), runId);
}
export function listDeploymentStatuses(deploymentId) {
return db.prepare(`
SELECT * FROM intune_deployment_statuses
WHERE deployment_id = ?
ORDER BY
CASE install_state WHEN 'failed' THEN 0 WHEN 'error' THEN 1 WHEN 'pending' THEN 2 WHEN 'installed' THEN 3 ELSE 4 END,
updated_at DESC
`).all(deploymentId).map(camelIntuneDeploymentStatus);
}
// Cross-deployment rollout health for the governance/reporting view. Scoped to
// the deployments the operator can see (admins see all).
export function intuneReportingOverview(userId, isAdmin = false) {
const scope = isAdmin ? '1=1' : visibleClause('d');
const params = isAdmin ? [] : [userId, userId];
const deploymentsByStatus = db.prepare(`
SELECT status, COUNT(*) AS count FROM intune_deployments d WHERE ${scope} GROUP BY status
`).all(...params);
const installStateCounts = db.prepare(`
SELECT s.install_state AS state, COUNT(*) AS count
FROM intune_deployment_statuses s
JOIN intune_deployments d ON d.id = s.deployment_id
WHERE ${scope}
GROUP BY s.install_state
`).all(...params);
const topErrors = db.prepare(`
SELECT s.error_code AS code, COUNT(*) AS count
FROM intune_deployment_statuses s
JOIN intune_deployments d ON d.id = s.deployment_id
WHERE ${scope} AND s.error_code IS NOT NULL AND s.error_code <> ''
GROUP BY s.error_code
ORDER BY count DESC
LIMIT 10
`).all(...params);
const writeActions = db.prepare(`
SELECT a.action, a.status, COUNT(*) AS count
FROM graph_audit_log a
JOIN intune_deployments d ON d.id = a.deployment_id
WHERE ${scope}
GROUP BY a.action, a.status
`).all(...params);
// SLA / soak timers: deployments mid-soak, with hours remaining until the
// auto-promote window elapses (negative = overdue for promotion).
const soakTimers = db.prepare(`
SELECT d.id, d.name, d.auto_promote_ring AS ring, d.auto_promote_after_hours AS hours, d.soak_started_at AS soakStartedAt,
ROUND(d.auto_promote_after_hours - (julianday('now') - julianday(d.soak_started_at)) * 24, 1) AS hoursRemaining
FROM intune_deployments d
WHERE d.auto_promote_after_hours > 0 AND d.soak_started_at IS NOT NULL AND d.soak_started_at <> ''
AND (d.promoted_at IS NULL OR d.promoted_at = '') AND ${scope}
ORDER BY hoursRemaining ASC
`).all(...params);
// Write-action trend over the last 14 days, by day and outcome.
const auditTrend = db.prepare(`
SELECT substr(a.created_at, 1, 10) AS day, a.status, COUNT(*) AS count
FROM graph_audit_log a
JOIN intune_deployments d ON d.id = a.deployment_id
WHERE ${scope} AND a.created_at >= date('now', '-14 days')
GROUP BY day, a.status
ORDER BY day
`).all(...params);
return { deploymentsByStatus, installStateCounts, topErrors, writeActions, soakTimers, auditTrend };
}
export function listDeploymentSyncRuns(deploymentId) {
return db.prepare(`
SELECT * FROM intune_deployment_sync_runs
WHERE deployment_id = ?
ORDER BY started_at DESC
LIMIT 20
`).all(deploymentId).map(camelIntuneDeploymentSyncRun);
}

View File

@@ -0,0 +1,25 @@
import { db, id, now } from '../db.js';
import { camelGroup } from './mappers.js';
export function listGroups() {
return db.prepare(`
SELECT g.*, COUNT(ug.user_id) AS member_count
FROM groups g
LEFT JOIN user_groups ug ON ug.group_id = g.id
GROUP BY g.id
ORDER BY g.name
`).all().map((group) => ({ ...camelGroup(group), memberCount: group.member_count }));
}
export function createGroup(payload) {
const groupId = id('grp');
db.prepare('INSERT INTO groups (id, name, description, created_at) VALUES (?, ?, ?, ?)').run(
groupId,
payload.name,
payload.description || '',
now()
);
const stmt = db.prepare('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)');
for (const userId of payload.userIds) stmt.run(userId, groupId);
return camelGroup(db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId));
}

View File

@@ -0,0 +1,20 @@
import { helpCatalog, helpCategories, helpSearch } from '../data/helpCatalog.js';
export function listHelp({ query = '', category = '' } = {}) {
const articles = helpSearch(query, category);
return {
categories: helpCategories(),
total: articles.length,
articles: articles.map((item) => ({
id: item.id,
category: item.category,
title: item.title,
summary: item.summary,
tags: item.tags || []
}))
};
}
export function getHelpArticle(articleId) {
return helpCatalog().find((item) => item.id === articleId) || null;
}

View File

@@ -0,0 +1,84 @@
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
import { camelHost } from './mappers.js';
export function listHosts(userId) {
return db.prepare(`
SELECT h.*, c.name AS credential_name, g.name AS group_name
FROM hosts h
LEFT JOIN credentials c ON c.id = h.credential_id
LEFT JOIN groups g ON g.id = h.group_id
WHERE ${visibleClause('h')}
ORDER BY h.name
`).all(userId, userId).map(camelHost);
}
function scopedGroupId(payload) {
return payload.visibility === 'group' ? payload.groupId || null : null;
}
export function createHost(payload, userId) {
const hostId = id('hst');
db.prepare(`
INSERT INTO hosts (id, name, fqdn, address, os_family, transport, port, credential_id, tags, notes,
visibility, owner_user_id, group_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
hostId,
payload.name,
payload.fqdn || '',
payload.address,
payload.osFamily,
payload.transport,
payload.port || null,
payload.credentialId || null,
json(payload.tags),
payload.notes || '',
payload.visibility || 'shared',
userId,
scopedGroupId(payload),
now(),
now()
);
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
}
export function updateHost(hostId, payload, userId) {
const existing = db.prepare(`SELECT * FROM hosts WHERE id = ? AND ${visibleClause()}`).get(hostId, userId, userId);
if (!existing) return null;
const visibility = payload.visibility || existing.visibility;
db.prepare(`
UPDATE hosts
SET name = ?, fqdn = ?, address = ?, os_family = ?, transport = ?, port = ?, credential_id = ?,
tags = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name || existing.name,
payload.fqdn ?? existing.fqdn,
payload.address || existing.address,
payload.osFamily || existing.os_family,
payload.transport || existing.transport,
payload.port ?? existing.port,
payload.credentialId ?? existing.credential_id,
json(payload.tags ?? parseJson(existing.tags)),
payload.notes ?? existing.notes,
visibility,
visibility === 'group' ? (payload.groupId ?? existing.group_id) : null,
now(),
hostId
);
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
}
export function deleteHost(hostId, userId) {
db.prepare(`DELETE FROM hosts WHERE id = ? AND ${visibleClause()}`).run(hostId, userId, userId);
}
// Return only the host IDs from the requested set that are visible to the user.
// Used to stop a RunPlan from targeting hosts the operator cannot see.
export function filterVisibleHostIds(hostIds = [], userId) {
if (!hostIds.length) return [];
const visible = new Set(
db.prepare(`SELECT id FROM hosts WHERE ${visibleClause()}`).all(userId, userId).map((row) => row.id)
);
return hostIds.filter((hostId) => visible.has(hostId));
}

69
server/models/jobModel.js Normal file
View File

@@ -0,0 +1,69 @@
import { db } from '../db.js';
import { camelJob, camelJobHost, camelJobLog } from './mappers.js';
// A job is visible to the operator who triggered it, to anyone who can see the
// linked RunPlan, or to any admin. This prevents cross-user log/output leakage.
function jobVisibilityClause() {
return `(
j.triggered_by = ?
OR r.visibility = 'shared'
OR r.owner_user_id = ?
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)`;
}
export function listJobs(userId, isAdmin = false) {
if (isAdmin) {
return db.prepare(`
SELECT j.*, r.name AS runplan_name, s.name AS script_name
FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
LEFT JOIN scripts s ON s.id = j.script_id
ORDER BY j.created_at DESC
LIMIT 100
`).all().map(camelJob);
}
return db.prepare(`
SELECT j.*, r.name AS runplan_name, s.name AS script_name
FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
LEFT JOIN scripts s ON s.id = j.script_id
WHERE ${jobVisibilityClause()}
ORDER BY j.created_at DESC
LIMIT 100
`).all(userId, userId, userId).map(camelJob);
}
export function getJob(jobId, userId, isAdmin = false) {
const job = isAdmin
? db.prepare(`
SELECT j.*, r.name AS runplan_name, s.name AS script_name
FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
LEFT JOIN scripts s ON s.id = j.script_id
WHERE j.id = ?
`).get(jobId)
: db.prepare(`
SELECT j.*, r.name AS runplan_name, s.name AS script_name
FROM jobs j
LEFT JOIN runplans r ON r.id = j.runplan_id
LEFT JOIN scripts s ON s.id = j.script_id
WHERE j.id = ? AND ${jobVisibilityClause()}
`).get(jobId, userId, userId, userId);
if (!job) return null;
const hosts = db.prepare(`
SELECT jh.*, h.name AS host_name, h.address AS host_address
FROM job_hosts jh
LEFT JOIN hosts h ON h.id = jh.host_id
WHERE jh.job_id = ?
ORDER BY h.name
`).all(jobId);
const logs = db.prepare(`
SELECT jl.*, h.name AS host_name
FROM job_logs jl
LEFT JOIN hosts h ON h.id = jl.host_id
WHERE jl.job_id = ?
ORDER BY jl.created_at, jl.id
`).all(jobId);
return { ...camelJob(job), hosts: hosts.map(camelJobHost), logs: logs.map(camelJobLog) };
}

View File

@@ -0,0 +1,158 @@
import { db, id, now, visibleClause } from '../db.js';
import { camelFolder, camelIntuneDeployment, camelRunPlan, camelScript } from './mappers.js';
import { normalizeScriptPayload } from '../forms/schemas.js';
export function listFolders(userId) {
return db.prepare(`SELECT * FROM folders WHERE ${visibleClause()} ORDER BY name`).all(userId, userId).map(camelFolder);
}
export function createFolder(payload, userId) {
const folderId = id('fld');
db.prepare(`
INSERT INTO folders (id, parent_id, name, visibility, owner_user_id, group_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
folderId,
payload.parentId || null,
payload.name,
payload.visibility,
userId,
payload.groupId || null,
now(),
now()
);
return camelFolder(db.prepare('SELECT * FROM folders WHERE id = ?').get(folderId));
}
export function updateFolder(folderId, payload, userId) {
const existing = db.prepare(`SELECT * FROM folders WHERE id = ? AND ${visibleClause()}`).get(folderId, userId, userId);
if (!existing) return null;
db.prepare(`
UPDATE folders SET parent_id = ?, name = ?, visibility = ?, group_id = ?, updated_at = ? WHERE id = ?
`).run(
payload.parentId ?? existing.parent_id,
payload.name || existing.name,
payload.visibility || existing.visibility,
payload.groupId ?? existing.group_id,
now(),
folderId
);
return camelFolder(db.prepare('SELECT * FROM folders WHERE id = ?').get(folderId));
}
export function deleteFolder(folderId, userId) {
db.prepare(`DELETE FROM folders WHERE id = ? AND ${visibleClause()}`).run(folderId, userId, userId);
}
export function listScripts(userId) {
return db.prepare(`
SELECT s.*, f.name AS folder_name, g.name AS group_name
FROM scripts s
LEFT JOIN folders f ON f.id = s.folder_id
LEFT JOIN groups g ON g.id = s.group_id
WHERE ${visibleClause('s')}
ORDER BY s.name
`).all(userId, userId).map(camelScript);
}
export function createScript(body, userId) {
const payload = normalizeScriptPayload(body);
const scriptId = id('scr');
db.prepare(`
INSERT INTO scripts (id, folder_id, name, description, content, visibility, owner_user_id, group_id, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
`).run(scriptId, payload.folderId, payload.name, payload.description, payload.content, payload.visibility, userId, payload.groupId, now(), now());
db.prepare('INSERT INTO script_versions (id, script_id, version, content, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?)').run(
id('ver'),
scriptId,
payload.content,
userId,
now()
);
return camelScript(db.prepare('SELECT * FROM scripts WHERE id = ?').get(scriptId));
}
export function cloneScript(scriptId, body, userId) {
const existing = getScript(scriptId, userId);
if (!existing) return null;
return createScript({
folderId: existing.folderId,
name: body.name || cloneName(existing.name),
description: body.description ?? existing.description ?? '',
content: existing.content,
visibility: existing.visibility,
groupId: existing.groupId
}, userId);
}
export function getScript(scriptId, userId) {
const row = db.prepare(`SELECT * FROM scripts WHERE id = ? AND ${visibleClause()}`).get(scriptId, userId, userId);
return row ? camelScript(row) : null;
}
export function updateScript(scriptId, body, userId) {
const existing = db.prepare(`SELECT * FROM scripts WHERE id = ? AND ${visibleClause()}`).get(scriptId, userId, userId);
if (!existing) return null;
const payload = normalizeScriptPayload({ ...existing, ...body });
const version = Number(existing.version) + 1;
db.prepare(`
UPDATE scripts
SET folder_id = ?, name = ?, description = ?, content = ?, visibility = ?, group_id = ?, version = ?, updated_at = ?
WHERE id = ?
`).run(payload.folderId, payload.name, payload.description, payload.content, payload.visibility, payload.groupId, version, now(), scriptId);
db.prepare('INSERT INTO script_versions (id, script_id, version, content, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(
id('ver'),
scriptId,
version,
payload.content,
userId,
now()
);
return camelScript(db.prepare('SELECT * FROM scripts WHERE id = ?').get(scriptId));
}
export function deleteScript(scriptId, userId) {
db.prepare(`DELETE FROM scripts WHERE id = ? AND ${visibleClause()}`).run(scriptId, userId, userId);
}
export function scriptUsage(scriptId, userId) {
const script = getScript(scriptId, userId);
if (!script) return null;
const runplans = db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
WHERE r.script_id = ? AND ${visibleClause('r')}
ORDER BY r.updated_at DESC
`).all(scriptId, userId, userId).map(camelRunPlan);
const intuneDeployments = db.prepare(`
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
FROM intune_deployments d
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN groups g ON g.id = d.group_id
WHERE d.script_id = ? AND ${visibleClause('d')}
ORDER BY d.updated_at DESC
`).all(scriptId, userId, userId).map(camelIntuneDeployment);
return {
script,
runplans,
intuneDeployments,
total: runplans.length + intuneDeployments.length
};
}
export function listScriptVersions(scriptId, userId) {
const script = db.prepare(`SELECT id FROM scripts WHERE id = ? AND ${visibleClause()}`).get(scriptId, userId, userId);
if (!script) return null;
return db.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC').all(scriptId);
}
function cloneName(name = 'Untitled Script.ps1') {
const match = String(name).match(/^(.*?)(\.[^.]+)?$/);
const base = match?.[1] || name;
const extension = match?.[2] || '';
return `${base} copy${extension}`;
}

351
server/models/mappers.js Normal file
View File

@@ -0,0 +1,351 @@
import { parseJson } from '../db.js';
export function camelGroup(row) {
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at };
}
export function camelCredential(row) {
return {
id: row.id,
name: row.name,
kind: row.kind,
username: row.username,
hasSecret: Boolean(row.secret_cipher),
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelHost(row) {
return {
id: row.id,
name: row.name,
fqdn: row.fqdn,
address: row.address,
osFamily: row.os_family,
transport: row.transport,
port: row.port,
credentialId: row.credential_id,
credentialName: row.credential_name || null,
tags: parseJson(row.tags),
notes: row.notes,
visibility: row.visibility || 'shared',
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelFolder(row) {
return {
id: row.id,
parentId: row.parent_id,
name: row.name,
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelScript(row) {
return {
id: row.id,
folderId: row.folder_id,
folderName: row.folder_name || null,
name: row.name,
description: row.description,
content: row.content,
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
version: row.version,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelAssetFolder(row) {
return {
id: row.id,
parentId: row.parent_id,
name: row.name,
description: row.description || '',
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelAsset(row) {
return {
id: row.id,
folderId: row.folder_id,
folderName: row.folder_name || null,
name: row.name,
originalName: row.original_name,
description: row.description || '',
kind: row.kind || 'generic',
mimeType: row.mime_type || 'application/octet-stream',
sizeBytes: row.size_bytes || 0,
checksum: row.checksum,
packagePath: row.package_path || '',
referenceToken: `{{asset:${row.id}}}`,
downloadUrl: `/api/assets/${row.id}/download`,
viewUrl: `/api/assets/${row.id}/view`,
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelAssetLink(row) {
return {
id: row.id,
assetId: row.asset_id,
assetName: row.asset_name || null,
targetType: row.target_type,
targetId: row.target_id,
linkRole: row.link_role,
packagePath: row.package_path || '',
createdBy: row.created_by,
createdAt: row.created_at
};
}
export function camelRunPlan(row) {
return {
id: row.id,
name: row.name,
description: row.description,
scriptId: row.script_id,
scriptName: row.script_name,
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
parallel: Boolean(row.parallel),
hostCount: row.host_count || 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelCustomVariable(row, includeValue = false) {
return {
id: row.id,
name: row.name,
syntax: `$${row.name}`,
token: `{{${row.name}}}`,
description: row.description || '',
category: row.category || 'Custom',
valueType: row.value_type || 'string',
value: includeValue ? row.value : undefined,
hasValue: Boolean(row.secret_cipher),
sensitive: Boolean(row.sensitive),
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
source: 'custom',
readOnly: false,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelPsadtProfile(row) {
return {
id: row.id,
name: row.name,
appVendor: row.app_vendor || '',
appName: row.app_name || '',
appVersion: row.app_version || '',
appArch: row.app_arch || 'x64',
appLang: row.app_lang || 'EN',
appRevision: row.app_revision || '01',
toolkitVersion: row.toolkit_version || 'v4',
templateVersion: row.template_version || 'v4',
deploymentType: row.deployment_type || 'Install',
deployMode: row.deploy_mode || 'Interactive',
requireAdmin: Boolean(row.require_admin),
zeroConfig: Boolean(row.zero_config),
suppressReboot: Boolean(row.suppress_reboot),
closeProcesses: parseJson(row.close_processes),
installTasks: parseJson(row.install_tasks),
uiPlan: parseJson(row.ui_plan, {}),
configPlan: parseJson(row.config_plan, {}),
admxPlan: parseJson(row.admx_plan, {}),
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelIntuneDeployment(row) {
return {
id: row.id,
name: row.name,
profileId: row.profile_id || null,
profileName: row.profile_name || null,
scriptId: row.script_id || null,
scriptName: row.script_name || null,
applicationId: row.application_id || null,
sourceFolder: row.source_folder || '',
intunewinFile: row.intunewin_file || '',
appType: row.app_type,
commandStyle: row.command_style || 'v4',
installBehavior: row.install_behavior || 'system',
restartBehavior: row.restart_behavior || 'return-code',
uiMode: row.ui_mode || 'native-v4',
requirements: parseJson(row.requirements, {}),
installCommand: row.install_command,
uninstallCommand: row.uninstall_command,
detectionType: row.detection_type,
detectionRule: row.detection_rule || '',
returnCodes: parseJson(row.return_codes),
assignments: parseJson(row.assignments),
relationships: parseJson(row.relationships),
status: row.status,
autoPromoteAfterHours: row.auto_promote_after_hours || 0,
autoPromoteRing: row.auto_promote_ring || '',
autoPromoteIntent: row.auto_promote_intent || 'required',
soakStartedAt: row.soak_started_at || '',
promotedAt: row.promoted_at || '',
graphAppId: row.graph_app_id || '',
graphConnectionId: row.graph_connection_id || '',
notes: row.notes || '',
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelGraphConnection(row) {
return {
id: row.id,
name: row.name,
tenantId: row.tenant_id,
clientId: row.client_id,
hasClientSecret: Boolean(row.secret_cipher),
cloud: row.cloud || 'global',
graphBaseUrl: row.graph_base_url || 'https://graph.microsoft.com',
authorityHost: row.authority_host || 'https://login.microsoftonline.com',
defaultApiVersion: row.default_api_version || 'v1.0',
enabled: Boolean(row.enabled),
allowWrite: Boolean(row.allow_write),
requireApproval: Boolean(row.require_approval),
publisherIds: parseJson(row.publisher_ids, []),
approverIds: parseJson(row.approver_ids, []),
lastTestStatus: row.last_test_status || '',
lastTestMessage: row.last_test_message || '',
lastTestAt: row.last_test_at || '',
visibility: row.visibility,
ownerUserId: row.owner_user_id,
groupId: row.group_id,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelIntuneDeploymentStatus(row) {
return {
id: row.id,
deploymentId: row.deployment_id,
connectionId: row.connection_id,
graphAppId: row.graph_app_id,
scope: row.scope,
principalId: row.principal_id || '',
principalName: row.principal_name || '',
deviceId: row.device_id || '',
deviceName: row.device_name || '',
userId: row.user_id || '',
userPrincipalName: row.user_principal_name || '',
installState: row.install_state || 'unknown',
installStateDetail: row.install_state_detail || '',
errorCode: row.error_code || '',
errorDescription: row.error_description || '',
lastSyncDateTime: row.last_sync_datetime || '',
raw: parseJson(row.raw_json, {}),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelIntuneDeploymentSyncRun(row) {
return {
id: row.id,
deploymentId: row.deployment_id,
connectionId: row.connection_id,
graphAppId: row.graph_app_id,
status: row.status,
message: row.message || '',
summary: parseJson(row.summary, {}),
startedAt: row.started_at,
endedAt: row.ended_at
};
}
export function camelJob(row) {
return {
id: row.id,
runplanId: row.runplan_id,
runplanName: row.runplan_name || null,
scriptId: row.script_id,
scriptName: row.script_name || null,
status: row.status,
triggeredBy: row.triggered_by,
startedAt: row.started_at,
endedAt: row.ended_at,
createdAt: row.created_at
};
}
export function camelJobHost(row) {
return {
id: row.id,
jobId: row.job_id,
hostId: row.host_id,
hostName: row.host_name,
hostAddress: row.host_address,
status: row.status,
exitCode: row.exit_code,
startedAt: row.started_at,
endedAt: row.ended_at
};
}
export function camelJobLog(row) {
return {
id: row.id,
jobId: row.job_id,
hostId: row.host_id,
hostName: row.host_name,
stream: row.stream,
line: row.line,
createdAt: row.created_at
};
}

View File

@@ -0,0 +1,77 @@
import bcrypt from 'bcryptjs';
import { db, now } from '../db.js';
import { publicUser } from '../auth.js';
import { config } from '../config.js';
function fullUser(userId) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
}
export function updateProfile(userId, payload) {
const current = fullUser(userId);
if (!current) throw new Error('User not found');
const nextEmail = current.auth_provider === 'local'
? (payload.email || current.email).toLowerCase()
: current.email;
const duplicate = db.prepare('SELECT id FROM users WHERE email = ? AND id <> ?').get(nextEmail, userId);
if (duplicate) throw new Error('Email address is already in use');
db.prepare(`
UPDATE users
SET email = ?,
display_name = ?,
job_title = ?,
phone = ?,
timezone = ?,
avatar_url = ?,
updated_at = ?
WHERE id = ?
`).run(
nextEmail,
payload.displayName,
payload.jobTitle || '',
payload.phone || '',
payload.timezone || '',
payload.avatarUrl || '',
now(),
userId
);
return publicUser(fullUser(userId));
}
export function updateTheme(userId, theme) {
db.prepare('UPDATE users SET theme = ?, updated_at = ? WHERE id = ?').run(theme, now(), userId);
return publicUser(fullUser(userId));
}
export function changePassword(userId, payload) {
const current = fullUser(userId);
if (!current) throw new Error('User not found');
if (current.auth_provider !== 'local') {
return {
external: true,
provider: current.auth_provider || 'entra',
redirectUrl: config.entra.passwordChangeUrl
};
}
if (!current.password_hash || !bcrypt.compareSync(payload.currentPassword || '', current.password_hash)) {
throw new Error('Current password is incorrect');
}
if (!payload.newPassword || payload.newPassword.length < 8) {
throw new Error('New password must be at least 8 characters');
}
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
bcrypt.hashSync(payload.newPassword, 12),
now(),
userId
);
return { changed: true };
}

539
server/models/psadtModel.js Normal file
View File

@@ -0,0 +1,539 @@
import { db, id, json, now, visibleClause } from '../db.js';
import { intuneDeploymentSchema, psadtProfileSchema } from '../forms/schemas.js';
import { psadtCatalog } from '../data/psadtCatalog.js';
import { camelIntuneDeployment, camelPsadtProfile } from './mappers.js';
function normalizeProfile(body, existing = {}) {
const parsed = psadtProfileSchema.parse({ ...existing, ...body });
return {
...parsed,
appName: parsed.zeroConfig ? '' : parsed.appName,
requireAdmin: Boolean(parsed.requireAdmin),
zeroConfig: Boolean(parsed.zeroConfig),
suppressReboot: Boolean(parsed.suppressReboot),
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
function normalizeIntuneDeployment(body, existing = {}, userId = null) {
const parsed = intuneDeploymentSchema.parse({ ...existing, ...body });
const rendered = parsed.profileId ? renderPsadtProfile(parsed.profileId, userId) : null;
const defaultCommands = commandsForStyle(parsed.commandStyle, parsed.restartBehavior);
return {
...parsed,
profileId: parsed.profileId || null,
scriptId: parsed.scriptId || null,
applicationId: parsed.applicationId || null,
sourceFolder: parsed.sourceFolder || '',
intunewinFile: parsed.intunewinFile || '',
graphAppId: parsed.graphAppId || '',
graphConnectionId: parsed.graphConnectionId || null,
requirements: {
architecture: parsed.requirements?.architecture || 'x64',
minOs: parsed.requirements?.minOs || 'Windows 10 22H2',
diskSpaceMb: Number(parsed.requirements?.diskSpaceMb || 0),
runAs32Bit: Boolean(parsed.requirements?.runAs32Bit)
},
installCommand: parsed.installCommand || (parsed.commandStyle === 'v4' ? rendered?.intuneInstallCommand : '') || defaultCommands.install,
uninstallCommand: parsed.uninstallCommand || (parsed.commandStyle === 'v4' ? rendered?.intuneUninstallCommand : '') || defaultCommands.uninstall,
returnCodes: parsed.returnCodes.length ? parsed.returnCodes : [
{ code: 0, type: 'success', meaning: 'Install completed successfully.' },
{ code: 1602, type: 'retry', meaning: 'User deferred or cancelled; let Intune retry.' },
{ code: 1703, type: 'softReboot', meaning: 'Legacy soft reboot mapping used by some PSADT Intune examples.' },
{ code: 3010, type: 'softReboot', meaning: 'Install completed and a reboot is required.' },
{ code: 60008, type: 'failed', meaning: 'PSADT initialization failure.' }
],
assignments: parsed.assignments.length ? parsed.assignments : [
{ ring: 'Pilot', intent: 'available', groupName: '', notes: 'Validation ring before broad assignment.' }
],
relationships: parsed.relationships || [],
autoPromoteAfterHours: Number(parsed.autoPromoteAfterHours) || 0,
autoPromoteRing: parsed.autoPromoteRing || '',
autoPromoteIntent: parsed.autoPromoteIntent || 'required',
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
function commandsForStyle(commandStyle = 'v4', restartBehavior = 'return-code') {
const suppress = restartBehavior === 'suppress' ? ' -SuppressRebootPassThru' : '';
if (commandStyle === 'legacy-v3') {
return {
install: `Deploy-Application.exe -DeploymentType "Install" -DeployMode "Interactive"${suppress}`,
uninstall: `Deploy-Application.exe -DeploymentType "Uninstall" -DeployMode "Interactive"${suppress}`
};
}
return {
install: `Invoke-AppDeployToolkit.exe -DeploymentType Install${suppress}`,
uninstall: `Invoke-AppDeployToolkit.exe -DeploymentType Uninstall${suppress}`
};
}
export function getPsadtCatalog() {
return psadtCatalog();
}
export function listIntuneDeployments(userId) {
return db.prepare(`
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
FROM intune_deployments d
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN groups g ON g.id = d.group_id
WHERE ${visibleClause('d')}
ORDER BY d.updated_at DESC
`).all(userId, userId).map(camelIntuneDeployment);
}
export function loadIntuneDeployment(deploymentId, userId) {
const row = db.prepare(`
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
FROM intune_deployments d
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN groups g ON g.id = d.group_id
WHERE d.id = ? AND ${visibleClause('d')}
`).get(deploymentId, userId, userId);
return row ? camelIntuneDeployment(row) : null;
}
export function createIntuneDeployment(body, userId) {
const payload = normalizeIntuneDeployment(body, {}, userId);
const deploymentId = id('intune');
db.prepare(`
INSERT INTO intune_deployments (
id, name, profile_id, script_id, application_id, source_folder, intunewin_file, app_type,
command_style, install_behavior, restart_behavior, ui_mode, requirements,
install_command, uninstall_command, detection_type, detection_rule, return_codes, assignments, relationships, status, graph_app_id, graph_connection_id, notes,
auto_promote_after_hours, auto_promote_ring, auto_promote_intent,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
deploymentId,
payload.name,
payload.profileId,
payload.scriptId,
payload.applicationId,
payload.sourceFolder,
payload.intunewinFile,
payload.appType,
payload.commandStyle,
payload.installBehavior,
payload.restartBehavior,
payload.uiMode,
json(payload.requirements, {}),
payload.installCommand,
payload.uninstallCommand,
payload.detectionType,
payload.detectionRule,
json(payload.returnCodes),
json(payload.assignments),
json(payload.relationships),
payload.status,
payload.graphAppId,
payload.graphConnectionId,
payload.notes,
payload.autoPromoteAfterHours,
payload.autoPromoteRing,
payload.autoPromoteIntent,
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return loadIntuneDeployment(deploymentId, userId);
}
export function updateIntuneDeployment(deploymentId, body, userId) {
const existing = loadIntuneDeployment(deploymentId, userId);
if (!existing) return null;
const payload = normalizeIntuneDeployment(body, existing, userId);
db.prepare(`
UPDATE intune_deployments
SET name = ?, profile_id = ?, script_id = ?, application_id = ?, source_folder = ?, intunewin_file = ?,
app_type = ?, command_style = ?, install_behavior = ?, restart_behavior = ?,
ui_mode = ?, requirements = ?, install_command = ?, uninstall_command = ?,
detection_type = ?, detection_rule = ?, return_codes = ?, assignments = ?, relationships = ?,
status = ?, graph_app_id = ?, graph_connection_id = ?, notes = ?,
auto_promote_after_hours = ?, auto_promote_ring = ?, auto_promote_intent = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.profileId,
payload.scriptId,
payload.applicationId,
payload.sourceFolder,
payload.intunewinFile,
payload.appType,
payload.commandStyle,
payload.installBehavior,
payload.restartBehavior,
payload.uiMode,
json(payload.requirements, {}),
payload.installCommand,
payload.uninstallCommand,
payload.detectionType,
payload.detectionRule,
json(payload.returnCodes),
json(payload.assignments),
json(payload.relationships),
payload.status,
payload.graphAppId,
payload.graphConnectionId,
payload.notes,
payload.autoPromoteAfterHours,
payload.autoPromoteRing,
payload.autoPromoteIntent,
payload.visibility,
payload.groupId,
now(),
deploymentId
);
return loadIntuneDeployment(deploymentId, userId);
}
export function deleteIntuneDeployment(deploymentId, userId) {
db.prepare(`DELETE FROM intune_deployments WHERE id = ? AND ${visibleClause()}`).run(deploymentId, userId, userId);
}
// Start the soak clock on first successful publish (auto-promote uses this).
export function markDeploymentPublished(deploymentId) {
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ? AND (soak_started_at IS NULL OR soak_started_at = \'\')')
.run(now(), deploymentId);
}
// System-context helpers for the auto-promote worker (no visibility scope).
export function loadDeploymentRaw(deploymentId) {
const row = db.prepare('SELECT * FROM intune_deployments WHERE id = ?').get(deploymentId);
return row ? camelIntuneDeployment(row) : null;
}
export function listPromotionCandidates() {
return db.prepare(`
SELECT * FROM intune_deployments
WHERE auto_promote_after_hours > 0 AND soak_started_at IS NOT NULL AND soak_started_at <> ''
AND (promoted_at IS NULL OR promoted_at = '') AND auto_promote_ring IS NOT NULL AND auto_promote_ring <> ''
`).all().map(camelIntuneDeployment);
}
export function applyPromotion(deploymentId, assignments) {
db.prepare('UPDATE intune_deployments SET assignments = ?, promoted_at = ?, updated_at = ? WHERE id = ?')
.run(json(assignments), now(), now(), deploymentId);
return loadDeploymentRaw(deploymentId);
}
export function listPsadtProfiles(userId) {
return db.prepare(`
SELECT p.*, g.name AS group_name
FROM psadt_profiles p
LEFT JOIN groups g ON g.id = p.group_id
WHERE ${visibleClause('p')}
ORDER BY p.updated_at DESC
`).all(userId, userId).map(camelPsadtProfile);
}
export function createPsadtProfile(body, userId) {
const payload = normalizeProfile(body);
const profileId = id('psadt');
db.prepare(`
INSERT INTO psadt_profiles (
id, name, app_vendor, app_name, app_version, app_arch, app_lang, app_revision,
toolkit_version, template_version, deployment_type, deploy_mode, require_admin,
zero_config, suppress_reboot, close_processes, install_tasks, ui_plan, config_plan,
admx_plan, visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
profileId,
payload.name,
payload.appVendor,
payload.appName,
payload.appVersion,
payload.appArch,
payload.appLang,
payload.appRevision,
payload.toolkitVersion,
payload.templateVersion,
payload.deploymentType,
payload.deployMode,
payload.requireAdmin ? 1 : 0,
payload.zeroConfig ? 1 : 0,
payload.suppressReboot ? 1 : 0,
json(payload.closeProcesses),
json(payload.installTasks),
json(payload.uiPlan, {}),
json(payload.configPlan, {}),
json(payload.admxPlan, {}),
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return loadPsadtProfile(profileId, userId);
}
export function loadPsadtProfile(profileId, userId) {
const row = db.prepare(`
SELECT p.*, g.name AS group_name
FROM psadt_profiles p
LEFT JOIN groups g ON g.id = p.group_id
WHERE p.id = ? AND ${visibleClause('p')}
`).get(profileId, userId, userId);
return row ? camelPsadtProfile(row) : null;
}
export function updatePsadtProfile(profileId, body, userId) {
const existing = loadPsadtProfile(profileId, userId);
if (!existing) return null;
const payload = normalizeProfile(body, existing);
db.prepare(`
UPDATE psadt_profiles
SET name = ?, app_vendor = ?, app_name = ?, app_version = ?, app_arch = ?, app_lang = ?,
app_revision = ?, toolkit_version = ?, template_version = ?, deployment_type = ?,
deploy_mode = ?, require_admin = ?, zero_config = ?, suppress_reboot = ?,
close_processes = ?, install_tasks = ?, ui_plan = ?, config_plan = ?, admx_plan = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.appVendor,
payload.appName,
payload.appVersion,
payload.appArch,
payload.appLang,
payload.appRevision,
payload.toolkitVersion,
payload.templateVersion,
payload.deploymentType,
payload.deployMode,
payload.requireAdmin ? 1 : 0,
payload.zeroConfig ? 1 : 0,
payload.suppressReboot ? 1 : 0,
json(payload.closeProcesses),
json(payload.installTasks),
json(payload.uiPlan, {}),
json(payload.configPlan, {}),
json(payload.admxPlan, {}),
payload.visibility,
payload.groupId,
now(),
profileId
);
return loadPsadtProfile(profileId, userId);
}
export function deletePsadtProfile(profileId, userId) {
db.prepare(`DELETE FROM psadt_profiles WHERE id = ? AND ${visibleClause()}`).run(profileId, userId, userId);
}
export function renderPsadtProfile(profileId, userId) {
const profile = loadPsadtProfile(profileId, userId);
if (!profile) return null;
const script = renderScript(profile);
const commandParts = [
'Invoke-AppDeployToolkit.exe',
`-DeploymentType ${profile.deploymentType}`,
profile.deployMode && profile.deployMode !== 'Auto' ? `-DeployMode ${profile.deployMode}` : '',
profile.suppressReboot ? '-SuppressRebootPassThru' : ''
].filter(Boolean);
const deployCommand = commandParts.join(' ');
const psCommand = [
'%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\PowerShell.exe -ExecutionPolicy Bypass -NoProfile -File Invoke-AppDeployToolkit.ps1',
`-DeploymentType ${profile.deploymentType}`,
profile.deployMode && profile.deployMode !== 'Auto' ? `-DeployMode ${profile.deployMode}` : ''
].filter(Boolean).join(' ');
return {
profile,
scriptName: `${profile.name.replace(/[^\w.-]+/g, '-') || 'Invoke-AppDeployToolkit'}.ps1`,
deployCommand,
powershellCommand: psCommand,
intuneInstallCommand: deployCommand,
intuneUninstallCommand: deployCommand.replace(`-DeploymentType ${profile.deploymentType}`, '-DeploymentType Uninstall'),
script
};
}
function renderScript(profile) {
const closeProcesses = profile.closeProcesses?.length
? `@(${profile.closeProcesses.map((item) => `@{ Name = '${ps(item.name)}'; Description = '${ps(item.description || item.name)}' }`).join(', ')})`
: '@()';
const installTasks = profile.installTasks?.length ? profile.installTasks : defaultInstallTasks(profile);
const phase = (name, lines) => [
`## MARK: ${name}`,
`New-Variable -Name ${name} -Value {`,
...lines.map((line) => line ? ` ${line}` : ''),
'}'
].join('\n');
const welcomeLines = [
'$saiwParams = @{',
' AllowDefer = $true',
' DeferTimes = 3',
' CheckDiskSpace = $true',
' PersistPrompt = $true',
'}',
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
'{',
" $saiwParams.Add('CloseProcesses', $adtSession.AppProcessesToClose)",
'}',
'Show-ADTInstallationWelcome @saiwParams',
'Show-ADTInstallationProgress'
];
return [
'<#',
'.SYNOPSIS',
'This script invokes a PSAppDeployToolkit deployment generated by POSHManager.',
'',
'.DESCRIPTION',
`PSADT profile: ${profile.name}`,
'The scaffold follows the upstream v4 frontend template shape and imports PSAppDeployToolkit from the package when available.',
'#>',
'',
'[CmdletBinding()]',
'param',
'(',
" [Parameter(Mandatory = $false)]",
" [ValidateSet('Install', 'Uninstall', 'Repair')]",
' [System.String]$DeploymentType,',
'',
" [Parameter(Mandatory = $false)]",
" [ValidateSet('Auto', 'Interactive', 'NonInteractive', 'Silent')]",
' [System.String]$DeployMode,',
'',
' [Parameter(Mandatory = $false)]',
' [System.Management.Automation.SwitchParameter]$SuppressRebootPassThru',
')',
'',
'## MARK: Variables',
'$adtSession = @{',
` AppVendor = '${ps(profile.appVendor)}'`,
` AppName = '${ps(profile.appName)}'`,
` AppVersion = '${ps(profile.appVersion)}'`,
` AppArch = '${ps(profile.appArch)}'`,
` AppLang = '${ps(profile.appLang)}'`,
` AppRevision = '${ps(profile.appRevision)}'`,
' AppSuccessExitCodes = @(0)',
' AppRebootExitCodes = @(1641, 3010)',
` AppProcessesToClose = ${closeProcesses}`,
'',
" AppScriptVersion = '1.0.0'",
` AppScriptDate = '${new Date().toISOString().slice(0, 10)}'`,
" AppScriptAuthor = 'POSHManager'",
'',
' DeployAppScriptFriendlyName = $MyInvocation.MyCommand.Name',
' DeployAppScriptParameters = $PSBoundParameters',
" DeployAppScriptVersion = '4.2.0'",
` RequireAdmin = ${profile.requireAdmin ? '$true' : '$false'}`,
'}',
'',
phase('Pre-Install', profile.uiPlan?.welcome === false ? ['# Welcome prompt disabled by profile.'] : welcomeLines),
'',
phase('Install', installTasks.filter((task) => task.phase === 'Install').map(renderTask)),
'',
phase('Post-Install', ["Show-ADTInstallationPrompt -Message \"$($adtSession.DeploymentType) complete.\" -ButtonRightText 'OK' -NoWait"]),
'',
phase('Pre-Uninstall', [
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
'{',
' Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -CloseProcessesCountdown 60',
'}',
'Show-ADTInstallationProgress'
]),
'',
phase('Uninstall', installTasks.filter((task) => task.phase === 'Uninstall').map(renderTask)),
'',
phase('Post-Uninstall', []),
'',
phase('Pre-Repair', [
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
'{',
' Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -CloseProcessesCountdown 60',
'}',
'Show-ADTInstallationProgress'
]),
'',
phase('Repair', installTasks.filter((task) => task.phase === 'Repair').map(renderTask)),
'',
phase('Post-Repair', ["Show-ADTInstallationPrompt -Message \"$($adtSession.DeploymentType) complete.\" -ButtonRightText 'OK' -NoWait"]),
'',
'## MARK: Initialization',
'$ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop',
'$ProgressPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue',
'Set-StrictMode -Version 1',
'try',
'{',
' if (Test-Path -LiteralPath "$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit\\PSAppDeployToolkit.psd1" -PathType Leaf)',
' {',
' Get-ChildItem -LiteralPath "$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit" -Recurse -File | Unblock-File -ErrorAction Ignore',
' Import-Module -FullyQualifiedName @{ ModuleName = [System.Management.Automation.WildcardPattern]::Escape("$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit\\PSAppDeployToolkit.psd1"); Guid = \'8c3c366b-8606-4576-9f2d-4051144f7ca2\'; ModuleVersion = \'4.2.0\' } -Force',
' }',
' else',
' {',
" Import-Module -FullyQualifiedName @{ ModuleName = 'PSAppDeployToolkit'; Guid = '8c3c366b-8606-4576-9f2d-4051144f7ca2'; ModuleVersion = '4.2.0' } -Force",
' }',
' $iadtParams = Get-ADTBoundParametersAndDefaultValues -Invocation $MyInvocation',
' $adtSession = Remove-ADTHashtableNullOrEmptyValues -Hashtable $adtSession',
' $adtSession = Open-ADTSession @adtSession @iadtParams -PassThru',
' Remove-Variable -Name iadtParams -Force -Confirm:$false',
'}',
'catch',
'{',
' $Host.UI.WriteErrorLine((Out-String -InputObject $_ -Width ([System.Int16]::MaxValue)))',
' exit 60008',
'}',
'',
'## MARK: Invocation',
'try',
'{',
' Get-ChildItem -LiteralPath $PSScriptRoot -Directory | & {',
' process',
' {',
" if ($_.Name -match 'PSAppDeployToolkit\\..+$')",
' {',
' Get-ChildItem -LiteralPath $_.FullName -Recurse -File | Unblock-File -ErrorAction Ignore',
' Import-Module -Name ([System.Management.Automation.WildcardPattern]::Escape("$($_.FullName)\\$($_.BaseName).psd1")) -Force',
' }',
' }',
' }',
' Get-Variable -Name "Pre-$($adtSession.DeploymentType)", $adtSession.DeploymentType, "Post-$($adtSession.DeploymentType)" -ErrorAction Ignore | . {',
' process',
' {',
' if (![System.String]::IsNullOrWhiteSpace($_.Value))',
' {',
' $adtSession.InstallPhase = $_.Name',
' . $_.Value',
' }',
' }',
' }',
' Close-ADTSession',
'}',
'catch',
'{',
' Write-ADTLogEntry -Message "An unhandled error within [$($MyInvocation.MyCommand.Name)] has occurred.`n$(Resolve-ADTErrorRecord -ErrorRecord $_)" -Severity Error',
' Close-ADTSession -ExitCode 60001',
'}',
''
].join('\n');
}
function defaultInstallTasks(profile) {
if (profile.zeroConfig) return [{ type: 'msi', phase: 'Install', filePath: 'Files\\SingleInstaller.msi', arguments: '', secureArguments: false }];
return [{ type: 'exe', phase: 'Install', filePath: 'setup.exe', arguments: '/S', secureArguments: false }];
}
function renderTask(task) {
if (task.type === 'msi' || task.type === 'msp') {
const action = task.type === 'msp' ? 'Patch' : task.phase.replace(/^Pre-/, '').replace(/^Post-/, '').replace('Install', 'Install');
const args = task.arguments ? ` -AdditionalArgumentList '${ps(task.arguments)}'` : '';
return `Start-ADTMsiProcess -Action '${ps(action)}' -FilePath '${ps(task.filePath)}'${args}${task.secureArguments ? ' -SecureArgumentList' : ''}`;
}
return `Start-ADTProcess -FilePath '${ps(task.filePath)}'${task.arguments ? ` -ArgumentList '${ps(task.arguments)}'` : ''} -WaitForChildProcesses${task.secureArguments ? ' -SecureArgumentList' : ''}`;
}
function ps(value) {
return String(value || '').replace(/'/g, "''");
}

View File

@@ -0,0 +1,68 @@
import { db, id, now, visibleClause } from '../db.js';
import { normalizeRunPlanPayload } from '../forms/schemas.js';
import { camelRunPlan } from './mappers.js';
import { filterVisibleHostIds } from './hostModel.js';
export function listRunPlans(userId) {
return db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
WHERE ${visibleClause('r')}
ORDER BY r.updated_at DESC
`).all(userId, userId).map(camelRunPlan);
}
export function createRunPlan(body, userId) {
const payload = normalizeRunPlanPayload(body);
const runplanId = id('run');
db.prepare(`
INSERT INTO runplans (id, name, description, script_id, visibility, owner_user_id, group_id, parallel, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(runplanId, payload.name, payload.description, payload.scriptId, payload.visibility, userId, payload.groupId, payload.parallel ? 1 : 0, now(), now());
replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
return loadRunPlan(runplanId);
}
export function loadRunPlan(runplanId) {
const runplan = db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
WHERE r.id = ?
`).get(runplanId);
if (!runplan) return null;
const hostIds = db.prepare('SELECT host_id FROM runplan_hosts WHERE runplan_id = ? ORDER BY host_id').all(runplanId).map((row) => row.host_id);
return { ...camelRunPlan(runplan), hostIds };
}
export function loadVisibleRunPlan(runplanId, userId) {
const visible = db.prepare(`SELECT id FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
return visible ? loadRunPlan(runplanId) : null;
}
export function updateRunPlan(runplanId, body, userId) {
const existing = db.prepare(`SELECT * FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
if (!existing) return null;
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined });
db.prepare(`
UPDATE runplans SET name = ?, description = ?, script_id = ?, visibility = ?, group_id = ?, parallel = ?, updated_at = ?
WHERE id = ?
`).run(payload.name, payload.description, payload.scriptId, payload.visibility, payload.groupId, payload.parallel ? 1 : 0, now(), runplanId);
if (body.hostIds) replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
return loadRunPlan(runplanId);
}
export function deleteRunPlan(runplanId, userId) {
db.prepare(`DELETE FROM runplans WHERE id = ? AND ${visibleClause()}`).run(runplanId, userId, userId);
}
function replaceRunPlanHosts(runplanId, hostIds) {
db.prepare('DELETE FROM runplan_hosts WHERE runplan_id = ?').run(runplanId);
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)');
for (const hostId of hostIds) stmt.run(runplanId, hostId);
}

View File

@@ -0,0 +1,46 @@
import { db, now } from '../db.js';
import { config } from '../config.js';
export function getSetting(key) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : undefined;
}
export function getBooleanSetting(key, fallback) {
const value = getSetting(key);
if (value === undefined || value === '') return fallback;
return String(value).toLowerCase() === 'true';
}
// Effective CORS allow-list: admin-editable `trusted_origins` merged with the
// built-in localhost defaults so the dev experience never locks itself out.
export function effectiveTrustedOrigins() {
const raw = getSetting('trusted_origins');
const fromDb = String(raw || '').split(',').map((origin) => origin.trim()).filter(Boolean);
return [...new Set([...fromDb, ...config.clientOrigins])];
}
export function settingsPayload() {
const settings = {};
for (const row of db.prepare('SELECT * FROM settings ORDER BY key').all()) {
settings[row.key] = { value: row.value, source: row.source, updatedAt: row.updated_at };
}
return settings;
}
export function updateSettings(payload) {
const entries = Object.entries(payload || {}).filter(([key]) => /^[a-z0-9_]+$/i.test(key));
const sourceOf = db.prepare('SELECT source FROM settings WHERE key = ?');
const stmt = db.prepare(`
INSERT INTO settings (key, value, source, updated_at)
VALUES (?, ?, 'db', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, source = 'db', updated_at = excluded.updated_at
`);
for (const [key, value] of entries) {
// Env-pinned settings are owned by the deployment environment; ignore
// attempts to override them so the UI and process never disagree.
if (sourceOf.get(key)?.source === 'env') continue;
stmt.run(key, String(value ?? ''), now());
}
return settingsPayload();
}

View File

@@ -0,0 +1,58 @@
import bcrypt from 'bcryptjs';
import { db, id, now } from '../db.js';
import { publicUser } from '../auth.js';
export function listUsers() {
return db.prepare('SELECT * FROM users ORDER BY display_name').all().map((row) => ({
...publicUser(row),
groups: db.prepare(`
SELECT g.id, g.name
FROM groups g
INNER JOIN user_groups ug ON ug.group_id = g.id
WHERE ug.user_id = ?
ORDER BY g.name
`).all(row.id)
}));
}
// Provision (or refresh) a user that authenticated through Microsoft Entra.
// Matches on email: an existing account is marked Entra-backed in place so the
// operator keeps their resources, and a brand-new account is created with no
// local password.
export function findOrProvisionEntraUser({ email, displayName }) {
const normalized = String(email || '').toLowerCase();
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(normalized);
if (existing) {
db.prepare('UPDATE users SET display_name = ?, auth_provider = ?, updated_at = ? WHERE id = ?')
.run(displayName || existing.display_name, 'entra', now(), existing.id);
return db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id);
}
const userId = id('usr');
const timestamp = now();
// First user on a fresh install becomes admin; subsequent Entra users are standard.
const isFirstUser = db.prepare('SELECT COUNT(*) AS count FROM users').get().count === 0;
db.prepare(`
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at, updated_at)
VALUES (?, ?, ?, NULL, 'entra', ?, ?, ?)
`).run(userId, normalized, displayName || normalized, isFirstUser ? 'admin' : 'user', timestamp, timestamp);
return db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
}
export function createUser(payload) {
const userId = id('usr');
const timestamp = now();
db.prepare(`
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at)
VALUES (?, ?, ?, ?, 'local', ?, ?)
`).run(
userId,
payload.email.toLowerCase(),
payload.displayName,
payload.password ? bcrypt.hashSync(payload.password, 12) : null,
payload.role,
timestamp
);
const groupStmt = db.prepare('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)');
for (const groupId of payload.groupIds || []) groupStmt.run(userId, groupId);
return listUsers().find((user) => user.id === userId);
}

View File

@@ -0,0 +1,141 @@
import { db, id, now, visibleClause } from '../db.js';
import { customVariableSchema } from '../forms/schemas.js';
import { fullVariableCatalog } from '../data/variableCatalog.js';
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
import { camelCustomVariable } from './mappers.js';
function normalizeVariable(body, existing = {}) {
const parsed = customVariableSchema.parse({ ...existing, ...body });
return {
...parsed,
sensitive: Boolean(parsed.sensitive),
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
function visibleCustomVariableRows(userId) {
return db.prepare(`
SELECT v.*, g.name AS group_name
FROM custom_variables v
LEFT JOIN groups g ON g.id = v.group_id
WHERE ${visibleClause('v')}
ORDER BY v.category, v.name
`).all(userId, userId);
}
export function listVariableCatalog(userId) {
const catalog = fullVariableCatalog();
return {
...catalog,
custom: visibleCustomVariableRows(userId).map((row) => {
const includeValue = !Boolean(row.sensitive);
const value = includeValue ? decryptSecret(row) : undefined;
return camelCustomVariable({ ...row, value }, includeValue);
})
};
}
export function listCustomVariables(userId) {
return visibleCustomVariableRows(userId).map((row) => {
const includeValue = !Boolean(row.sensitive);
const value = includeValue ? decryptSecret(row) : undefined;
return camelCustomVariable({ ...row, value }, includeValue);
});
}
export function listExecutableCustomVariables(userId) {
return visibleCustomVariableRows(userId).map((row) => ({
id: row.id,
name: row.name,
value: decryptSecret(row),
valueType: row.value_type || 'string',
sensitive: Boolean(row.sensitive)
}));
}
export function createCustomVariable(body, userId) {
const payload = normalizeVariable(body);
const encrypted = encryptSecret(payload.value);
const variableId = id('var');
db.prepare(`
INSERT INTO custom_variables (
id, name, description, category, value_type, secret_cipher, secret_iv, secret_tag,
sensitive, visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
variableId,
payload.name,
payload.description,
payload.category,
payload.valueType,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.sensitive ? 1 : 0,
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return loadCustomVariable(variableId, userId);
}
export function loadCustomVariable(variableId, userId) {
const row = db.prepare(`
SELECT v.*, g.name AS group_name
FROM custom_variables v
LEFT JOIN groups g ON g.id = v.group_id
WHERE v.id = ? AND ${visibleClause('v')}
`).get(variableId, userId, userId);
if (!row) return null;
const includeValue = !Boolean(row.sensitive);
const value = includeValue ? decryptSecret(row) : undefined;
return camelCustomVariable({ ...row, value }, includeValue);
}
export function updateCustomVariable(variableId, body, userId) {
const existing = db.prepare(`SELECT * FROM custom_variables WHERE id = ? AND ${visibleClause()}`).get(variableId, userId, userId);
if (!existing) return null;
const payload = normalizeVariable(body, {
name: existing.name,
description: existing.description || '',
category: existing.category || 'Custom',
value: body.value ?? decryptSecret(existing),
valueType: existing.value_type || 'string',
sensitive: Boolean(existing.sensitive),
visibility: existing.visibility,
groupId: existing.group_id
});
const encrypted = body.value === undefined ? {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
} : encryptSecret(payload.value);
db.prepare(`
UPDATE custom_variables
SET name = ?, description = ?, category = ?, value_type = ?, secret_cipher = ?, secret_iv = ?,
secret_tag = ?, sensitive = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.description,
payload.category,
payload.valueType,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.sensitive ? 1 : 0,
payload.visibility,
payload.groupId,
now(),
variableId
);
return loadCustomVariable(variableId, userId);
}
export function deleteCustomVariable(variableId, userId) {
db.prepare(`DELETE FROM custom_variables WHERE id = ? AND ${visibleClause()}`).run(variableId, userId, userId);
}

View File

@@ -0,0 +1,16 @@
import { Router } from 'express';
import { checkAll, checkUpdate, create, destroy, importFromTenant, index, show, update } from '../controllers/applicationController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const applicationRoutes = Router();
// Phase 5 application catalog: groups versioned deployments + tracks upstream
// version state and adoption of existing tenant apps.
applicationRoutes.get('/', requireAuth, index);
applicationRoutes.post('/', requireAuth, create);
applicationRoutes.post('/check-all', requireAuth, requireAdmin, checkAll);
applicationRoutes.post('/import', requireAuth, importFromTenant);
applicationRoutes.get('/:id', requireAuth, show);
applicationRoutes.put('/:id', requireAuth, update);
applicationRoutes.delete('/:id', requireAuth, destroy);
applicationRoutes.post('/:id/check-update', requireAuth, checkUpdate);

View File

@@ -0,0 +1,38 @@
import { Router } from 'express';
import {
assetFolders,
assetLinks,
assets,
createAssetAction,
createAssetFolderAction,
createAssetLinkAction,
deleteAssetAction,
deleteAssetFolderAction,
deleteAssetLinkAction,
downloadAssetAction,
getAssetAction,
updateAssetAction,
updateAssetFolderAction,
viewAssetAction
} from '../controllers/assetController.js';
import { requireAuth } from '../middleware/auth.js';
import { assetUpload } from '../middleware/upload.js';
export const assetRoutes = Router();
// Asset routes stay API-first: UI upload, package builders, and external clients share this surface.
assetRoutes.get('/folders', requireAuth, assetFolders);
assetRoutes.post('/folders', requireAuth, createAssetFolderAction);
assetRoutes.put('/folders/:id', requireAuth, updateAssetFolderAction);
assetRoutes.delete('/folders/:id', requireAuth, deleteAssetFolderAction);
assetRoutes.delete('/links/:linkId', requireAuth, deleteAssetLinkAction);
assetRoutes.get('/', requireAuth, assets);
assetRoutes.post('/', requireAuth, assetUpload.single('file'), createAssetAction);
assetRoutes.get('/:id/download', requireAuth, downloadAssetAction);
assetRoutes.get('/:id/view', requireAuth, viewAssetAction);
assetRoutes.get('/:id/links', requireAuth, assetLinks);
assetRoutes.post('/:id/links', requireAuth, createAssetLinkAction);
assetRoutes.get('/:id', requireAuth, getAssetAction);
assetRoutes.put('/:id', requireAuth, updateAssetAction);
assetRoutes.delete('/:id', requireAuth, deleteAssetAction);

View File

@@ -0,0 +1,18 @@
import { Router } from 'express';
import { entraCallback, entraLogin, login, me, password, profile, theme } from '../controllers/authController.js';
import { requireAuth } from '../middleware/auth.js';
import { rateLimit } from '../middleware/rateLimit.js';
export const authRoutes = Router();
// Throttle credential-guessing against the public login endpoint.
const loginLimiter = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'login' });
// Session/profile endpoints. Local login is public; profile mutations require a valid JWT.
authRoutes.post('/login', loginLimiter, login);
authRoutes.get('/entra/login', entraLogin);
authRoutes.get('/entra/callback', entraCallback);
authRoutes.get('/me', requireAuth, me);
authRoutes.put('/profile', requireAuth, profile);
authRoutes.put('/theme', requireAuth, theme);
authRoutes.post('/change-password', requireAuth, password);

8
server/routes/bootstrapRoutes.js vendored Normal file
View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { bootstrap, health } from '../controllers/bootstrapController.js';
import { requireAuth } from '../middleware/auth.js';
export const bootstrapRoutes = Router();
bootstrapRoutes.get('/health', health);
bootstrapRoutes.get('/bootstrap', requireAuth, bootstrap);

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import { create, destroy, index, update } from '../controllers/credentialController.js';
import { requireAuth } from '../middleware/auth.js';
export const credentialRoutes = Router();
credentialRoutes.get('/', requireAuth, index);
credentialRoutes.post('/', requireAuth, create);
credentialRoutes.put('/:id', requireAuth, update);
credentialRoutes.delete('/:id', requireAuth, destroy);

View File

@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
approveChangeRequest,
auditExport,
changeRequests,
reportingOverview,
rejectChangeRequest
} from '../controllers/governanceController.js';
import { requireAuth } from '../middleware/auth.js';
export const governanceRoutes = Router();
// Phase 6 governance & reporting for Intune tenant write-back. Approve/reject
// authorize admins or named approvers inside the controller.
governanceRoutes.get('/change-requests', requireAuth, changeRequests);
governanceRoutes.post('/change-requests/:id/approve', requireAuth, approveChangeRequest);
governanceRoutes.post('/change-requests/:id/reject', requireAuth, rejectChangeRequest);
governanceRoutes.get('/reporting/overview', requireAuth, reportingOverview);
governanceRoutes.get('/deployments/:id/audit/export', requireAuth, auditExport);

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import { create, destroy, index, mobileApps, test, update } from '../controllers/graphController.js';
import { requireAuth } from '../middleware/auth.js';
export const graphRoutes = Router();
// Microsoft Graph environments for Intune app publishing/status tracking.
graphRoutes.get('/connections', requireAuth, index);
graphRoutes.post('/connections', requireAuth, create);
graphRoutes.put('/connections/:id', requireAuth, update);
graphRoutes.delete('/connections/:id', requireAuth, destroy);
graphRoutes.post('/connections/:id/test', requireAuth, test);
graphRoutes.get('/connections/:id/mobile-apps', requireAuth, mobileApps);

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { create, index } from '../controllers/groupController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const groupRoutes = Router();
groupRoutes.get('/', requireAuth, index);
groupRoutes.post('/', requireAuth, requireAdmin, create);

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { index, show } from '../controllers/helpController.js';
import { requireAuth } from '../middleware/auth.js';
export const helpRoutes = Router();
// Searchable in-app help library for operators and API automation clients.
helpRoutes.get('/', requireAuth, index);
helpRoutes.get('/:id', requireAuth, show);

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import { create, destroy, index, update } from '../controllers/hostController.js';
import { requireAuth } from '../middleware/auth.js';
export const hostRoutes = Router();
hostRoutes.get('/', requireAuth, index);
hostRoutes.post('/', requireAuth, create);
hostRoutes.put('/:id', requireAuth, update);
hostRoutes.delete('/:id', requireAuth, destroy);

44
server/routes/index.js Normal file
View File

@@ -0,0 +1,44 @@
import { Router } from 'express';
import { applicationRoutes } from './applicationRoutes.js';
import { assetRoutes } from './assetRoutes.js';
import { authRoutes } from './authRoutes.js';
import { bootstrapRoutes } from './bootstrapRoutes.js';
import { credentialRoutes } from './credentialRoutes.js';
import { folderRoutes, scriptRoutes } from './libraryRoutes.js';
import { governanceRoutes } from './governanceRoutes.js';
import { groupRoutes } from './groupRoutes.js';
import { graphRoutes } from './graphRoutes.js';
import { helpRoutes } from './helpRoutes.js';
import { hostRoutes } from './hostRoutes.js';
import { jobRoutes } from './jobRoutes.js';
import { logRoutes } from './logRoutes.js';
import { packagingRoutes } from './packagingRoutes.js';
import { psadtRoutes } from './psadtRoutes.js';
import { runPlanRoutes } from './runPlanRoutes.js';
import { settingsRoutes } from './settingsRoutes.js';
import { userRoutes } from './userRoutes.js';
import { variableRoutes } from './variableRoutes.js';
export const apiRoutes = Router();
// Central API composition keeps individual route modules small and domain-focused.
apiRoutes.use(bootstrapRoutes);
apiRoutes.use('/auth', authRoutes);
apiRoutes.use('/settings', settingsRoutes);
apiRoutes.use('/users', userRoutes);
apiRoutes.use('/groups', groupRoutes);
apiRoutes.use('/graph', graphRoutes);
apiRoutes.use('/help', helpRoutes);
apiRoutes.use('/assets', assetRoutes);
apiRoutes.use('/credentials', credentialRoutes);
apiRoutes.use('/hosts', hostRoutes);
apiRoutes.use('/folders', folderRoutes);
apiRoutes.use('/scripts', scriptRoutes);
apiRoutes.use('/variables', variableRoutes);
apiRoutes.use('/psadt', psadtRoutes);
apiRoutes.use('/packaging', packagingRoutes);
apiRoutes.use('/intune/applications', applicationRoutes);
apiRoutes.use('/intune', governanceRoutes);
apiRoutes.use('/runplans', runPlanRoutes);
apiRoutes.use('/jobs', jobRoutes);
apiRoutes.use('/logs', logRoutes);

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { index, show } from '../controllers/jobController.js';
import { requireAuth } from '../middleware/auth.js';
export const jobRoutes = Router();
jobRoutes.get('/', requireAuth, index);
jobRoutes.get('/:id', requireAuth, show);

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import {
cloneScriptAction,
createFolderAction,
createScriptAction,
deleteFolderAction,
deleteScriptAction,
folders,
getScriptAction,
scriptUsageAction,
scripts,
scriptVersions,
testRunScriptAction,
updateFolderAction,
updateScriptAction
} from '../controllers/libraryController.js';
import { requireAuth } from '../middleware/auth.js';
export const folderRoutes = Router();
export const scriptRoutes = Router();
// Folders and scripts are split for clean URLs while sharing library visibility rules.
folderRoutes.get('/', requireAuth, folders);
folderRoutes.post('/', requireAuth, createFolderAction);
folderRoutes.put('/:id', requireAuth, updateFolderAction);
folderRoutes.delete('/:id', requireAuth, deleteFolderAction);
scriptRoutes.get('/', requireAuth, scripts);
scriptRoutes.post('/', requireAuth, createScriptAction);
scriptRoutes.get('/:id/usage', requireAuth, scriptUsageAction);
scriptRoutes.post('/:id/clone', requireAuth, cloneScriptAction);
scriptRoutes.post('/:id/test-run', requireAuth, testRunScriptAction);
scriptRoutes.get('/:id', requireAuth, getScriptAction);
scriptRoutes.put('/:id', requireAuth, updateScriptAction);
scriptRoutes.delete('/:id', requireAuth, deleteScriptAction);
scriptRoutes.get('/:id/versions', requireAuth, scriptVersions);

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { systemLogs } from '../controllers/logController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const logRoutes = Router();
// System logs can expose operational internals, so access is admin-only.
logRoutes.get('/system', requireAuth, requireAdmin, systemLogs);

View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import { analyze, detection, installerTypes } from '../controllers/packagingController.js';
import { requireAuth } from '../middleware/auth.js';
export const packagingRoutes = Router();
// Phase 1 installer intelligence: detect technology, recommend silent commands,
// and generate detection rules.
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
packagingRoutes.post('/analyze', requireAuth, analyze);
packagingRoutes.post('/detection', requireAuth, detection);

View File

@@ -0,0 +1,68 @@
import { Router } from 'express';
import {
catalog,
create,
destroy,
index,
intuneCreate,
intuneDestroy,
intuneGraphAssign,
intuneGraphAudit,
intuneGraphDrift,
intuneGraphLink,
intuneGraphPublish,
intuneGraphRelationships,
intuneBuild,
intuneBuilderStatus,
intuneGraphReconcile,
intuneGraphStatus,
intuneGraphSync,
intuneGraphUploadContent,
intuneIndex,
intuneNewVersion,
intunePromote,
intuneShow,
intuneUpdate,
migrationApply,
migrationPlan,
render,
rules,
show,
update,
validate
} from '../controllers/psadtController.js';
import { requireAuth } from '../middleware/auth.js';
export const psadtRoutes = Router();
// PSADT Workbench APIs expose reference support plus persistent deployment profiles.
psadtRoutes.get('/catalog', requireAuth, catalog);
psadtRoutes.get('/validation-rules', requireAuth, rules);
psadtRoutes.post('/validate', requireAuth, validate);
psadtRoutes.post('/migration/plan', requireAuth, migrationPlan);
psadtRoutes.post('/migration/apply', requireAuth, migrationApply);
psadtRoutes.get('/intune/deployments', requireAuth, intuneIndex);
psadtRoutes.post('/intune/deployments', requireAuth, intuneCreate);
psadtRoutes.get('/intune/deployments/:id', requireAuth, intuneShow);
psadtRoutes.put('/intune/deployments/:id', requireAuth, intuneUpdate);
psadtRoutes.delete('/intune/deployments/:id', requireAuth, intuneDestroy);
psadtRoutes.get('/intune/deployments/:id/graph/status', requireAuth, intuneGraphStatus);
psadtRoutes.post('/intune/deployments/:id/graph/link', requireAuth, intuneGraphLink);
psadtRoutes.post('/intune/deployments/:id/graph/sync', requireAuth, intuneGraphSync);
psadtRoutes.post('/intune/deployments/:id/graph/publish', requireAuth, intuneGraphPublish);
psadtRoutes.post('/intune/deployments/:id/graph/assign', requireAuth, intuneGraphAssign);
psadtRoutes.post('/intune/deployments/:id/graph/relationships', requireAuth, intuneGraphRelationships);
psadtRoutes.post('/intune/deployments/:id/graph/upload-content', requireAuth, intuneGraphUploadContent);
psadtRoutes.post('/intune/deployments/:id/graph/drift', requireAuth, intuneGraphDrift);
psadtRoutes.post('/intune/deployments/:id/graph/reconcile', requireAuth, intuneGraphReconcile);
psadtRoutes.get('/intune/builder/status', requireAuth, intuneBuilderStatus);
psadtRoutes.post('/intune/deployments/:id/build', requireAuth, intuneBuild);
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
psadtRoutes.get('/profiles', requireAuth, index);
psadtRoutes.post('/profiles', requireAuth, create);
psadtRoutes.get('/profiles/:id', requireAuth, show);
psadtRoutes.put('/profiles/:id', requireAuth, update);
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
psadtRoutes.get('/profiles/:id/render', requireAuth, render);

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import { create, destroy, execute, index, show, update } from '../controllers/runPlanController.js';
import { requireAuth } from '../middleware/auth.js';
export const runPlanRoutes = Router();
// RunPlan metadata is CRUD; execution is an explicit action endpoint.
runPlanRoutes.get('/', requireAuth, index);
runPlanRoutes.post('/', requireAuth, create);
runPlanRoutes.get('/:id', requireAuth, show);
runPlanRoutes.put('/:id', requireAuth, update);
runPlanRoutes.delete('/:id', requireAuth, destroy);
runPlanRoutes.post('/:id/execute', requireAuth, execute);

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { listSettings, saveSettings } from '../controllers/settingsController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const settingsRoutes = Router();
// Settings are readable by operators but writable only by admins.
settingsRoutes.get('/', requireAuth, listSettings);
settingsRoutes.put('/', requireAuth, requireAdmin, saveSettings);

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { create, index } from '../controllers/userController.js';
import { requireAdmin, requireAuth } from '../middleware/auth.js';
export const userRoutes = Router();
userRoutes.get('/', requireAuth, requireAdmin, index);
userRoutes.post('/', requireAuth, requireAdmin, create);

View File

@@ -0,0 +1,12 @@
import { Router } from 'express';
import { catalog, createCustom, customIndex, destroyCustom, updateCustom } from '../controllers/variableController.js';
import { requireAuth } from '../middleware/auth.js';
export const variableRoutes = Router();
// Variables expose the PSADT/POSHManager catalog plus user-managed runtime values for scripts.
variableRoutes.get('/catalog', requireAuth, catalog);
variableRoutes.get('/custom', requireAuth, customIndex);
variableRoutes.post('/custom', requireAuth, createCustom);
variableRoutes.put('/custom/:id', requireAuth, updateCustom);
variableRoutes.delete('/custom/:id', requireAuth, destroyCustom);

View File

@@ -0,0 +1,78 @@
// Application version comparison + upstream version lookup for the Phase 5
// catalog. The comparison and Graph/winget mapping are pure and unit-tested; the
// network lookups (`fetchLatestVersion`) require live sources and are marked
// needs-live-verification.
// Compare two dotted version strings numerically with lexical fallback for
// non-numeric segments. Returns -1 (a<b), 0 (equal), or 1 (a>b).
export function compareVersions(a = '', b = '') {
const norm = (v) => String(v).trim().replace(/^v/i, '').split(/[.\-+]/).filter(Boolean);
const pa = norm(a);
const pb = norm(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const sa = pa[i] ?? '0';
const sb = pb[i] ?? '0';
const na = Number(sa);
const nb = Number(sb);
const bothNumeric = Number.isFinite(na) && Number.isFinite(nb) && /^\d+$/.test(sa) && /^\d+$/.test(sb);
let cmp;
if (bothNumeric) cmp = na === nb ? 0 : (na < nb ? -1 : 1);
else cmp = sa === sb ? 0 : (sa < sb ? -1 : 1);
if (cmp !== 0) return cmp;
}
return 0;
}
// Decide whether a catalog entry has a newer upstream version than what is
// currently published.
export function evaluateUpdateState({ currentVersion, latestKnownVersion } = {}) {
const current = currentVersion || '';
const latest = latestKnownVersion || '';
if (!latest) return { updateAvailable: false, current, latest, reason: 'no upstream version known' };
if (!current) return { updateAvailable: true, current, latest, reason: 'no current version recorded' };
const cmp = compareVersions(latest, current);
return {
updateAvailable: cmp > 0,
current,
latest,
reason: cmp > 0 ? 'newer upstream version available' : 'up to date'
};
}
// Pure mapping from a Graph mobileApp (win32LobApp) to catalog fields, used when
// adopting an existing tenant app into the catalog.
export function mapGraphAppToCatalog(graphApp = {}) {
return {
name: graphApp.displayName || graphApp.name || graphApp.id || 'Imported app',
vendor: graphApp.publisher || '',
publisher: graphApp.publisher || '',
currentVersion: graphApp.displayVersion || graphApp.version || '',
currentGraphAppId: graphApp.id || ''
};
}
// Look up the latest upstream version for a catalog entry. `manual` sources are
// operator-driven; `url` expects JSON with a `version` field; `winget` queries a
// community winget index (configurable ref). Network-dependent — verify live.
export async function fetchLatestVersion({ versionSource = 'manual', versionSourceRef = '' } = {}) {
if (versionSource === 'manual' || !versionSourceRef) {
return { version: '', source: 'manual', message: 'Manual source; set the latest version by hand.' };
}
if (versionSource === 'url') {
const res = await fetch(versionSourceRef, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`Version URL returned ${res.status}`);
const data = await res.json().catch(() => ({}));
const version = data.version || data.latestVersion || data.tag_name || '';
return { version: String(version).replace(/^v/i, ''), source: 'url', message: version ? '' : 'No version field found in URL response.' };
}
if (versionSource === 'winget') {
// Community winget index (e.g. https://api.winget.run/v2/packages/<id>).
const res = await fetch(versionSourceRef, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`winget source returned ${res.status}`);
const data = await res.json().catch(() => ({}));
const version = data?.Package?.Latest?.PackageVersion || data?.latestVersion || data?.version || '';
return { version: String(version), source: 'winget', message: version ? '' : 'No version found in winget response.' };
}
throw new Error(`Unknown version source "${versionSource}"`);
}

View File

@@ -0,0 +1,49 @@
import { applyVersionCheck, listAutoCheckApplications } from '../models/applicationModel.js';
import { fetchLatestVersion } from './appVersionService.js';
import { logger } from './logger.js';
// Background upstream-version checker for catalog applications that opted in via
// `autoCheck`. The runner is dependency-injected (fetcher) so it can be tested
// without network access; the scheduler wires in the real fetchLatestVersion.
export async function runCatalogChecks({ fetcher = fetchLatestVersion } = {}) {
const apps = listAutoCheckApplications();
const summary = { checked: 0, updated: 0, errors: 0 };
for (const app of apps) {
summary.checked += 1;
try {
const result = await fetcher(app);
const next = result.version || '';
const changed = next && next !== app.latestKnownVersion;
applyVersionCheck(app.id, { latestKnownVersion: next || app.latestKnownVersion, message: result.message || '' });
if (changed) summary.updated += 1;
} catch (error) {
summary.errors += 1;
applyVersionCheck(app.id, { latestKnownVersion: app.latestKnownVersion, message: error.message });
}
}
return summary;
}
let timer = null;
// Start the recurring worker. Disabled unless CATALOG_CHECK_INTERVAL_MINUTES > 0
// so installs never make surprise outbound calls.
export function startCatalogWorker() {
const minutes = Number(process.env.CATALOG_CHECK_INTERVAL_MINUTES || 0);
if (!Number.isFinite(minutes) || minutes <= 0) return null;
const intervalMs = minutes * 60 * 1000;
timer = setInterval(() => {
runCatalogChecks()
.then((summary) => logger.info('catalog auto-check complete', summary))
.catch((error) => logger.error('catalog auto-check failed', { error: error.message }));
}, intervalMs);
timer.unref?.();
logger.info(`catalog auto-check scheduled every ${minutes} minute(s)`);
return timer;
}
export function stopCatalogWorker() {
if (timer) clearInterval(timer);
timer = null;
}

View File

@@ -0,0 +1,35 @@
import crypto from 'node:crypto';
import { config } from '../config.js';
function keyBuffer() {
// Derive fixed-length AES key material from the configured secret-like environment value.
return crypto.createHash('sha256').update(config.credentialStoreKey).digest();
}
export function encryptSecret(value = '') {
// AES-GCM stores cipher text plus IV/tag so reads can expose metadata without returning secrets.
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer(), iv);
const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
cipher: encrypted.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64')
};
}
export function decryptSecret(record) {
// Missing cipher fields mean the credential has no retrievable secret rather than throwing.
if (!record?.secret_cipher || !record?.secret_iv || !record?.secret_tag) return '';
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
keyBuffer(),
Buffer.from(record.secret_iv, 'base64')
);
decipher.setAuthTag(Buffer.from(record.secret_tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(record.secret_cipher, 'base64')),
decipher.final()
]).toString('utf8');
}

View File

@@ -0,0 +1,66 @@
// Generate a "software footprint" detection rule from simple inputs, in the
// shape the deployment model already uses (`detectionType` + `detectionRule`).
// MSI maps to a product-code rule; file/registry produce Intune-style custom
// PowerShell detection scripts so they flow straight through publishing without
// extra mapping. Pure + unit-tested.
function fileDetectionScript({ path, version }) {
const lines = [`$path = '${path.replace(/'/g, "''")}'`, 'if (Test-Path -LiteralPath $path) {'];
if (version) {
lines.push(" $found = (Get-Item -LiteralPath $path).VersionInfo.ProductVersion");
lines.push(` if ($found -and [version]$found -ge [version]'${version}') { Write-Output 'Detected'; exit 0 }`);
} else {
lines.push(" Write-Output 'Detected'; exit 0");
}
lines.push('}');
lines.push('exit 1');
return lines.join('\n');
}
function registryDetectionScript({ keyPath, valueName, valueData }) {
const key = keyPath.replace(/'/g, "''");
const lines = [`$key = '${key}'`];
if (valueName) {
lines.push("$value = (Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue).'" + valueName.replace(/'/g, "''") + "'");
if (valueData) {
lines.push(`if ($value -eq '${valueData.replace(/'/g, "''")}') { Write-Output 'Detected'; exit 0 }`);
} else {
lines.push("if ($null -ne $value) { Write-Output 'Detected'; exit 0 }");
}
} else {
lines.push("if (Test-Path -LiteralPath $key) { Write-Output 'Detected'; exit 0 }");
}
lines.push('exit 1');
return lines.join('\n');
}
export function buildDetectionRule(input = {}) {
switch (input.type) {
case 'msi': {
if (!input.productCode) throw new Error('productCode is required for MSI detection');
return {
detectionType: 'msi-product-code',
detectionRule: input.productCode,
description: `MSI product code ${input.productCode}`
};
}
case 'file': {
if (!input.path) throw new Error('path is required for file detection');
return {
detectionType: 'custom-script',
detectionRule: fileDetectionScript(input),
description: input.version ? `File ${input.path} version ≥ ${input.version}` : `File exists: ${input.path}`
};
}
case 'registry': {
if (!input.keyPath) throw new Error('keyPath is required for registry detection');
return {
detectionType: 'custom-script',
detectionRule: registryDetectionScript(input),
description: input.valueName ? `Registry ${input.keyPath}\\${input.valueName}` : `Registry key ${input.keyPath}`
};
}
default:
throw new Error(`Unknown detection type "${input.type}" (expected msi, file, or registry)`);
}
}

View File

@@ -0,0 +1,87 @@
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { getSetting, getBooleanSetting } from '../models/settingsModel.js';
// Microsoft Entra ID (Azure AD) OAuth 2.0 authorization-code login.
//
// Tenant/client/callback values are read from the effective settings so admins
// can adjust them in the Config screen, while the client secret stays in the
// process environment (never persisted to the settings table). The flow uses
// Node's global fetch and the existing jsonwebtoken dependency for signed
// state, so no new packages are required.
const AUTHORITY_HOST = 'https://login.microsoftonline.com';
const GRAPH_ME_URL = 'https://graph.microsoft.com/v1.0/me';
const LOGIN_SCOPE = 'openid profile email https://graph.microsoft.com/User.Read';
export function entraSettings() {
return {
enabled: getBooleanSetting('entra_enabled', config.entra.enabled),
tenantId: getSetting('entra_tenant_id') || config.entra.tenantId,
clientId: getSetting('entra_client_id') || config.entra.clientId,
clientSecret: config.entra.clientSecret,
callbackUrl: getSetting('entra_callback_url') || config.entra.callbackUrl
};
}
// Login is only offered when Entra is enabled AND fully configured. Missing any
// piece (common in dev) means we fall back to local auth without erroring.
export function isEntraLoginConfigured() {
const s = entraSettings();
return Boolean(s.enabled && s.tenantId && s.clientId && s.clientSecret && s.callbackUrl);
}
export function signState(payload = {}) {
return jwt.sign({ ...payload, purpose: 'entra-state' }, config.jwtSecret, { expiresIn: '10m' });
}
export function verifyState(state) {
const decoded = jwt.verify(state, config.jwtSecret);
if (decoded.purpose !== 'entra-state') throw new Error('Invalid state');
return decoded;
}
export function buildAuthorizeUrl(state) {
const s = entraSettings();
const params = new URLSearchParams({
client_id: s.clientId,
response_type: 'code',
redirect_uri: s.callbackUrl,
response_mode: 'query',
scope: LOGIN_SCOPE,
state
});
return `${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/authorize?${params.toString()}`;
}
export async function exchangeCodeForToken(code) {
const s = entraSettings();
const response = await fetch(`${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: s.clientId,
client_secret: s.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: s.callbackUrl,
scope: LOGIN_SCOPE
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token exchange failed: ${response.status}`);
return payload;
}
export async function fetchEntraProfile(accessToken) {
const response = await fetch(GRAPH_ME_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload?.error?.message || `Profile lookup failed: ${response.status}`);
const email = (payload.mail || payload.userPrincipalName || '').toLowerCase();
if (!email) throw new Error('Entra profile did not include an email or user principal name');
return {
email,
displayName: payload.displayName || email,
oid: payload.id || ''
};
}

View File

@@ -0,0 +1,28 @@
// Pure per-connection RBAC checks for Intune tenant write-back. A connection may
// name explicit publishers (who may trigger writes) and approvers (who may
// approve change requests). Admins always pass. Empty lists mean "no extra
// restriction" — the allow_write / require_approval gates still apply.
function asList(value) {
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function canPublish(connection, user) {
if (!user) return false;
if (user.role === 'admin') return true;
const publishers = asList(connection?.publisher_ids ?? connection?.publisherIds);
return publishers.length === 0 || publishers.includes(user.id);
}
export function canApprove(connection, user) {
if (!user) return false;
if (user.role === 'admin') return true;
const approvers = asList(connection?.approver_ids ?? connection?.approverIds);
return approvers.includes(user.id);
}

View File

@@ -0,0 +1,423 @@
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
function sanitizeBase(url = '') {
return String(url || '').replace(/\/+$/, '');
}
// Defense in depth: even though connection URLs are validated on write, re-check
// before every outbound request so stored/legacy rows can't trigger SSRF.
function assertAllowedEndpoints(connection) {
if (!isAllowedAuthorityHost(connection.authority_host)) {
throw new Error('Graph connection authority host is not an allowed Microsoft endpoint');
}
if (!isAllowedGraphHost(connection.graph_base_url)) {
throw new Error('Graph connection base URL is not an allowed Microsoft endpoint');
}
}
export async function getGraphToken(connection) {
assertAllowedEndpoints(connection);
const authority = sanitizeBase(connection.authority_host);
const graphBase = sanitizeBase(connection.graph_base_url);
const response = await fetch(`${authority}/${encodeURIComponent(connection.tenant_id)}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: connection.client_id,
client_secret: connection.clientSecret,
scope: `${graphBase}/.default`,
grant_type: 'client_credentials'
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token request failed: ${response.status}`);
return payload.access_token;
}
export async function graphRequest(connection, path, options = {}) {
assertAllowedEndpoints(connection);
const token = options.token || await getGraphToken(connection);
const base = sanitizeBase(connection.graph_base_url);
const response = await fetch(`${base}${path}`, {
method: options.method || 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
const message = payload?.error?.message || payload?.error_description || `Graph request failed: ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function testGraphConnection(connection) {
const token = await getGraphToken(connection);
const payload = await graphRequest(connection, '/v1.0/deviceAppManagement?$select=id&$top=1', { token });
return { ok: true, message: `Connected. deviceAppManagement readable; sample keys: ${Object.keys(payload).join(', ')}` };
}
// Create or update the win32LobApp metadata object. Content (.intunewin bytes)
// upload is a separate Phase 2 concern; this writes the app definition only.
// mobileApps create/update lives on the beta endpoint for full win32 fidelity.
export async function upsertWin32App(connection, payload, existingAppId = '') {
const token = await getGraphToken(connection);
if (existingAppId) {
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(existingAppId)}`, {
token,
method: 'PATCH',
body: payload
});
return { id: existingAppId, updated: true };
}
const created = await graphRequest(connection, '/beta/deviceAppManagement/mobileApps', {
token,
method: 'POST',
body: payload
});
return { id: created.id, created: true, raw: created };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForFileState(connection, token, appPath, cvId, fileId, target, { attempts = 30, delayMs = 2000 } = {}) {
for (let i = 0; i < attempts; i++) {
const file = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}`, { token });
const state = file.uploadState || '';
if (state === target) return file;
if (state.endsWith('Failed')) throw new Error(`Intune content upload failed in state "${state}"`);
await sleep(delayMs);
}
throw new Error(`Timed out waiting for Intune content state "${target}"`);
}
// Upload encrypted .intunewin content to Azure Storage in blocks, then commit.
// The SAS URI is returned by Graph (trusted); the block PUTs go straight to
// Azure Blob storage, outside the Graph allow-list.
async function azureBlockUpload(sasUri, content) {
const blockSize = 6 * 1024 * 1024;
const blockIds = [];
let index = 0;
for (let start = 0; start < content.length; start += blockSize) {
const chunk = content.subarray(start, start + blockSize);
const blockId = Buffer.from(String(index).padStart(8, '0')).toString('base64');
blockIds.push(blockId);
const res = await fetch(`${sasUri}&comp=block&blockid=${encodeURIComponent(blockId)}`, {
method: 'PUT',
headers: { 'x-ms-blob-type': 'BlockBlob' },
body: chunk
});
if (!res.ok) throw new Error(`Azure block upload failed (${res.status}) on block ${index}`);
index += 1;
}
const listXml = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((b) => `<Latest>${b}</Latest>`).join('')}</BlockList>`;
const commit = await fetch(`${sasUri}&comp=blocklist`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
body: listXml
});
if (!commit.ok) throw new Error(`Azure block-list commit failed (${commit.status})`);
}
// Full Win32 content commit flow (Phase 2). Requires a live tenant + Azure
// Storage; validated end to end only against a real tenant. `parsed` is the
// output of parseIntunewin().
export async function uploadWin32Content(connection, appId, parsed) {
const token = await getGraphToken(connection);
const appPath = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/microsoft.graph.win32LobApp`;
const version = await graphRequest(connection, `${appPath}/contentVersions`, { token, method: 'POST', body: {} });
const cvId = version.id;
const fileMeta = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files`, {
token,
method: 'POST',
body: {
'@odata.type': '#microsoft.graph.mobileAppContentFile',
name: parsed.setupFile || 'IntunePackage.intunewin',
size: parsed.unencryptedContentSize,
sizeEncrypted: parsed.encryptedContentSize,
isDependency: false,
manifest: null
}
});
const fileId = fileMeta.id;
const ready = await waitForFileState(connection, token, appPath, cvId, fileId, 'azureStorageUriRequestSuccess');
await azureBlockUpload(ready.azureStorageUri, parsed.encryptedContent);
await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}/commit`, {
token,
method: 'POST',
body: { fileEncryptionInfo: parsed.fileEncryptionInfo }
});
await waitForFileState(connection, token, appPath, cvId, fileId, 'commitFileSuccess');
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`, {
token,
method: 'PATCH',
body: { '@odata.type': '#microsoft.graph.win32LobApp', committedContentVersion: cvId }
});
return { contentVersionId: cvId, fileId, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize };
}
// Replace the app's assignments in one call. The /assign action is a full
// replacement, so callers send the complete desired assignment set.
export async function assignWin32App(connection, appId, mobileAppAssignments) {
const token = await getGraphToken(connection);
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/assign`, {
token,
method: 'POST',
body: { mobileAppAssignments }
});
return { ok: true, count: mobileAppAssignments.length };
}
// Replace the app's supersedence/dependency relationships. updateRelationships
// is also a full replacement of the app's outgoing relationship set.
export async function setWin32AppRelationships(connection, appId, relationships) {
const token = await getGraphToken(connection);
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/updateRelationships`, {
token,
method: 'POST',
body: { relationships }
});
return { ok: true, count: relationships.length };
}
// Live win32LobApp metadata + assignments + relationships, used by drift checks.
export async function getWin32AppState(connection, appId) {
const token = await getGraphToken(connection);
const base = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`;
const [app, assignments, relationships] = await Promise.all([
graphRequest(connection, base, { token }),
graphRequest(connection, `${base}/assignments`, { token }).then((r) => r.value || []).catch(() => []),
graphRequest(connection, `${base}/relationships`, { token }).then((r) => r.value || []).catch(() => [])
]);
return { app, assignments, relationships };
}
export async function getGraphMobileApp(connection, appId) {
const token = await getGraphToken(connection);
const version = connection.default_api_version || 'v1.0';
return graphRequest(
connection,
`/${version}/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}?$select=id,displayName,publisher,displayVersion`,
{ token }
);
}
export async function listGraphMobileApps(connection, query = '') {
const token = await getGraphToken(connection);
const version = connection.default_api_version || 'v1.0';
const filter = query ? `&$filter=contains(displayName,'${String(query).replaceAll("'", "''")}')` : '';
const payload = await graphRequest(
connection,
`/${version}/deviceAppManagement/mobileApps?$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime,isAssigned${filter}&$top=50`,
{ token }
);
return (payload.value || []).map((app) => ({
id: app.id,
displayName: app.displayName || app.name || app.id,
publisher: app.publisher || '',
createdDateTime: app.createdDateTime || '',
lastModifiedDateTime: app.lastModifiedDateTime || '',
isAssigned: Boolean(app.isAssigned)
}));
}
export async function syncIntuneDeploymentStatus(connection, graphAppId, mode = 'reports') {
const token = await getGraphToken(connection);
const rows = [];
const sources = [];
let overview = {};
if (mode === 'reports' || mode === 'both') {
const reportResult = await syncReports(connection, token, graphAppId);
rows.push(...reportResult.rows);
overview = reportResult.overview;
sources.push(...reportResult.sources);
}
if ((mode === 'legacy-status' || mode === 'both') && !rows.length) {
const legacyRows = await syncLegacyDeviceStatuses(connection, token, graphAppId);
rows.push(...legacyRows);
sources.push('legacy-deviceStatuses');
}
return {
rows,
overview,
sources,
summary: summarizeRows(rows, overview)
};
}
async function syncReports(connection, token, graphAppId) {
const rows = [];
const sources = [];
let overview = {};
try {
const appOverview = await graphRequest(connection, '/beta/deviceManagement/reports/getAppStatusOverviewReport', {
token,
method: 'POST',
body: { filter: `(ApplicationId eq '${graphAppId}')` }
});
overview = reportRows(appOverview)[0] || {};
sources.push('getAppStatusOverviewReport');
} catch (error) {
overview = { warning: `Overview report unavailable: ${error.message}` };
}
try {
const deviceReport = await graphRequest(connection, '/beta/deviceManagement/reports/getDeviceInstallStatusReport', {
token,
method: 'POST',
body: {
select: [
'DeviceId',
'DeviceName',
'UserPrincipalName',
'Platform',
'Status',
'ErrorCode',
'AppInstallState',
'InstallState',
'InstallStateDetail',
'LastModifiedDateTime',
'ApplicationId'
],
filter: `(ApplicationId eq '${graphAppId}')`,
skip: 0,
top: 50000,
orderBy: []
}
});
rows.push(...reportRows(deviceReport).map((row) => normalizeReportStatus(row, 'device')));
sources.push('getDeviceInstallStatusReport');
} catch (error) {
rows.push(failureRow('device', 'report-unavailable', error.message, { source: 'getDeviceInstallStatusReport' }));
}
try {
const userReport = await graphRequest(connection, '/beta/deviceManagement/reports/getUserInstallStatusReport', {
token,
method: 'POST',
body: {
select: ['UserId', 'UserName', 'UserPrincipalName', 'Status', 'InstalledDeviceCount', 'FailedDeviceCount', 'ApplicationId'],
filter: `(ApplicationId eq '${graphAppId}')`,
skip: 0,
top: 50000,
orderBy: []
}
});
rows.push(...reportRows(userReport).map((row) => normalizeReportStatus(row, 'user')));
sources.push('getUserInstallStatusReport');
} catch (error) {
rows.push(failureRow('user', 'report-unavailable', error.message, { source: 'getUserInstallStatusReport' }));
}
return { rows, overview, sources };
}
async function syncLegacyDeviceStatuses(connection, token, graphAppId) {
const payload = await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${graphAppId}/deviceStatuses`, { token });
return (payload.value || []).map((row) => ({
scope: 'device',
principalId: row.id || '',
principalName: row.deviceName || row.userName || '',
deviceId: row.deviceId || '',
deviceName: row.deviceName || '',
userId: row.userId || '',
userPrincipalName: row.userPrincipalName || row.userName || '',
installState: normalizeState(row.installState || row.mobileAppInstallStatusValue || row.status),
installStateDetail: row.installStateDetail || '',
errorCode: row.errorCode,
errorDescription: row.errorDescription || failureCause(row.errorCode, row.installStateDetail),
lastSyncDateTime: row.lastSyncDateTime || '',
raw: row
}));
}
function reportRows(report) {
const schema = report.Schema || report.schema || [];
const values = report.Values || report.values || report.value || [];
if (!Array.isArray(values)) return [];
if (!schema.length) return values;
return values.map((entry) => {
if (!Array.isArray(entry)) return entry;
return Object.fromEntries(schema.map((column, index) => [column.Column || column.column || column.Name || column.name, entry[index]]));
});
}
function normalizeReportStatus(row, scope) {
const installState = normalizeState(row.Status || row.InstallState || row.AppInstallState || row.State || row.InstallStatus);
const errorCode = row.ErrorCode ?? row.Error ?? row.HexErrorCode ?? '';
return {
scope,
principalId: scope === 'device' ? row.DeviceId || row.ManagedDeviceId || '' : row.UserId || '',
principalName: scope === 'device' ? row.DeviceName || '' : row.UserName || row.UserPrincipalName || '',
deviceId: row.DeviceId || row.ManagedDeviceId || '',
deviceName: row.DeviceName || '',
userId: row.UserId || '',
userPrincipalName: row.UserPrincipalName || row.UserName || '',
installState,
installStateDetail: row.InstallStateDetail || row.StatusDetails || row.StateDetails || '',
errorCode,
errorDescription: row.ErrorDescription || row.ErrorDetails || failureCause(errorCode, row.InstallStateDetail),
lastSyncDateTime: row.LastModifiedDateTime || row.LastSyncDateTime || row.LastReportedDateTime || '',
raw: row
};
}
function normalizeState(value = '') {
const text = String(value || '').toLowerCase();
if (text.includes('fail') || text === 'error') return 'failed';
if (text.includes('install') || text === 'success' || text === 'installed') return 'installed';
if (text.includes('pending') || text.includes('progress')) return 'pending';
if (text.includes('notapplicable') || text.includes('not applicable')) return 'notApplicable';
if (text.includes('notinstalled') || text.includes('not installed')) return 'notInstalled';
return text || 'unknown';
}
function failureRow(scope, code, message, raw) {
return {
scope,
installState: 'failed',
installStateDetail: code,
errorCode: code,
errorDescription: message,
raw
};
}
function failureCause(errorCode, detail = '') {
const code = String(errorCode || '').toLowerCase();
const known = {
'0x87d1041c': 'Intune did not detect the app after installation. Check detection rules, install command, and app install timing.',
'0x87d13b9f': 'App installation failed. Review Intune Management Extension logs and PSADT logs.',
'1602': 'User deferred or cancelled installation. Confirm return code is mapped as retry and DeferRunInterval is configured when needed.',
'3010': 'Soft reboot required. Confirm Intune return code behavior.',
'1703': 'Legacy soft reboot mapping. Confirm this is intentional for the package.'
};
return known[code] || detail || '';
}
function summarizeRows(rows, overview = {}) {
const counts = { total: rows.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0 };
for (const row of rows) {
const state = row.installState || 'unknown';
if (counts[state] == null) counts.unknown += 1;
else counts[state] += 1;
}
return { counts, overview };
}

View File

@@ -0,0 +1,76 @@
import { INSTALLER_TECHNOLOGIES } from '../data/installerCatalog.js';
// Installer intelligence: infer the installer technology from a file name and
// return recommended silent install/uninstall commands plus a detection
// suggestion. Pure + table-driven, so it is fully unit-tested. Binary signature
// sniffing (reading the file) is Phase 5; this works from the name/extension.
function extensionOf(fileName = '') {
const match = String(fileName).toLowerCase().match(/(\.[a-z0-9]+)$/);
return match ? match[1] : '';
}
function render(template = '', { file = '', productCode = '' } = {}) {
return String(template)
.split('{file}').join(file)
.split('{productCode}').join(productCode || '{productCode}');
}
function shape(tech, ctx) {
return {
id: tech.id,
label: tech.label,
install: render(tech.install, ctx),
uninstall: render(tech.uninstall, ctx),
psadtInstall: render(tech.psadtInstall, ctx),
psadtUninstall: tech.psadtUninstall ? render(tech.psadtUninstall, ctx) : '',
detection: tech.detection,
notes: tech.notes
};
}
export function listInstallerTechnologies() {
return INSTALLER_TECHNOLOGIES.map(({ id, label, extensions, detection, notes }) => ({ id, label, extensions, detection, notes }));
}
// Analyze a file name. Returns the best-guess technology, a confidence level,
// and the full set of candidate technologies for that extension so the operator
// can pick when the name alone is ambiguous (common for .exe).
export function analyzeInstaller({ fileName = '', productCode = '' } = {}) {
const ext = extensionOf(fileName);
const lower = String(fileName).toLowerCase();
const ctx = { file: fileName, productCode };
const byExt = INSTALLER_TECHNOLOGIES.filter((tech) => tech.extensions.includes(ext));
if (!byExt.length) {
return {
fileName,
extension: ext,
confidence: 'unknown',
primary: null,
candidates: [],
message: ext ? `No installer technology is known for "${ext}".` : 'Provide an installer file name with an extension.'
};
}
// Unique extension (.msi/.msp/.msix...) → high confidence, single technology.
if (byExt.length === 1) {
return { fileName, extension: ext, confidence: 'high', primary: shape(byExt[0], ctx), candidates: [shape(byExt[0], ctx)] };
}
// Ambiguous (.exe): try a name hint, otherwise fall back to generic EXE.
const hinted = byExt.find((tech) => (tech.hints || []).some((hint) => lower.includes(hint)) && tech.id !== 'exe');
const generic = byExt.find((tech) => tech.id === 'exe') || byExt[0];
const primary = hinted || generic;
return {
fileName,
extension: ext,
confidence: hinted ? 'medium' : 'low',
primary: shape(primary, ctx),
// Offer all candidates (generic last) so the UI can present a picker.
candidates: [...byExt.filter((t) => t.id !== 'exe'), ...byExt.filter((t) => t.id === 'exe')].map((t) => shape(t, ctx)),
message: hinted
? `Matched "${hinted.label}" from the file name.`
: 'Ambiguous EXE — pick the matching technology, or confirm the silent switch with the vendor.'
};
}

View File

@@ -0,0 +1,66 @@
// Pure drift computation: compares the POSHManager plan (expected) against what
// Microsoft Graph currently reports (live), for the win32LobApp metadata, its
// assignments, and its supersedence/dependency relationships. No I/O — the
// controller fetches live state and feeds it here, so this is fully unit-tested.
// Fields on the win32LobApp we treat as authoritative from the plan.
const APP_DRIFT_FIELDS = [
'displayName',
'publisher',
'installCommandLine',
'uninstallCommandLine',
'applicableArchitectures'
];
function fieldDrift(expected = {}, actual = {}, fields = APP_DRIFT_FIELDS) {
const drift = [];
for (const field of fields) {
const want = expected[field] ?? '';
const got = actual[field] ?? '';
if (String(want) !== String(got)) drift.push({ field, expected: want, actual: got });
}
return drift;
}
function assignmentKey(assignment = {}) {
const target = assignment.target || {};
const type = (target['@odata.type'] || '').replace('#microsoft.graph.', '');
return `${assignment.intent || ''}|${type}|${target.groupId || ''}`;
}
function relationshipKey(rel = {}) {
const type = (rel['@odata.type'] || '').replace('#microsoft.graph.', '');
return `${type}|${rel.targetId || ''}`;
}
// Generic set diff by key: returns items present only in expected (missing in
// tenant) and only in actual (extra in tenant).
function setDiff(expected, actual, keyFn) {
const expectedKeys = new Map(expected.map((item) => [keyFn(item), item]));
const actualKeys = new Map(actual.map((item) => [keyFn(item), item]));
const missing = [...expectedKeys.keys()].filter((k) => !actualKeys.has(k));
const extra = [...actualKeys.keys()].filter((k) => !expectedKeys.has(k));
return { missing, extra };
}
export function computeDrift({
expectedApp = {},
liveApp = {},
expectedAssignments = [],
liveAssignments = [],
expectedRelationships = [],
liveRelationships = []
} = {}) {
const appDrift = fieldDrift(expectedApp, liveApp);
const assignmentDrift = setDiff(expectedAssignments, liveAssignments, assignmentKey);
const relationshipDrift = setDiff(expectedRelationships, liveRelationships, relationshipKey);
const inSync =
appDrift.length === 0 &&
assignmentDrift.missing.length === 0 &&
assignmentDrift.extra.length === 0 &&
relationshipDrift.missing.length === 0 &&
relationshipDrift.extra.length === 0;
return { inSync, appDrift, assignmentDrift, relationshipDrift };
}

View File

@@ -0,0 +1,69 @@
// Pure helpers for the application promotion pipeline: producing the next
// version's deployment plan and advancing assignment rings. No I/O, so both are
// unit-tested; the controller persists/pushes the results.
// Bump the trailing numeric component of a version string (1.2.3 -> 1.2.4,
// 126 -> 127). Falls back to appending ".1" when no number is present.
export function bumpVersion(version = '') {
const text = String(version).trim();
if (!text) return '1.0.0';
const match = text.match(/^(.*?)(\d+)(\D*)$/);
if (!match) return `${text}.1`;
const [, head, num, tail] = match;
return `${head}${Number(num) + 1}${tail}`;
}
// Build a fresh deployment payload for the next version, based on an existing
// plan. The new plan is unpublished (no graphAppId), draft status, and keeps the
// application link so the catalog groups both versions.
export function cloneForNextVersion(deployment, { version, applicationId } = {}) {
if (!deployment) throw new Error('A source deployment is required');
const nextVersion = version || bumpVersion(extractVersion(deployment.name));
const baseName = deployment.name.replace(/\s+\d[\d.]*$/, '').trim() || deployment.name;
return {
name: `${baseName} ${nextVersion}`.trim(),
profileId: deployment.profileId || null,
scriptId: deployment.scriptId || null,
applicationId: applicationId || deployment.applicationId || null,
sourceFolder: deployment.sourceFolder || '',
intunewinFile: '',
commandStyle: deployment.commandStyle,
installBehavior: deployment.installBehavior,
restartBehavior: deployment.restartBehavior,
uiMode: deployment.uiMode,
requirements: { ...(deployment.requirements || {}) },
installCommand: deployment.installCommand,
uninstallCommand: deployment.uninstallCommand,
detectionType: deployment.detectionType,
detectionRule: deployment.detectionRule,
returnCodes: deployment.returnCodes || [],
assignments: deployment.assignments || [],
relationships: deployment.relationships || [],
status: 'draft',
graphAppId: '',
graphConnectionId: deployment.graphConnectionId || null,
notes: `Version ${nextVersion} created from ${deployment.name}.`,
visibility: deployment.visibility,
groupId: deployment.groupId || null
};
}
function extractVersion(name = '') {
const match = String(name).match(/(\d[\d.]*)\s*$/);
return match ? match[1] : '';
}
// Advance a named assignment ring to a new intent (e.g. promote "Broad" from
// available to required). Returns a new assignments array; unknown rings are a
// no-op so callers can detect "nothing changed".
export function setRingIntent(assignments = [], ring, intent) {
let changed = false;
const next = assignments.map((assignment) => {
if (assignment.ring?.toLowerCase() === String(ring).toLowerCase() && assignment.intent !== intent) {
changed = true;
return { ...assignment, intent };
}
return assignment;
});
return { assignments: next, changed };
}

View File

@@ -0,0 +1,268 @@
// Pure mapping from a POSHManager Intune deployment plan (+ optional PSADT
// profile) to the Microsoft Graph `win32LobApp` resource shape and its
// assignment/relationship bodies.
//
// These functions perform no I/O so they can be unit-tested without a tenant.
// The live HTTP calls live in graphService; the publish controller composes the
// two. Field shapes follow the Graph beta `win32LobApp` documentation.
const RESTART_BEHAVIOR = {
'return-code': 'basedOnReturnCode',
suppress: 'suppress',
intune: 'force'
};
const RETURN_CODE_TYPES = new Set(['success', 'softReboot', 'hardReboot', 'retry', 'failed']);
// Map a human OS string ("Windows 10 22H2", "Windows 11 23H2") to the Graph
// windowsMinimumOperatingSystem boolean flag. Defaults to a safe broad floor.
function minimumSupportedOperatingSystem(minOs = '') {
const text = String(minOs).toLowerCase();
const flags = {
v10_1607: false, v10_1703: false, v10_1709: false, v10_1803: false,
v10_1809: false, v10_1903: false, v10_1909: false, v10_2004: false,
v10_20H2: false, v10_21H1: false, v10_21H2: false, v10_22H2: false,
v11_21H2: false, v11_22H2: false, v11_23H2: false, v11_24H2: false
};
const map = [
['11 24h2', 'v11_24H2'], ['11 23h2', 'v11_23H2'], ['11 22h2', 'v11_22H2'], ['11 21h2', 'v11_21H2'],
['22h2', 'v10_22H2'], ['21h2', 'v10_21H2'], ['21h1', 'v10_21H1'], ['20h2', 'v10_20H2'],
['2004', 'v10_2004'], ['1909', 'v10_1909'], ['1903', 'v10_1903'], ['1809', 'v10_1809'],
['1803', 'v10_1803'], ['1709', 'v10_1709'], ['1703', 'v10_1703'], ['1607', 'v10_1607']
];
const match = map.find(([needle]) => text.includes(needle));
flags[match ? match[1] : 'v10_1809'] = true;
return flags;
}
function applicableArchitectures(architecture = 'x64') {
if (architecture === 'both') return 'x64,x86';
if (architecture === 'x86') return 'x86';
return 'x64';
}
function mapReturnCodes(returnCodes = []) {
const mapped = (returnCodes || []).map((entry) => ({
returnCode: Number(entry.code),
type: RETURN_CODE_TYPES.has(entry.type) ? entry.type : 'failed'
})).filter((entry) => Number.isFinite(entry.returnCode));
// Intune requires at least the canonical 0/1707/3010/1641 floor; ensure 0=success exists.
if (!mapped.some((entry) => entry.returnCode === 0)) mapped.unshift({ returnCode: 0, type: 'success' });
return mapped;
}
// Detection rule mapping. Our model stores a single `detectionRule` string plus
// a `detectionType`, so structured types (file/registry) that need more fields
// are emitted best-effort and a warning is returned for operator review.
function mapDetectionRules(deployment, warnings) {
const rule = String(deployment.detectionRule || '').trim();
switch (deployment.detectionType) {
case 'msi-product-code':
if (!rule) warnings.push('MSI detection selected but no product code provided.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppProductCodeDetection',
productCode: rule,
productVersionOperator: 'notConfigured',
productVersion: null
}];
case 'custom-script':
if (!rule) warnings.push('Custom-script detection selected but the script body is empty.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
enforceSignatureCheck: false,
runAs32Bit: false
}];
case 'file-version':
warnings.push('File-version detection requires structured path/file/version fields; emitted a best-effort rule for review.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppFileSystemDetection',
path: rule || 'C:\\Program Files',
fileOrFolderName: '',
check32BitOn64System: false,
detectionType: 'exists',
operator: 'notConfigured',
detectionValue: null
}];
case 'registry':
warnings.push('Registry detection requires structured key/value fields; emitted a best-effort rule for review.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppRegistryDetection',
keyPath: rule || 'HKEY_LOCAL_MACHINE\\SOFTWARE',
valueName: '',
check32BitOn64System: false,
detectionType: 'exists',
operator: 'notConfigured',
detectionValue: null
}];
default:
warnings.push(`Unknown detection type "${deployment.detectionType}"; defaulted to script detection.`);
return [{
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
enforceSignatureCheck: false,
runAs32Bit: false
}];
}
}
export function buildWin32LobAppPayload(deployment, profile = null) {
const warnings = [];
if (!deployment) throw new Error('A deployment is required to build a win32LobApp payload');
const requirements = deployment.requirements || {};
const displayName = profile?.appName
? `${profile.appVendor ? `${profile.appVendor} ` : ''}${profile.appName}${profile.appVersion ? ` ${profile.appVersion}` : ''}`.trim()
: deployment.name;
const fileName = deployment.intunewinFile || `${(deployment.name || 'package').replace(/[^A-Za-z0-9._-]+/g, '-')}.intunewin`;
if (!deployment.installCommand) warnings.push('Install command is empty.');
if (!deployment.uninstallCommand) warnings.push('Uninstall command is empty.');
if (!deployment.intunewinFile) warnings.push('No .intunewin file recorded; content must be uploaded before the app is installable (Phase 2).');
const payload = {
'@odata.type': '#microsoft.graph.win32LobApp',
displayName,
description: deployment.notes || displayName,
publisher: profile?.appVendor || 'POSHManager',
fileName,
setupFilePath: fileName,
installCommandLine: deployment.installCommand || '',
uninstallCommandLine: deployment.uninstallCommand || '',
applicableArchitectures: applicableArchitectures(requirements.architecture),
allowAvailableUninstall: true,
minimumFreeDiskSpaceInMB: Number(requirements.diskSpaceMb) > 0 ? Number(requirements.diskSpaceMb) : null,
minimumSupportedOperatingSystem: minimumSupportedOperatingSystem(requirements.minOs),
installExperience: {
'@odata.type': 'microsoft.graph.win32LobAppInstallExperience',
runAsAccount: deployment.installBehavior === 'user' ? 'user' : 'system',
deviceRestartBehavior: RESTART_BEHAVIOR[deployment.restartBehavior] || 'basedOnReturnCode'
},
returnCodes: mapReturnCodes(deployment.returnCodes),
detectionRules: mapDetectionRules(deployment, warnings),
requirementRules: [],
msiInformation: null,
runAs32Bit: Boolean(requirements.runAs32Bit)
};
return { payload, warnings };
}
// ---------------------------------------------------------------------------
// Phase 3 — assignments / filters / rings
// ---------------------------------------------------------------------------
const ASSIGNMENT_INTENTS = new Set(['available', 'required', 'uninstall']);
function assignmentTarget(assignment, warnings) {
const filter = assignment.filterId
? {
deviceAndAppManagementAssignmentFilterId: assignment.filterId,
deviceAndAppManagementAssignmentFilterType: assignment.filterType && assignment.filterType !== 'none' ? assignment.filterType : 'include'
}
: { deviceAndAppManagementAssignmentFilterId: null, deviceAndAppManagementAssignmentFilterType: 'none' };
if (assignment.groupMode === 'allDevices') {
return { '@odata.type': '#microsoft.graph.allDevicesAssignmentTarget', ...filter };
}
if (assignment.groupMode === 'allUsers') {
return { '@odata.type': '#microsoft.graph.allLicensedUsersAssignmentTarget', ...filter };
}
if (!assignment.groupId) {
warnings.push(`Assignment "${assignment.ring}" has no Entra group id and was skipped. Resolve the group to its object id first.`);
return null;
}
const isExclusion = assignment.intent === 'exclude';
return {
'@odata.type': isExclusion
? '#microsoft.graph.exclusionGroupAssignmentTarget'
: '#microsoft.graph.groupAssignmentTarget',
groupId: assignment.groupId,
...filter
};
}
// Build the `mobileAppAssignments` array for the /assign action. Exclusions map
// to exclusionGroupAssignmentTarget (Graph still requires a real intent, so we
// exclude from "required"). Group assignments without a resolved object id are
// skipped with a warning rather than producing an invalid request.
export function buildAssignmentsPayload(deployment) {
const warnings = [];
const assignments = [];
for (const assignment of deployment.assignments || []) {
const target = assignmentTarget(assignment, warnings);
if (!target) continue;
const intent = assignment.intent === 'exclude'
? 'required'
: (ASSIGNMENT_INTENTS.has(assignment.intent) ? assignment.intent : 'required');
assignments.push({
'@odata.type': '#microsoft.graph.mobileAppAssignment',
intent,
target,
settings: {
'@odata.type': '#microsoft.graph.win32LobAppAssignmentSettings',
notifications: 'showAll',
restartSettings: null,
installTimeSettings: null,
deliveryOptimizationPriority: 'notConfigured',
autoUpdateSettings: assignment.autoUpdate
? { '@odata.type': '#microsoft.graph.win32LobAppAutoUpdateSettings', autoUpdateSupersededApps: 'enabled' }
: null
}
});
}
if (!assignments.length) warnings.push('No assignable targets were produced; nothing would be assigned.');
return { assignments, warnings };
}
// ---------------------------------------------------------------------------
// Phase 4 — supersedence & dependency relationships
// ---------------------------------------------------------------------------
// Intune limits a supersedence graph to 11 nodes total (this app + superseded +
// their related apps). We can only see this app's direct edges, so we enforce a
// local ceiling of 10 supersedence targets and flag that the full graph limit is
// tenant-wide. Self-references and duplicates are rejected as cycles.
export const MAX_SUPERSEDENCE_TARGETS = 10;
export function validateRelationships(relationships = [], selfAppId = '') {
const errors = [];
const seen = new Set();
let supersedenceCount = 0;
for (const rel of relationships) {
const target = rel.targetAppId;
if (!target) { errors.push('A relationship is missing targetAppId.'); continue; }
if (selfAppId && target === selfAppId) errors.push(`An app cannot have a ${rel.kind} relationship with itself (${target}).`);
const key = `${rel.kind}:${target}`;
if (seen.has(key)) errors.push(`Duplicate ${rel.kind} relationship to ${target}.`);
seen.add(key);
if (rel.kind === 'supersedence') supersedenceCount += 1;
}
if (supersedenceCount > MAX_SUPERSEDENCE_TARGETS) {
errors.push(`Too many supersedence targets (${supersedenceCount}); Intune allows at most ${MAX_SUPERSEDENCE_TARGETS} direct targets (11-node graph limit).`);
}
return errors;
}
export function buildRelationshipsPayload(relationships = [], selfAppId = '') {
const errors = validateRelationships(relationships, selfAppId);
if (errors.length) {
const error = new Error(errors.join(' '));
error.validation = errors;
throw error;
}
const mapped = relationships.map((rel) => {
if (rel.kind === 'dependency') {
return {
'@odata.type': '#microsoft.graph.mobileAppDependency',
targetId: rel.targetAppId,
dependencyType: rel.mode === 'autoInstall' ? 'autoInstall' : 'detect'
};
}
return {
'@odata.type': '#microsoft.graph.mobileAppSupersedence',
targetId: rel.targetAppId,
supersedenceType: rel.mode === 'replace' ? 'replace' : 'update'
};
});
return { relationships: mapped };
}

View File

@@ -0,0 +1,80 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
// Wraps a PSADT/package source folder into a `.intunewin` using the Microsoft
// Win32 Content Prep Tool (IntuneWinAppUtil.exe) on Windows, or an optional
// cross-platform packager via INTUNEWIN_BUILD_COMMAND. The command construction
// is pure and unit-tested; the spawn wrapper is thin.
export function resolveToolPath() {
return config.intunewinUtilPath || path.join(config.toolsDir, 'IntuneWinAppUtil.exe');
}
// Where to put the tool and whether we can build here.
export function builderAvailability() {
if (config.intunewinBuildCommand) {
return { available: true, mode: 'external-command', toolPath: null };
}
const toolPath = resolveToolPath();
if (!fs.existsSync(toolPath)) {
return { available: false, mode: 'bundled', toolPath, reason: `IntuneWinAppUtil.exe not found at ${toolPath}. Place it in the tools directory or set INTUNEWIN_UTIL_PATH / INTUNEWIN_BUILD_COMMAND.` };
}
if (process.platform !== 'win32') {
return { available: false, mode: 'bundled', toolPath, reason: 'IntuneWinAppUtil.exe requires Windows. Set INTUNEWIN_BUILD_COMMAND to use a cross-platform packager.' };
}
return { available: true, mode: 'bundled', toolPath };
}
// Pure: produce the spawn descriptor for the configured packager.
export function buildPackagerInvocation({ toolPath, sourceFolder, setupFile, outputDir, commandTemplate }) {
if (commandTemplate) {
const command = commandTemplate
.split('{source}').join(sourceFolder)
.split('{setup}').join(setupFile)
.split('{output}').join(outputDir);
return { shell: true, command };
}
return {
shell: false,
command: toolPath,
args: ['-c', sourceFolder, '-s', setupFile, '-o', outputDir, '-q']
};
}
// IntuneWinAppUtil names the output after the setup file's base name.
export function expectedOutputFile(outputDir, setupFile) {
return path.join(outputDir, `${path.parse(setupFile).name}.intunewin`);
}
export function buildIntuneWin({ sourceFolder, setupFile, outputDir }) {
return new Promise((resolve, reject) => {
const availability = builderAvailability();
if (!availability.available) return reject(new Error(availability.reason || 'Packager not available'));
if (!sourceFolder || !fs.existsSync(sourceFolder)) return reject(new Error(`Source folder not found: ${sourceFolder}`));
if (!setupFile) return reject(new Error('A setup file (entry installer) is required'));
fs.mkdirSync(outputDir, { recursive: true });
const invocation = buildPackagerInvocation({
toolPath: availability.toolPath,
sourceFolder,
setupFile,
outputDir,
commandTemplate: config.intunewinBuildCommand
});
const child = invocation.shell
? spawn(invocation.command, { shell: true })
: spawn(invocation.command, invocation.args, { shell: false });
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
child.on('error', (error) => reject(new Error(`Packager failed to start: ${error.message}`)));
child.on('close', (code) => {
const outputFile = expectedOutputFile(outputDir, setupFile);
if (code === 0 && fs.existsSync(outputFile)) return resolve({ outputFile });
reject(new Error(`Packager exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
});
});
}

View File

@@ -0,0 +1,104 @@
import zlib from 'node:zlib';
// Minimal reader for the `.intunewin` package produced by the Microsoft Win32
// Content Prep Tool. The package is a ZIP containing:
// IntuneWinPackage/Metadata/Detection.xml (encryption info + sizes)
// IntuneWinPackage/Contents/IntunePackage.intunewin (AES-encrypted payload)
//
// We avoid a third-party zip dependency by parsing the central directory and
// inflating entries with the built-in zlib. Only stored (0) and deflate (8)
// compression methods are supported, which is what the tool emits.
const EOCD_SIG = 0x06054b50;
const CEN_SIG = 0x02014b50;
const LOC_SIG = 0x04034b50;
function findEndOfCentralDirectory(buffer) {
// EOCD is at the end; scan backwards past any (absent) comment.
for (let i = buffer.length - 22; i >= 0; i--) {
if (buffer.readUInt32LE(i) === EOCD_SIG) return i;
}
throw new Error('Not a valid .intunewin (ZIP end-of-central-directory not found)');
}
function readCentralDirectory(buffer) {
const eocd = findEndOfCentralDirectory(buffer);
const count = buffer.readUInt16LE(eocd + 10);
let offset = buffer.readUInt32LE(eocd + 16);
const entries = [];
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(offset) !== CEN_SIG) break;
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLen);
entries.push({ name, method, compressedSize, localHeaderOffset });
offset += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function readEntryData(buffer, entry) {
const o = entry.localHeaderOffset;
if (buffer.readUInt32LE(o) !== LOC_SIG) throw new Error(`Bad local header for ${entry.name}`);
const nameLen = buffer.readUInt16LE(o + 26);
const extraLen = buffer.readUInt16LE(o + 28);
const dataStart = o + 30 + nameLen + extraLen;
const raw = buffer.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.method === 0) return Buffer.from(raw);
if (entry.method === 8) return zlib.inflateRawSync(raw);
throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}`);
}
function findEntry(entries, suffix) {
// Match by path suffix so we tolerate the IntuneWinPackage/ prefix casing.
const lower = suffix.toLowerCase();
return entries.find((e) => e.name.toLowerCase().replace(/\\/g, '/').endsWith(lower));
}
function xmlValue(xml, tag) {
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i'));
return match ? match[1].trim() : '';
}
// Graph's commit step expects camelCase fileEncryptionInfo; map directly from
// the Detection.xml EncryptionInfo element.
function parseEncryptionInfo(xml) {
const block = xml.match(/<EncryptionInfo>([\s\S]*?)<\/EncryptionInfo>/i);
if (!block) throw new Error('Detection.xml is missing EncryptionInfo');
const info = block[1];
return {
encryptionKey: xmlValue(info, 'EncryptionKey'),
macKey: xmlValue(info, 'MacKey'),
initializationVector: xmlValue(info, 'InitializationVector'),
mac: xmlValue(info, 'Mac'),
profileIdentifier: xmlValue(info, 'ProfileIdentifier') || 'ProfileVersion1',
fileDigest: xmlValue(info, 'FileDigest'),
fileDigestAlgorithm: xmlValue(info, 'FileDigestAlgorithm') || 'SHA256'
};
}
export function parseIntunewin(buffer) {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const entries = readCentralDirectory(buffer);
const detectionEntry = findEntry(entries, 'metadata/detection.xml');
if (!detectionEntry) throw new Error('Detection.xml not found in .intunewin package');
const contentEntry = findEntry(entries, 'contents/intunepackage.intunewin');
if (!contentEntry) throw new Error('Encrypted content (IntunePackage.intunewin) not found in package');
const xml = readEntryData(buffer, detectionEntry).toString('utf8');
const encryptedContent = readEntryData(buffer, contentEntry);
const unencryptedContentSize = Number(xmlValue(xml, 'UnencryptedContentSize')) || 0;
return {
setupFile: xmlValue(xml, 'SetupFile') || xmlValue(xml, 'FileName') || '',
name: xmlValue(xml, 'Name') || '',
unencryptedContentSize,
encryptedContent,
encryptedContentSize: encryptedContent.length,
fileEncryptionInfo: parseEncryptionInfo(xml)
};
}

44
server/services/logger.js Normal file
View File

@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
import winston from 'winston';
import { config } from '../config.js';
fs.mkdirSync(config.logDir, { recursive: true });
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
);
export const logger = winston.createLogger({
level: config.isProduction ? 'info' : 'debug',
format: logFormat,
transports: [
new winston.transports.File({ filename: path.join(config.logDir, 'system.log'), maxsize: 5_000_000, maxFiles: 5 }),
...(!config.isProduction
? [new winston.transports.Console({
format: winston.format.combine(winston.format.colorize(), winston.format.simple())
})]
: [])
]
});
export function readSystemLog(limit = 300) {
// System logs are line-delimited JSON; malformed historic lines are returned as plain messages.
const file = path.join(config.logDir, 'system.log');
if (!fs.existsSync(file)) return [];
const content = fs.readFileSync(file, 'utf8');
return content
.trim()
.split('\n')
.filter(Boolean)
.slice(-limit)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return { level: 'info', message: line };
}
});
}

View File

@@ -0,0 +1,317 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { db, id, now } from '../db.js';
import { decryptSecret } from './cryptoStore.js';
import { config } from '../config.js';
import { logger } from './logger.js';
import { listExecutableAssets } from '../models/assetModel.js';
import { listExecutableCustomVariables } from '../models/variableModel.js';
import { getBooleanSetting } from '../models/settingsModel.js';
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
// flipped at runtime via the Config screen (allow_script_execution). The DB
// value, when present, takes precedence so the UI toggle is actually effective.
function scriptExecutionAllowed() {
return getBooleanSetting('allow_script_execution', config.allowScriptExecution);
}
export function executeRunPlan(runplanId, userId) {
// Create the durable job record before async execution so the UI can attach to logs immediately.
const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
if (!runplan) throw new Error('RunPlan not found');
const script = db.prepare('SELECT * FROM scripts WHERE id = ?').get(runplan.script_id);
if (!script) throw new Error('Script not found');
const hosts = db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM runplan_hosts rh
JOIN hosts h ON h.id = rh.host_id
LEFT JOIN credentials c ON c.id = h.credential_id
WHERE rh.runplan_id = ?
ORDER BY h.name
`).all(runplanId);
if (hosts.length === 0) throw new Error('RunPlan has no hosts');
const jobId = id('job');
db.prepare(`
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)
`).run(jobId, runplan.id, script.id, userId, now(), now());
for (const host of hosts) {
db.prepare(`
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL)
`).run(id('jhost'), jobId, host.id);
}
const executionContext = {
jobId,
runplanId: runplan.id,
scriptId: script.id,
scriptName: script.name,
triggeredBy: userId,
customVariables: listExecutableCustomVariables(userId),
assets: listExecutableAssets(userId)
};
void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch((error) => {
logger.error('job runner crashed', { jobId, error });
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
});
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
}
export function executeScriptTest(scriptId, payload, userId) {
const script = db.prepare(`
SELECT * FROM scripts
WHERE id = ? AND (
visibility = 'shared'
OR owner_user_id = ?
OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(scriptId, userId, userId);
if (!script) throw new Error('Script not found');
const host = db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM hosts h
JOIN credentials c ON c.id = ?
AND (
c.visibility = 'shared'
OR c.owner_user_id = ?
OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
WHERE h.id = ? AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
if (!host) throw new Error('Visible host and credential are required');
const jobId = id('job');
db.prepare(`
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, NULL, ?, 'running', ?, ?, ?)
`).run(jobId, script.id, userId, now(), now());
db.prepare(`
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL)
`).run(id('jhost'), jobId, host.id);
const executionContext = {
jobId,
runplanId: 'script-test',
scriptId: script.id,
scriptName: script.name,
triggeredBy: userId,
testRun: true,
customVariables: listExecutableCustomVariables(userId),
assets: listExecutableAssets(userId)
};
appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
void runJob(jobId, script, [host], false, executionContext).catch((error) => {
logger.error('script test runner crashed', { jobId, error });
appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
});
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
}
async function runJob(jobId, script, hosts, parallel, executionContext) {
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
const runner = async (host) => runHost(jobId, script, host, executionContext);
if (parallel) {
await Promise.all(hosts.map(runner));
} else {
for (const host of hosts) await runner(host);
}
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
}
async function runHost(jobId, script, host, executionContext) {
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id);
appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`);
if (!scriptExecutionAllowed()) {
appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
finishHost(jobId, host.id, 'failed', 126);
return;
}
const secret = decryptSecret(host);
const wrapper = buildWrapper(script.content, host, executionContext);
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
try {
await fs.writeFile(file, wrapper, { mode: 0o600 });
await spawnPowerShell(jobId, host, file, secret, executionContext);
} catch (error) {
appendLog(jobId, host.id, 'stderr', error.message);
finishHost(jobId, host.id, 'failed', 1);
} finally {
await fs.rm(file, { force: true }).catch(() => {});
}
}
function buildWrapper(content, host, executionContext = {}) {
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
const runtimeVariables = runtimeVariableValues(host, executionContext);
const customVariables = executionContext.customVariables || [];
const assetVariables = (executionContext.assets || []).map((asset) => ({
name: `asset:${asset.id}`,
value: asset.localPath,
valueType: 'string'
}));
const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]);
const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`;
const escaped = scriptWithPrelude.replace(/'@/g, "'`@");
if (host.transport === 'local') {
return [
'$ErrorActionPreference = "Continue"',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
'& $scriptBlock'
].join('\n');
}
if (host.transport === 'ssh') {
return [
'$ErrorActionPreference = "Continue"',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock`
].join('\n');
}
return [
'$ErrorActionPreference = "Continue"',
'$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
'$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock`
].join('\n');
}
function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
// Secrets are passed through process environment variables and are never written to job logs.
const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')]));
const assetManifest = JSON.stringify(executionContext.assets || []);
return new Promise((resolve) => {
const child = spawn(config.powershellBin, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], {
env: {
...process.env,
...runtimeEnv,
POSHM_HOST_ID: host.id,
POSHM_HOST_NAME: host.name,
POSHM_HOST_ADDRESS: host.address,
POSHM_HOST_TRANSPORT: host.transport,
POSHM_ASSET_MANIFEST: assetManifest,
POSHM_USERNAME: host.credential_username || '',
POSHM_SECRET: secret || ''
},
shell: false
});
child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk));
child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk));
child.on('error', (error) => {
appendLog(jobId, host.id, 'stderr', `Unable to start PowerShell: ${error.message}`);
finishHost(jobId, host.id, 'failed', 127);
resolve();
});
child.on('close', (code, signal) => {
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
const exitCode = code ?? 1;
finishHost(jobId, host.id, exitCode === 0 ? 'completed' : 'failed', exitCode);
resolve();
});
});
}
function appendChunk(jobId, hostId, stream, chunk) {
String(chunk)
.split(/\r?\n/)
.filter(Boolean)
.forEach((line) => appendLog(jobId, hostId, stream, line));
}
function appendLog(jobId, hostId, stream, line) {
db.prepare(`
INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id('log'), jobId, hostId, stream, line, now());
}
function finishHost(jobId, hostId, status, exitCode) {
db.prepare(`
UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ?
WHERE job_id = ? AND host_id = ?
`).run(status, exitCode, now(), jobId, hostId);
appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`);
}
function runtimeVariableValues(host, executionContext) {
const assetManifest = JSON.stringify(executionContext.assets || []);
return [
{ name: 'POSHM_JOB_ID', value: executionContext.jobId || '', valueType: 'string' },
{ name: 'POSHM_RUNPLAN_ID', value: executionContext.runplanId || '', valueType: 'string' },
{ name: 'POSHM_SCRIPT_ID', value: executionContext.scriptId || '', valueType: 'string' },
{ name: 'POSHM_SCRIPT_NAME', value: executionContext.scriptName || '', valueType: 'string' },
{ name: 'POSHM_HOST_ID', value: host.id, valueType: 'string' },
{ name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' },
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
{ name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' },
{ name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
];
}
function buildVariablePrelude(runtimeVariables, customVariables) {
const envLines = runtimeVariables.map((variable) => `$env:${variable.name} = ${psLiteral(variable.value, variable.valueType)}`);
const setLines = [...runtimeVariables, ...customVariables]
.filter((variable) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(variable.name))
.map((variable) => `Set-Variable -Name ${psString(variable.name)} -Value ${psLiteral(variable.value, variable.valueType)} -Scope Script`);
return [
'# POSHManager runtime variables',
...envLines,
...setLines
].join('\n');
}
function renderTokens(content, variables) {
let rendered = String(content || '');
for (const variable of variables) {
const token = `{{${variable.name}}}`;
rendered = rendered.split(token).join(String(variable.value ?? ''));
}
return rendered;
}
function psLiteral(value, type = 'string') {
if (type === 'boolean') return String(value).toLowerCase() === 'true' || value === true ? '$true' : '$false';
if (type === 'number' && value !== '' && Number.isFinite(Number(value))) return String(Number(value));
if (type === 'json') return `(ConvertFrom-Json -InputObject ${psString(value)})`;
return psString(value);
}
function psString(value) {
return `'${String(value || '').replace(/'/g, "''")}'`;
}

View File

@@ -0,0 +1,73 @@
import { applyPromotion, listPromotionCandidates } from '../models/psadtModel.js';
import { getGraphConnectionSystem } from '../models/graphModel.js';
import { setRingIntent } from './intunePromotionService.js';
import { buildAssignmentsPayload } from './intunePublishService.js';
import { assignWin32App } from './graphService.js';
import { logger } from './logger.js';
// Scheduled auto-promotion: after a deployment's soak window elapses, advance
// its configured rollout ring's intent. When the linked Graph connection is
// write-enabled and not approval-gated, the new assignments are also pushed to
// the tenant; otherwise the plan is promoted and an operator pushes it.
// Pure eligibility decision so the timing logic is unit-tested.
export function evaluatePromotion(deployment, nowMs = Date.now()) {
const hours = Number(deployment.autoPromoteAfterHours) || 0;
if (hours <= 0) return { due: false, reason: 'auto-promote disabled' };
if (!deployment.autoPromoteRing) return { due: false, reason: 'no target ring' };
if (deployment.promotedAt) return { due: false, reason: 'already promoted' };
if (!deployment.soakStartedAt) return { due: false, reason: 'soak not started' };
const soakStart = Date.parse(deployment.soakStartedAt);
if (Number.isNaN(soakStart)) return { due: false, reason: 'invalid soak timestamp' };
const elapsedHours = (nowMs - soakStart) / 3_600_000;
if (elapsedHours < hours) return { due: false, reason: `soaking (${elapsedHours.toFixed(1)}/${hours}h)` };
return { due: true, reason: 'soak window elapsed' };
}
export async function runAutoPromotions({ nowMs = Date.now(), pusher = assignWin32App } = {}) {
const summary = { evaluated: 0, promoted: 0, pushed: 0, errors: 0 };
for (const deployment of listPromotionCandidates()) {
summary.evaluated += 1;
const decision = evaluatePromotion(deployment, nowMs);
if (!decision.due) continue;
try {
const { assignments, changed } = setRingIntent(deployment.assignments, deployment.autoPromoteRing, deployment.autoPromoteIntent);
applyPromotion(deployment.id, changed ? assignments : deployment.assignments);
summary.promoted += 1;
// Optionally push to the tenant when it is safe to do so automatically.
if (deployment.graphAppId && deployment.graphConnectionId) {
const connection = getGraphConnectionSystem(deployment.graphConnectionId);
if (connection?.allow_write && !connection?.require_approval) {
const built = buildAssignmentsPayload({ ...deployment, assignments });
await pusher(connection, deployment.graphAppId, built.assignments);
summary.pushed += 1;
}
}
} catch (error) {
summary.errors += 1;
logger.error('auto-promotion failed', { deploymentId: deployment.id, error: error.message });
}
}
return summary;
}
let timer = null;
export function startPromotionWorker() {
const minutes = Number(process.env.AUTO_PROMOTE_INTERVAL_MINUTES || 0);
if (!Number.isFinite(minutes) || minutes <= 0) return null;
timer = setInterval(() => {
runAutoPromotions()
.then((summary) => logger.info('auto-promotion run complete', summary))
.catch((error) => logger.error('auto-promotion run failed', { error: error.message }));
}, minutes * 60 * 1000);
timer.unref?.();
logger.info(`auto-promotion scheduled every ${minutes} minute(s)`);
return timer;
}
export function stopPromotionWorker() {
if (timer) clearInterval(timer);
timer = null;
}

View File

@@ -0,0 +1,148 @@
const replacements = [
{ id: 'MIG-FN-EXECUTE-PROCESS', label: 'Execute-Process to Start-ADTProcess', pattern: /\bExecute-Process\b/g, replacement: 'Start-ADTProcess', automatic: true },
{ id: 'MIG-FN-EXECUTE-MSI', label: 'Execute-MSI to Start-ADTMsiProcess', pattern: /\bExecute-MSI\b/g, replacement: 'Start-ADTMsiProcess', automatic: true },
{ id: 'MIG-FN-EXECUTE-MSP', label: 'Execute-MSP to Start-ADTMspProcess', pattern: /\bExecute-MSP\b/g, replacement: 'Start-ADTMspProcess', automatic: true },
{ id: 'MIG-FN-WELCOME', label: 'Show-InstallationWelcome to Show-ADTInstallationWelcome', pattern: /\bShow-InstallationWelcome\b/g, replacement: 'Show-ADTInstallationWelcome', automatic: true },
{ id: 'MIG-FN-PROMPT', label: 'Show-InstallationPrompt to Show-ADTInstallationPrompt', pattern: /\bShow-InstallationPrompt\b/g, replacement: 'Show-ADTInstallationPrompt', automatic: true },
{ id: 'MIG-FN-PROGRESS', label: 'Show-InstallationProgress to Show-ADTInstallationProgress', pattern: /\bShow-InstallationProgress\b/g, replacement: 'Show-ADTInstallationProgress', automatic: true },
{ id: 'MIG-FN-RESTART', label: 'Show-InstallationRestartPrompt to Show-ADTInstallationRestartPrompt', pattern: /\bShow-InstallationRestartPrompt\b/g, replacement: 'Show-ADTInstallationRestartPrompt', automatic: true },
{ id: 'MIG-FN-LOG', label: 'Write-Log to Write-ADTLogEntry', pattern: /\bWrite-Log\b/g, replacement: 'Write-ADTLogEntry', automatic: true },
{ id: 'MIG-FN-REMOVE-MSI', label: 'Remove-MSIApplications to Uninstall-ADTApplication', pattern: /\bRemove-MSIApplications\b/g, replacement: 'Uninstall-ADTApplication', automatic: true },
{ id: 'MIG-FN-HKCU', label: 'Invoke-HKCURegistrySettingsForAllUsers to Invoke-ADTAllUsersRegistryAction', pattern: /\bInvoke-HKCURegistrySettingsForAllUsers\b/g, replacement: 'Invoke-ADTAllUsersRegistryAction', automatic: true },
{ id: 'MIG-NAME-DEPLOY-PS1', label: 'Deploy-Application.ps1 to Invoke-AppDeployToolkit.ps1', pattern: /\bDeploy-Application\.ps1\b/g, replacement: 'Invoke-AppDeployToolkit.ps1', automatic: true },
{ id: 'MIG-NAME-DEPLOY-EXE', label: 'Deploy-Application.exe to Invoke-AppDeployToolkit.exe', pattern: /\bDeploy-Application\.exe\b/g, replacement: 'Invoke-AppDeployToolkit.exe', automatic: true },
{ id: 'MIG-VAR-APPNAME', label: '$appName to $adtSession.AppName', pattern: /\$appName\b/g, replacement: '$adtSession.AppName', automatic: true },
{ id: 'MIG-VAR-APPVERSION', label: '$appVersion to $adtSession.AppVersion', pattern: /\$appVersion\b/g, replacement: '$adtSession.AppVersion', automatic: true },
{ id: 'MIG-VAR-APPVENDOR', label: '$appVendor to $adtSession.AppVendor', pattern: /\$appVendor\b/g, replacement: '$adtSession.AppVendor', automatic: true },
{ id: 'MIG-VAR-APPARCH', label: '$appArch to $adtSession.AppArch', pattern: /\$appArch\b/g, replacement: '$adtSession.AppArch', automatic: true },
{ id: 'MIG-VAR-APPLANG', label: '$appLang to $adtSession.AppLang', pattern: /\$appLang\b/g, replacement: '$adtSession.AppLang', automatic: true },
{ id: 'MIG-VAR-APPREVISION', label: '$appRevision to $adtSession.AppRevision', pattern: /\$appRevision\b/g, replacement: '$adtSession.AppRevision', automatic: true },
{ id: 'MIG-MODULE-420', label: 'Pin PSAppDeployToolkit ModuleVersion to 4.2.0', pattern: /ModuleVersion\s*=\s*['"][0-9.]+['"]/g, replacement: "ModuleVersion = '4.2.0'", automatic: true }
];
const manualChecks = [
{
id: 'MANUAL-SERVICEUI',
severity: 'critical',
pattern: /\b(ServiceUI\.exe|Invoke-ServiceUI\.ps1)\b/i,
title: 'Remove ServiceUI launch wrappers',
guidance: 'PSADT v4.1+ provides native user-session UI for Intune. Remove ServiceUI and test with SYSTEM context launch helpers.'
},
{
id: 'MANUAL-TEMPLATE-SHAPE',
severity: 'warning',
pattern: /function\s+(Install|Uninstall|Repair)-ADTDeployment/i,
title: 'Older function-based deployment phase shape detected',
guidance: 'Review the migrated script against the upstream v4 template that uses New-Variable phase scriptblocks and Open-ADTSession.'
},
{
id: 'MANUAL-RAW-MSIEXEC',
severity: 'warning',
pattern: /\bmsiexec(?:\.exe)?\b/i,
title: 'Raw msiexec call needs installer review',
guidance: 'Prefer Start-ADTMsiProcess or Start-ADTMspProcess so logging, success codes, and reboot handling stay consistent.'
},
{
id: 'MANUAL-INTUNE-DEFER',
severity: 'warning',
pattern: /Show-ADTInstallationWelcome[^\n\r]*-AllowDefer\b(?![^\n\r]*-DeferRunInterval)/i,
title: 'Intune deferral flow should include DeferRunInterval',
guidance: 'Add -DeferRunInterval to avoid repeated Intune retry prompts after a 1602 defer result.'
}
];
export function planPsadtMigration(content = '', options = {}) {
const source = String(content || '');
const steps = [];
let automaticCount = 0;
for (const rule of replacements) {
const matches = [...source.matchAll(rule.pattern)].filter((match) => !isCommentLine(source, match.index || 0));
if (!matches.length) continue;
automaticCount += matches.length;
steps.push({
id: rule.id,
type: 'replace',
automatic: rule.automatic,
status: 'ready',
title: rule.label,
count: matches.length,
lines: matches.slice(0, 8).map((match) => lineNumber(source, match.index || 0)),
replacement: rule.replacement
});
}
for (const rule of manualChecks) {
const match = rule.pattern.exec(source);
if (!match || isCommentLine(source, match.index || 0)) continue;
steps.push({
id: rule.id,
type: 'manual-review',
automatic: false,
status: 'review',
severity: rule.severity,
title: rule.title,
guidance: rule.guidance,
lines: [lineNumber(source, match.index || 0)]
});
}
if (!/Open-ADTSession/i.test(source)) {
steps.push({
id: 'MANUAL-OPEN-ADTSESSION',
type: 'manual-review',
automatic: false,
status: 'review',
severity: 'warning',
title: 'Open-ADTSession not detected',
guidance: 'Latest PSADT scripts should initialize the module and open an ADTSession before invoking phase logic.',
lines: []
});
}
return {
targetVersion: options.targetVersion || '4.2.0',
summary: {
steps: steps.length,
automatic: steps.filter((step) => step.automatic).length,
manual: steps.filter((step) => !step.automatic).length,
replacements: automaticCount
},
steps,
migratedContent: applyPsadtMigration(source)
};
}
export function applyPsadtMigration(content = '') {
let migrated = String(content || '');
for (const rule of replacements) {
migrated = replaceOutsideComments(migrated, rule.pattern, rule.replacement);
}
migrated = migrated.replace(/(Show-ADTInstallationPrompt[^\n\r]*)\s-Icon\s+\S+/gi, '$1');
if (!/POSHManager PSADT migration notes/i.test(migrated)) {
migrated = [
'# POSHManager PSADT migration notes:',
'# - Automatic replacements were applied for known v3/v4 legacy names and variables.',
'# - Review manual migration findings before deploying through Intune or any production channel.',
'',
migrated
].join('\n');
}
return migrated;
}
function replaceOutsideComments(content, pattern, replacement) {
return content.split(/\r?\n/).map((line) => {
if (line.trimStart().startsWith('#')) return line;
return line.replace(pattern, replacement);
}).join('\n');
}
function isCommentLine(content, index) {
const line = content.slice(0, index).split(/\r?\n/).length;
return content.split(/\r?\n/)[line - 1]?.trimStart().startsWith('#');
}
function lineNumber(content, index) {
return content.slice(0, index).split(/\r?\n/).length;
}

View File

@@ -0,0 +1,247 @@
import { psadtValidationRules } from '../data/psadtValidationRules.js';
const ruleById = new Map(psadtValidationRules.map((rule) => [rule.id, rule]));
const legacyFunctions = [
'Execute-Process',
'Execute-MSI',
'Execute-MSP',
'Show-InstallationWelcome',
'Show-InstallationPrompt',
'Show-InstallationProgress',
'Show-InstallationRestartPrompt',
'Write-Log',
'Remove-MSIApplications',
'Invoke-HKCURegistrySettingsForAllUsers'
];
export function validationRuleCatalog() {
return psadtValidationRules;
}
export function validatePsadtScript(content = '', context = {}) {
const text = String(content || '');
const findings = [
...checkPowerShellRequirement(text),
...checkRequireAdminConfig(text),
...checkRemovedDetectionConfig(text),
...checkAppProcessesToClose(text),
...checkSessionCleanup(text),
...checkModuleVersionDrift(text),
...checkDeployModeAuto(text),
...checkLegacyFunctions(text),
...checkLegacyDeploymentNaming(text),
...checkLegacyDeploymentVariables(text),
...checkEnvironmentVariables(text),
...checkServiceUi(text),
...checkDeferRunInterval(text, context),
...checkPromptIcon(text),
...checkRawMsiExec(text),
...checkStartProcessWait(text),
...checkExitCodes(text),
...checkGlobalModuleImport(text),
...checkDeprecatedObjectProperty(text),
...checkUserContextRequireAdmin(text, context)
].map((finding, index) => ({
id: `${finding.ruleId}-${index + 1}`,
...ruleById.get(finding.ruleId),
...finding
}));
return {
ok: findings.every((finding) => !['critical', 'error'].includes(finding.severity)),
score: scoreFindings(findings),
counts: countFindings(findings),
findings,
rules: psadtValidationRules,
analyzedAt: new Date().toISOString()
};
}
function add(ruleId, text, match, extra = {}) {
return {
ruleId,
line: lineNumber(text, match.index ?? 0),
excerpt: lineText(text, match.index ?? 0),
...extra
};
}
function checkPowerShellRequirement(text) {
const findings = [];
for (const match of text.matchAll(/#requires\s+-version\s+([0-9.]+)/gi)) {
const version = Number.parseFloat(match[1]);
if (Number.isFinite(version) && version < 5.1) findings.push(add('REQ-PSVERSION-MINIMUM', text, match, { detected: match[0] }));
}
return findings;
}
function checkRequireAdminConfig(text) {
const findings = [];
const configPattern = /(Toolkit\.)?RequireAdmin\s*=/gi;
for (const match of text.matchAll(configPattern)) {
const nearby = text.slice(Math.max(0, match.index - 80), match.index + 160);
if (!/\$adtSession\s*=|@\{|RequireAdmin\s*=\s*\$(true|false)/i.test(nearby) || /Toolkit\.RequireAdmin/i.test(match[0])) {
findings.push(add('UPG-REQUIREADMIN-CONFIG', text, match));
}
}
return findings;
}
function checkRemovedDetectionConfig(text) {
return [...text.matchAll(/\b(Toolkit\.)?(OOBEDetection|SessionDetection)\b/gi)]
.map((match) => add('UPG-REMOVED-CONFIG-DETECTION', text, match, { detected: match[0] }));
}
function checkAppProcessesToClose(text) {
if (/AppProcessesToClose/i.test(text)) return [];
return [...text.matchAll(/Show-ADTInstallationWelcome[^\n\r]*-CloseProcesses\s+(?!\$adtSession\.AppProcessesToClose)/gi)]
.map((match) => add('UPG-APP-PROCESSES-SESSION', text, match));
}
function checkSessionCleanup(text) {
if (!/Open-ADTSession/i.test(text)) return [];
if (/Remove-ADTHashtableNullOrEmptyValues/i.test(text)) return [];
const match = /Open-ADTSession/i.exec(text);
return match ? [add('UPG-MISSING-SESSION-CLEANUP', text, match)] : [];
}
function checkLegacyFunctions(text) {
const pattern = new RegExp(`\\b(${legacyFunctions.map(escapeRegExp).join('|')})\\b`, 'gi');
return [...text.matchAll(pattern)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('LEGACY-V3-FUNCTION', text, match, { detected: match[0] }));
}
function checkLegacyDeploymentNaming(text) {
return [...text.matchAll(/\bDeploy-Application\.(ps1|exe)\b/gi)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('LEGACY-DEPLOY-APPLICATION', text, match));
}
function checkLegacyDeploymentVariables(text) {
return [...text.matchAll(/\$(appName|appVersion|appVendor|appArch|appLang|appRevision)\b/g)]
.map((match) => add('LEGACY-DEPLOYMENT-VARIABLE', text, match, { detected: match[0] }));
}
function checkEnvironmentVariables(text) {
if (/Open-ADTSession|Export-ADTEnvironmentTableToSessionState/i.test(text)) return [];
return [...text.matchAll(/\$(envProgramFiles|envProgramFilesX86|envSystemRoot|envWinDir|envTemp)\b/g)]
.map((match) => add('FAQ-ENV-VAR-BEFORE-SESSION', text, match, { detected: match[0] }));
}
function checkServiceUi(text) {
return [...text.matchAll(/\b(ServiceUI\.exe|Invoke-ServiceUI\.ps1)\b/gi)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('INTUNE-SERVICEUI', text, match));
}
function checkDeferRunInterval(text, context) {
const findings = [];
const intuneHint = context.platform === 'intune' || /Intune|IME|Microsoft Intune/i.test(text);
for (const match of text.matchAll(/Show-ADTInstallationWelcome[^\n\r]*-AllowDefer\b(?![^\n\r]*-DeferRunInterval)/gi)) {
if (intuneHint || /Invoke-AppDeployToolkit\.exe|DeployMode/i.test(text)) findings.push(add('INTUNE-DEFER-RUN-INTERVAL', text, match));
}
return findings;
}
function checkPromptIcon(text) {
return [...text.matchAll(/Show-ADTInstallationPrompt[^\n\r]*\s-Icon\b/gi)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('UI-PROMPT-ICON-FLUENT', text, match));
}
function checkRawMsiExec(text) {
return [...text.matchAll(/\bmsiexec(?:\.exe)?\b[^\n\r]*(\/i|\/x|\/f|\/p)/gi)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('INSTALL-RAW-MSIEXEC', text, match));
}
function checkStartProcessWait(text) {
return [...text.matchAll(/Start-ADTProcess[^\n\r]*-FilePath\s+['"][^'"]+\.(exe|cmd|bat)['"][^\n\r]*/gi)]
.filter((match) => !/-WaitForChildProcesses\b/i.test(match[0]) && !/-NoWait\b/i.test(match[0]))
.map((match) => add('INSTALL-PROCESS-NO-WAIT-CHILD', text, match));
}
function checkExitCodes(text) {
const findings = [];
for (const match of text.matchAll(/\b(6[0-9]{4}|7[0-9]{4})\b/g)) {
if (isCommentMatch(text, match)) continue;
const code = Number(match[1]);
if (code >= 60000 && code <= 68999) findings.push(add('EXIT-RESERVED-BUILTIN', text, match, { detected: String(code) }));
if (code >= 69000 && code <= 69999) findings.push(add('EXIT-CUSTOM-RANGE', text, match, { detected: String(code) }));
}
return findings;
}
function checkModuleVersionDrift(text) {
const findings = [];
for (const match of text.matchAll(/ModuleVersion\s*=\s*['"]([0-9.]+)['"]/gi)) {
if (isCommentMatch(text, match)) continue;
if (compareVersion(match[1], '4.2.0') < 0) findings.push(add('REPO-MODULE-VERSION-DRIFT', text, match, { detected: match[1] }));
}
return findings;
}
function checkDeployModeAuto(text) {
return [...text.matchAll(/-DeployMode\s+(Interactive|Silent|NonInteractive)\b/gi)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('REPO-DEPLOYMODE-NO-AUTO', text, match, { detected: match[1] }));
}
function checkDeprecatedObjectProperty(text) {
return [...text.matchAll(/\bGet-ADTObjectProperty\b/gi)]
.filter((match) => !isCommentMatch(text, match))
.map((match) => add('DEPRECATED-GET-ADTOBJECTPROPERTY', text, match));
}
function checkGlobalModuleImport(text) {
return [...text.matchAll(/Import-Module\s+['"]?PSAppDeployToolkit['"]?(?![\\/])/gi)].map((match) => add('FAQ-GLOBAL-MODULE-IMPORT', text, match));
}
function checkUserContextRequireAdmin(text, context) {
if (!/Start-ADTProcessAsUser|Start-ADTMsiProcessAsUser/i.test(text)) return [];
if (/RequireAdmin\s*=\s*\$false/i.test(text) || context.requireAdmin === false) return [];
const match = /Start-ADTMsiProcessAsUser|Start-ADTProcessAsUser/i.exec(text);
return match ? [add('USERCONTEXT-REQUIREADMIN', text, match)] : [];
}
function scoreFindings(findings) {
const penalties = { critical: 35, error: 22, warning: 10, info: 3 };
const penalty = findings.reduce((total, finding) => total + (penalties[finding.severity] || 0), 0);
return Math.max(0, 100 - penalty);
}
function countFindings(findings) {
return findings.reduce((counts, finding) => {
counts[finding.severity] = (counts[finding.severity] || 0) + 1;
counts.total += 1;
return counts;
}, { total: 0, critical: 0, error: 0, warning: 0, info: 0 });
}
function lineNumber(text, index) {
return text.slice(0, index).split(/\r?\n/).length;
}
function lineText(text, index) {
const lines = text.split(/\r?\n/);
return lines[lineNumber(text, index) - 1]?.trim().slice(0, 240) || '';
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isCommentMatch(text, match) {
return lineText(text, match.index ?? 0).trimStart().startsWith('#');
}
function compareVersion(left, right) {
const a = String(left).split('.').map((part) => Number(part) || 0);
const b = String(right).split('.').map((part) => Number(part) || 0);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if ((a[index] || 0) < (b[index] || 0)) return -1;
if ((a[index] || 0) > (b[index] || 0)) return 1;
}
return 0;
}

View File

@@ -0,0 +1,39 @@
import { config } from './config.js';
// Single source of truth for which app settings are surfaced in the Config
// screen, what their boot-time value is, and whether they are pinned by an
// environment variable.
//
// Pinned settings are authoritative from the deployment environment (Docker /
// reverse proxy) and are locked in the UI; `seed()` refreshes them on every
// boot. Unpinned settings are seeded once as editable defaults and then owned
// by the admin via the Config screen, so their edits persist across restarts.
function envProvided(...names) {
return names.some((name) => process.env[name] !== undefined && process.env[name] !== '');
}
export function settingDefinitions() {
return [
{ key: 'server_fqdn', value: config.serverFqdn, pinned: envProvided('SERVER_FQDN') },
{ key: 'trusted_origins', value: config.clientOrigins.join(','), pinned: envProvided('CLIENT_ORIGINS', 'CLIENT_ORIGIN') },
{ key: 'client_origin', value: config.clientOrigin, pinned: envProvided('CLIENT_ORIGIN') },
{ key: 'api_port', value: String(config.port), pinned: envProvided('PORT') },
// Filesystem and process-structural settings are always env/locked; changing
// them at runtime would point the live process at the wrong storage.
{ key: 'database_path', value: config.databasePath, pinned: true },
{ key: 'log_dir', value: config.logDir, pinned: true },
{ key: 'file_locker_dir', value: config.fileLockerDir, pinned: true },
{ key: 'asset_dir', value: config.fileLockerDir, pinned: true },
{ key: 'node_env', value: config.nodeEnv, pinned: true },
{ key: 'max_asset_bytes', value: String(config.maxAssetBytes), pinned: envProvided('MAX_ASSET_BYTES') },
{ key: 'json_limit', value: config.jsonLimit, pinned: envProvided('JSON_LIMIT') },
{ key: 'entra_enabled', value: String(config.entra.enabled), pinned: envProvided('ENTRA_ENABLED') },
{ key: 'entra_tenant_id', value: config.entra.tenantId, pinned: envProvided('ENTRA_TENANT_ID') },
{ key: 'entra_client_id', value: config.entra.clientId, pinned: envProvided('ENTRA_CLIENT_ID') },
{ key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') },
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') }
];
}

View File

@@ -0,0 +1,78 @@
import { adminUserId, makeUser, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { compareVersions, evaluateUpdateState, mapGraphAppToCatalog } = await load('../services/appVersionService.js');
const { createApplication, listApplications, loadApplication, updateApplication, deleteApplication, recordVersionCheck } = await load('../models/applicationModel.js');
const { runCatalogChecks } = await load('../services/catalogWorker.js');
const owner = adminUserId();
const other = makeUser({ email: 'app-other@test.local' });
// --- pure version logic ---------------------------------------------------
test('compareVersions orders numeric segments correctly', () => {
assert.equal(compareVersions('1.2.0', '1.10.0'), -1);
assert.equal(compareVersions('2.0', '1.9.9'), 1);
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
assert.equal(compareVersions('v3.1', '3.1.0'), 0);
});
test('evaluateUpdateState flags newer upstream versions', () => {
assert.equal(evaluateUpdateState({ currentVersion: '1.0.0', latestKnownVersion: '1.1.0' }).updateAvailable, true);
assert.equal(evaluateUpdateState({ currentVersion: '2.0.0', latestKnownVersion: '1.1.0' }).updateAvailable, false);
assert.equal(evaluateUpdateState({ currentVersion: '', latestKnownVersion: '1.0.0' }).updateAvailable, true);
assert.equal(evaluateUpdateState({ currentVersion: '1.0.0', latestKnownVersion: '' }).updateAvailable, false);
});
test('mapGraphAppToCatalog maps the key fields', () => {
const mapped = mapGraphAppToCatalog({ id: 'app-1', displayName: 'Contoso VPN', publisher: 'Contoso', displayVersion: '3.1.0' });
assert.equal(mapped.name, 'Contoso VPN');
assert.equal(mapped.vendor, 'Contoso');
assert.equal(mapped.currentVersion, '3.1.0');
assert.equal(mapped.currentGraphAppId, 'app-1');
});
// --- catalog model --------------------------------------------------------
test('applications are visibility-scoped', () => {
const app = createApplication({ name: 'Private App', currentVersion: '1.0.0', visibility: 'personal' }, owner);
assert.ok(listApplications(owner).some((a) => a.id === app.id));
assert.ok(!listApplications(other).some((a) => a.id === app.id));
assert.equal(loadApplication(app.id, other), null);
});
test('application read surfaces a computed updateState', () => {
const app = createApplication({ name: 'Edge', currentVersion: '1.0.0', latestKnownVersion: '1.2.0', visibility: 'shared' }, owner);
const loaded = loadApplication(app.id, owner);
assert.equal(loaded.updateState.updateAvailable, true);
assert.equal(loaded.updateState.latest, '1.2.0');
});
test('recordVersionCheck updates the latest known version', () => {
const app = createApplication({ name: 'Firefox', currentVersion: '120.0', visibility: 'shared' }, owner);
const updated = recordVersionCheck(app.id, owner, { latestKnownVersion: '121.0', message: '' });
assert.equal(updated.latestKnownVersion, '121.0');
assert.equal(updated.updateState.updateAvailable, true);
assert.ok(updated.lastCheckedAt);
});
test('auto-check worker only processes opted-in apps and records new versions', async () => {
const optedIn = createApplication({ name: 'Worker Opt-In', currentVersion: '1.0.0', latestKnownVersion: '1.0.0', versionSource: 'url', versionSourceRef: 'https://example/v', autoCheck: true, visibility: 'shared' }, owner);
const optedOut = createApplication({ name: 'Worker Opt-Out', currentVersion: '1.0.0', latestKnownVersion: '1.0.0', autoCheck: false, visibility: 'shared' }, owner);
// Injected fetcher avoids network; returns a newer version.
const summary = await runCatalogChecks({ fetcher: async () => ({ version: '2.0.0', message: '' }) });
assert.ok(summary.checked >= 1);
assert.ok(summary.updated >= 1);
assert.equal(loadApplication(optedIn.id, owner).latestKnownVersion, '2.0.0');
assert.equal(loadApplication(optedOut.id, owner).latestKnownVersion, '1.0.0');
});
test('a non-owner cannot update or delete a personal application', () => {
const app = createApplication({ name: 'Locked App', visibility: 'personal' }, owner);
assert.equal(updateApplication(app.id, { name: 'Hijacked' }, other), null);
deleteApplication(app.id, other);
assert.ok(loadApplication(app.id, owner), 'should survive foreign delete');
});

View File

@@ -0,0 +1,54 @@
import { db, adminUserId, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { evaluatePromotion, runAutoPromotions } = await load('../services/promotionWorker.js');
const { createIntuneDeployment, loadDeploymentRaw } = await load('../models/psadtModel.js');
const owner = adminUserId();
const hoursAgo = (h) => new Date(Date.now() - h * 3_600_000).toISOString();
// --- pure eligibility -----------------------------------------------------
test('evaluatePromotion is due once the soak window elapses', () => {
const d = { autoPromoteAfterHours: 1, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(2), promotedAt: '' };
assert.equal(evaluatePromotion(d).due, true);
});
test('evaluatePromotion is not due while soaking, disabled, or already promoted', () => {
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 4, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(1), promotedAt: '' }).due, false);
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 0, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(5) }).due, false);
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 1, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(5), promotedAt: hoursAgo(1) }).due, false);
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 1, autoPromoteRing: '', soakStartedAt: hoursAgo(5) }).due, false);
});
// --- worker run -----------------------------------------------------------
test('runAutoPromotions promotes an eligible deployment and skips a soaking one', async () => {
const eligible = createIntuneDeployment({
name: 'Promote Me', visibility: 'shared', autoPromoteAfterHours: 1, autoPromoteRing: 'Broad', autoPromoteIntent: 'required',
assignments: [{ ring: 'Broad', intent: 'available', groupId: 'g1', groupMode: 'group' }]
}, owner);
const soaking = createIntuneDeployment({
name: 'Still Soaking', visibility: 'shared', autoPromoteAfterHours: 8, autoPromoteRing: 'Broad', autoPromoteIntent: 'required',
assignments: [{ ring: 'Broad', intent: 'available', groupId: 'g2', groupMode: 'group' }]
}, owner);
// Start the soak clock: eligible elapsed, soaking just started.
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ?').run(hoursAgo(3), eligible.id);
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ?').run(hoursAgo(1), soaking.id);
let pushed = 0;
const summary = await runAutoPromotions({ pusher: async () => { pushed += 1; } });
assert.ok(summary.promoted >= 1);
const promoted = loadDeploymentRaw(eligible.id);
assert.equal(promoted.assignments.find((a) => a.ring === 'Broad').intent, 'required');
assert.ok(promoted.promotedAt);
const stillSoaking = loadDeploymentRaw(soaking.id);
assert.equal(stillSoaking.assignments.find((a) => a.ring === 'Broad').intent, 'available');
assert.equal(stillSoaking.promotedAt, '');
// Neither deployment has a Graph connection, so nothing was pushed.
assert.equal(pushed, 0);
});

View File

@@ -0,0 +1,38 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { buildPackagerInvocation, expectedOutputFile, resolveToolPath, builderAvailability } = await load('../services/intuneWinBuilder.js');
test('bundled invocation uses IntuneWinAppUtil flags', () => {
const inv = buildPackagerInvocation({ toolPath: 'C:/tools/IntuneWinAppUtil.exe', sourceFolder: 'C:/src', setupFile: 'setup.exe', outputDir: 'C:/out' });
assert.equal(inv.shell, false);
assert.equal(inv.command, 'C:/tools/IntuneWinAppUtil.exe');
assert.deepEqual(inv.args, ['-c', 'C:/src', '-s', 'setup.exe', '-o', 'C:/out', '-q']);
});
test('external command template substitutes placeholders', () => {
const inv = buildPackagerInvocation({
commandTemplate: 'pack --src {source} --setup {setup} --out {output}',
sourceFolder: '/s', setupFile: 'a.msi', outputDir: '/o'
});
assert.equal(inv.shell, true);
assert.equal(inv.command, 'pack --src /s --setup a.msi --out /o');
});
test('expectedOutputFile names after the setup base name', () => {
assert.ok(expectedOutputFile('/out', 'ContosoVpnSetup.exe').endsWith('ContosoVpnSetup.intunewin'));
assert.ok(expectedOutputFile('/out', 'app.msi').endsWith('app.intunewin'));
});
test('resolveToolPath defaults into the tools directory', () => {
assert.ok(resolveToolPath().toLowerCase().includes('intunewinapputil.exe'));
});
test('builderAvailability reports unavailable when the tool is missing on this host', () => {
// The test host has no tool placed and is not Windows, so build is unavailable.
const availability = builderAvailability();
assert.equal(availability.available, false);
assert.ok(availability.reason);
});

View File

@@ -0,0 +1,62 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { computeDrift } = await load('../services/intuneDriftService.js');
const { canPublish, canApprove } = await load('../services/graphRbac.js');
// --- drift ----------------------------------------------------------------
test('computeDrift reports in-sync when plan matches tenant', () => {
const app = { displayName: 'Edge', publisher: 'Microsoft', installCommandLine: 'a', uninstallCommandLine: 'b', applicableArchitectures: 'x64' };
const assignment = { intent: 'required', target: { '@odata.type': '#microsoft.graph.groupAssignmentTarget', groupId: 'g1' } };
const rel = { '@odata.type': '#microsoft.graph.mobileAppSupersedence', targetId: 'old' };
const drift = computeDrift({
expectedApp: app, liveApp: { ...app },
expectedAssignments: [assignment], liveAssignments: [assignment],
expectedRelationships: [rel], liveRelationships: [rel]
});
assert.equal(drift.inSync, true);
});
test('computeDrift detects field, assignment, and relationship differences', () => {
const drift = computeDrift({
expectedApp: { displayName: 'Edge', installCommandLine: 'new' },
liveApp: { displayName: 'Edge', installCommandLine: 'old' },
expectedAssignments: [{ intent: 'required', target: { '@odata.type': '#microsoft.graph.groupAssignmentTarget', groupId: 'g1' } }],
liveAssignments: [{ intent: 'available', target: { '@odata.type': '#microsoft.graph.groupAssignmentTarget', groupId: 'g1' } }],
expectedRelationships: [{ '@odata.type': '#microsoft.graph.mobileAppSupersedence', targetId: 'old' }],
liveRelationships: []
});
assert.equal(drift.inSync, false);
assert.ok(drift.appDrift.some((d) => d.field === 'installCommandLine'));
assert.equal(drift.assignmentDrift.missing.length, 1); // required|...|g1 missing in tenant
assert.equal(drift.assignmentDrift.extra.length, 1); // available|...|g1 extra in tenant
assert.equal(drift.relationshipDrift.missing.length, 1);
});
// --- per-connection RBAC --------------------------------------------------
const admin = { id: 'u-admin', role: 'admin' };
const alice = { id: 'u-alice', role: 'user' };
const bob = { id: 'u-bob', role: 'user' };
test('canPublish: admins always; empty list allows all; otherwise listed only', () => {
assert.equal(canPublish({ publisherIds: [] }, admin), true);
assert.equal(canPublish({ publisherIds: [] }, alice), true);
assert.equal(canPublish({ publisherIds: ['u-alice'] }, alice), true);
assert.equal(canPublish({ publisherIds: ['u-alice'] }, bob), false);
});
test('canApprove: admins always; otherwise only named approvers (empty = none)', () => {
assert.equal(canApprove({ approverIds: [] }, admin), true);
assert.equal(canApprove({ approverIds: [] }, alice), false);
assert.equal(canApprove({ approverIds: ['u-alice'] }, alice), true);
assert.equal(canApprove({ approverIds: ['u-alice'] }, bob), false);
});
test('RBAC helpers tolerate JSON-string list columns', () => {
assert.equal(canPublish({ publisher_ids: '["u-alice"]' }, alice), true);
assert.equal(canApprove({ approver_ids: '["u-bob"]' }, bob), true);
});

View File

@@ -0,0 +1,19 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { isEntraLoginConfigured, signState, verifyState } = await load('../services/entraService.js');
test('Entra login is disabled when not configured', () => {
// The test environment leaves ENTRA_* unset, so login must not be offered.
assert.equal(isEntraLoginConfigured(), false);
});
test('signed state round-trips and rejects tampering', () => {
const state = signState({ nonce: 42 });
const decoded = verifyState(state);
assert.equal(decoded.purpose, 'entra-state');
assert.throws(() => verifyState(state + 'tampered'));
assert.throws(() => verifyState('not-a-token'));
});

View File

@@ -0,0 +1,71 @@
import { db, adminUserId, makeUser, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const {
createChangeRequest, findApprovedRequest, findPendingRequest, decideChangeRequest, markRequestExecuted, listChangeRequests
} = await load('../models/changeRequestModel.js');
const { intuneReportingOverview, recordGraphAudit } = await load('../models/graphModel.js');
const { createIntuneDeployment } = await load('../models/psadtModel.js');
const owner = adminUserId();
const other = makeUser({ email: 'gov-other@test.local' });
const deployment = createIntuneDeployment({ name: 'Gov Test App', visibility: 'shared', status: 'ready' }, owner);
test('approval lifecycle: pending -> approved -> executed', () => {
const request = createChangeRequest({ deploymentId: deployment.id, connectionId: null, action: 'publish', requestedBy: owner, requesterEmail: 'a@b.c' });
assert.equal(request.status, 'pending');
assert.equal(findPendingRequest(deployment.id, 'publish').id, request.id);
assert.equal(findApprovedRequest(deployment.id, 'publish'), null);
const approved = decideChangeRequest(request.id, { status: 'approved', approverUserId: owner, approverEmail: 'admin@b.c' });
assert.equal(approved.status, 'approved');
assert.equal(findApprovedRequest(deployment.id, 'publish').id, request.id);
markRequestExecuted(request.id);
assert.equal(findApprovedRequest(deployment.id, 'publish'), null, 'executed request is no longer approved');
});
test('deciding an already-decided request returns null', () => {
const request = createChangeRequest({ deploymentId: deployment.id, action: 'assign', requestedBy: owner });
decideChangeRequest(request.id, { status: 'rejected', approverUserId: owner });
assert.equal(decideChangeRequest(request.id, { status: 'approved', approverUserId: owner }), null);
});
test('change requests are scoped to visible deployments for non-admins', () => {
createChangeRequest({ deploymentId: deployment.id, action: 'relationships', requestedBy: owner });
// deployment is shared, so a non-admin still sees its requests
assert.ok(listChangeRequests(other, { isAdmin: false }).some((r) => r.deploymentId === deployment.id));
});
test('reporting overview aggregates deployment status and install states', () => {
const ts = new Date().toISOString();
const insert = db.prepare(`
INSERT INTO intune_deployment_statuses (id, deployment_id, graph_app_id, scope, install_state, error_code, created_at, updated_at)
VALUES (?, ?, 'app-1', 'device', ?, ?, ?, ?)
`);
insert.run('ids_1', deployment.id, 'failed', '0x87D1041C', ts, ts);
insert.run('ids_2', deployment.id, 'installed', '', ts, ts);
insert.run('ids_3', deployment.id, 'failed', '0x87D1041C', ts, ts);
const overview = intuneReportingOverview(owner, false);
const failed = overview.installStateCounts.find((row) => row.state === 'failed');
assert.equal(failed.count, 2);
assert.ok(overview.deploymentsByStatus.some((row) => row.status === 'ready'));
assert.equal(overview.topErrors[0].code, '0x87D1041C');
assert.equal(overview.topErrors[0].count, 2);
});
test('reporting overview includes soak timers and a write-action trend', () => {
const soaking = createIntuneDeployment({ name: 'Soak Report', visibility: 'shared', autoPromoteAfterHours: 24, autoPromoteRing: 'Broad' }, owner);
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ?')
.run(new Date(Date.now() - 3_600_000).toISOString(), soaking.id);
recordGraphAudit({ deploymentId: soaking.id, actorUserId: owner, action: 'win32app.publish', status: 'success', message: 'ok' });
const overview = intuneReportingOverview(owner, false);
const timer = overview.soakTimers.find((row) => row.id === soaking.id);
assert.ok(timer, 'soak timer present');
assert.ok(timer.hoursRemaining > 22 && timer.hoursRemaining <= 24, `~23h remaining, got ${timer.hoursRemaining}`);
assert.ok(overview.auditTrend.some((row) => row.status === 'success' && row.count >= 1));
});

View File

@@ -0,0 +1,44 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { isAllowedGraphHost, isAllowedAuthorityHost } = await load('../data/graphHosts.js');
const { graphConnectionSchema } = await load('../forms/schemas.js');
test('allow-list accepts Microsoft cloud hosts', () => {
assert.ok(isAllowedGraphHost('https://graph.microsoft.com'));
assert.ok(isAllowedGraphHost('https://graph.microsoft.us'));
assert.ok(isAllowedAuthorityHost('https://login.microsoftonline.com'));
});
test('allow-list rejects non-Microsoft and internal hosts', () => {
assert.ok(!isAllowedGraphHost('https://graph.microsoft.com.evil.com'));
assert.ok(!isAllowedGraphHost('http://169.254.169.254'));
assert.ok(!isAllowedAuthorityHost('https://internal.corp.local'));
assert.ok(!isAllowedGraphHost('https://graph.microsoft.com:8080@evil.com'));
});
test('graph connection schema rejects SSRF endpoints', () => {
const result = graphConnectionSchema.safeParse({
name: 'Bad',
tenantId: 'contoso',
clientId: 'abc',
clientSecret: 'shh',
graphBaseUrl: 'http://169.254.169.254',
authorityHost: 'https://login.microsoftonline.com'
});
assert.equal(result.success, false);
});
test('graph connection schema accepts a valid Microsoft endpoint', () => {
const result = graphConnectionSchema.safeParse({
name: 'Good',
tenantId: 'contoso',
clientId: 'abc',
clientSecret: 'shh',
graphBaseUrl: 'https://graph.microsoft.com',
authorityHost: 'https://login.microsoftonline.com'
});
assert.equal(result.success, true);
});

View File

@@ -0,0 +1,33 @@
import { adminUserId, makeUser, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
const owner = adminUserId();
const other = makeUser({ email: 'other-host@test.local' });
test('personal hosts are visible only to their owner', () => {
const host = createHost({ name: 'Private', address: 'priv.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
assert.ok(listHosts(owner).some((h) => h.id === host.id));
assert.ok(!listHosts(other).some((h) => h.id === host.id));
});
test('shared hosts are visible to everyone', () => {
const host = createHost({ name: 'Shared', address: 'shared.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
assert.ok(listHosts(other).some((h) => h.id === host.id));
});
test('a non-owner cannot update or delete a personal host', () => {
const host = createHost({ name: 'Locked', address: 'locked.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
assert.equal(updateHost(host.id, { name: 'Hacked' }, other), null);
deleteHost(host.id, other);
assert.ok(listHosts(owner).some((h) => h.id === host.id), 'host should still exist after foreign delete attempt');
});
test('filterVisibleHostIds drops hosts the user cannot see', () => {
const priv = createHost({ name: 'OwnerOnly', address: 'owneronly.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
const shared = createHost({ name: 'Everyone', address: 'everyone.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
const visibleToOther = filterVisibleHostIds([priv.id, shared.id], other);
assert.deepEqual(visibleToOther, [shared.id]);
});

162
server/test/intune.test.mjs Normal file
View File

@@ -0,0 +1,162 @@
import { adminUserId, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const {
buildWin32LobAppPayload,
buildAssignmentsPayload,
buildRelationshipsPayload,
validateRelationships,
MAX_SUPERSEDENCE_TARGETS
} = await load('../services/intunePublishService.js');
const { createGraphConnection, recordGraphAudit, listGraphAudit } = await load('../models/graphModel.js');
const owner = adminUserId();
function deploymentFixture(overrides = {}) {
return {
id: 'intune_fixture',
name: 'Contoso VPN',
notes: 'Pilot ring',
intunewinFile: 'ContosoVpn.intunewin',
installBehavior: 'system',
restartBehavior: 'return-code',
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
detectionType: 'msi-product-code',
detectionRule: '{12345678-1234-1234-1234-123456789012}',
returnCodes: [{ code: 3010, type: 'softReboot' }, { code: 1602, type: 'retry' }],
requirements: { architecture: 'both', minOs: 'Windows 10 22H2', diskSpaceMb: 512, runAs32Bit: false },
...overrides
};
}
const profileFixture = { appVendor: 'Contoso', appName: 'VPN Client', appVersion: '3.1.0' };
test('builds a win32LobApp payload with correct core shape', () => {
const { payload } = buildWin32LobAppPayload(deploymentFixture(), profileFixture);
assert.equal(payload['@odata.type'], '#microsoft.graph.win32LobApp');
assert.equal(payload.displayName, 'Contoso VPN Client 3.1.0');
assert.equal(payload.publisher, 'Contoso');
assert.equal(payload.installExperience.runAsAccount, 'system');
assert.equal(payload.installExperience.deviceRestartBehavior, 'basedOnReturnCode');
assert.equal(payload.applicableArchitectures, 'x64,x86');
assert.equal(payload.minimumSupportedOperatingSystem.v10_22H2, true);
});
test('return codes always include 0=success and clamp unknown types', () => {
const { payload } = buildWin32LobAppPayload(deploymentFixture({ returnCodes: [{ code: 7, type: 'bogus' }] }));
assert.ok(payload.returnCodes.some((rc) => rc.returnCode === 0 && rc.type === 'success'));
assert.equal(payload.returnCodes.find((rc) => rc.returnCode === 7).type, 'failed');
});
test('MSI detection maps to product code rule', () => {
const { payload } = buildWin32LobAppPayload(deploymentFixture());
const rule = payload.detectionRules[0];
assert.equal(rule['@odata.type'], '#microsoft.graph.win32LobAppProductCodeDetection');
assert.equal(rule.productCode, '{12345678-1234-1234-1234-123456789012}');
});
test('custom-script detection base64-encodes the script body', () => {
const { payload } = buildWin32LobAppPayload(deploymentFixture({ detectionType: 'custom-script', detectionRule: 'Test-Path C:\\app.exe' }));
const rule = payload.detectionRules[0];
assert.equal(rule['@odata.type'], '#microsoft.graph.win32LobAppPowerShellScriptDetection');
assert.equal(Buffer.from(rule.scriptContent, 'base64').toString('utf8'), 'Test-Path C:\\app.exe');
});
test('missing .intunewin produces a content warning', () => {
const { warnings } = buildWin32LobAppPayload(deploymentFixture({ intunewinFile: '' }));
assert.ok(warnings.some((w) => w.toLowerCase().includes('content must be uploaded')));
});
// --- Phase 3: assignments -------------------------------------------------
test('group assignment maps to groupAssignmentTarget with intent', () => {
const { assignments } = buildAssignmentsPayload({
assignments: [{ ring: 'Broad', intent: 'required', groupMode: 'group', groupId: 'g-1' }]
});
assert.equal(assignments.length, 1);
assert.equal(assignments[0].intent, 'required');
assert.equal(assignments[0].target['@odata.type'], '#microsoft.graph.groupAssignmentTarget');
assert.equal(assignments[0].target.groupId, 'g-1');
});
test('exclude intent maps to exclusionGroupAssignmentTarget', () => {
const { assignments } = buildAssignmentsPayload({
assignments: [{ ring: 'Exclusions', intent: 'exclude', groupMode: 'group', groupId: 'g-x' }]
});
assert.equal(assignments[0].target['@odata.type'], '#microsoft.graph.exclusionGroupAssignmentTarget');
assert.ok(['required', 'available', 'uninstall'].includes(assignments[0].intent));
});
test('all-devices target and assignment filters are honored', () => {
const { assignments } = buildAssignmentsPayload({
assignments: [{ ring: 'AllDev', intent: 'available', groupMode: 'allDevices', filterId: 'f-1', filterType: 'exclude', autoUpdate: true }]
});
assert.equal(assignments[0].target['@odata.type'], '#microsoft.graph.allDevicesAssignmentTarget');
assert.equal(assignments[0].target.deviceAndAppManagementAssignmentFilterId, 'f-1');
assert.equal(assignments[0].target.deviceAndAppManagementAssignmentFilterType, 'exclude');
assert.equal(assignments[0].settings.autoUpdateSettings.autoUpdateSupersededApps, 'enabled');
});
test('group assignment without an object id is skipped with a warning', () => {
const { assignments, warnings } = buildAssignmentsPayload({
assignments: [{ ring: 'Broad', intent: 'required', groupMode: 'group', groupId: '' }]
});
assert.equal(assignments.length, 0);
assert.ok(warnings.some((w) => w.includes('Broad')));
});
// --- Phase 4: relationships ----------------------------------------------
test('supersedence and dependency relationships map to correct Graph types', () => {
const { relationships } = buildRelationshipsPayload([
{ kind: 'supersedence', targetAppId: 'old-app', mode: 'replace' },
{ kind: 'dependency', targetAppId: 'dep-app', mode: 'autoInstall' }
], 'self-app');
assert.equal(relationships[0]['@odata.type'], '#microsoft.graph.mobileAppSupersedence');
assert.equal(relationships[0].supersedenceType, 'replace');
assert.equal(relationships[1]['@odata.type'], '#microsoft.graph.mobileAppDependency');
assert.equal(relationships[1].dependencyType, 'autoInstall');
});
test('relationship validation rejects self-reference and duplicates', () => {
const errors = validateRelationships([
{ kind: 'supersedence', targetAppId: 'self' },
{ kind: 'supersedence', targetAppId: 'a' },
{ kind: 'supersedence', targetAppId: 'a' }
], 'self');
assert.ok(errors.some((e) => e.includes('itself')));
assert.ok(errors.some((e) => e.toLowerCase().includes('duplicate')));
});
test('relationship validation enforces the supersedence node ceiling', () => {
const many = Array.from({ length: MAX_SUPERSEDENCE_TARGETS + 1 }, (_, i) => ({ kind: 'supersedence', targetAppId: `app-${i}` }));
const errors = validateRelationships(many, 'self');
assert.ok(errors.some((e) => e.includes('Too many supersedence')));
assert.throws(() => buildRelationshipsPayload(many, 'self'));
});
test('graph connection persists the allow_write write-gate', () => {
const conn = createGraphConnection({
name: 'WriteConn', tenantId: 'contoso', clientId: 'abc', clientSecret: 'shh',
allowWrite: true, visibility: 'personal'
}, owner);
assert.equal(conn.allowWrite, true);
});
test('audit log records and reads back request/response payloads', () => {
// connection/deployment are nullable FKs; use null here and assert the
// payload round-trips. (Real publishes pass real ids.)
recordGraphAudit({
connectionId: null, deploymentId: null, actorUserId: owner, actorEmail: 'a@b.c',
action: 'win32app.create', targetGraphId: 'app123', status: 'success', message: 'ok',
request: { displayName: 'X' }, response: { id: 'app123' }
});
const rows = listGraphAudit({ limit: 5 });
const entry = rows.find((r) => r.targetGraphId === 'app123');
assert.ok(entry, 'audit entry should be retrievable');
assert.equal(entry.action, 'win32app.create');
assert.equal(entry.request.displayName, 'X');
assert.equal(entry.response.id, 'app123');
});

View File

@@ -0,0 +1,107 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { parseIntunewin } = await load('../services/intunewinParser.js');
// --- Minimal stored-method (no compression) ZIP builder for fixtures --------
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i];
for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
return (crc ^ 0xffffffff) >>> 0;
}
function makeStoredZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'utf8');
const crc = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 8); // method = stored
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
const localRecord = Buffer.concat([local, nameBuf, data]);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 10); // method = stored
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE(offset, 42);
centrals.push(Buffer.concat([central, nameBuf]));
locals.push(localRecord);
offset += localRecord.length;
}
const cd = Buffer.concat(centrals);
const localsBuf = Buffer.concat(locals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(cd.length, 12);
eocd.writeUInt32LE(localsBuf.length, 16);
return Buffer.concat([localsBuf, cd, eocd]);
}
const detectionXml = `<?xml version="1.0" encoding="utf-8"?>
<ApplicationInfo ToolVersion="1.8.6">
<Name>Contoso VPN</Name>
<UnencryptedContentSize>2048</UnencryptedContentSize>
<FileName>IntunePackage.intunewin</FileName>
<SetupFile>ContosoVpnSetup.exe</SetupFile>
<EncryptionInfo>
<EncryptionKey>QUJDRA==</EncryptionKey>
<MacKey>RUZHSA==</MacKey>
<InitializationVector>SUpLTA==</InitializationVector>
<Mac>TU5PUA==</Mac>
<ProfileIdentifier>ProfileVersion1</ProfileIdentifier>
<FileDigest>UVJTVA==</FileDigest>
<FileDigestAlgorithm>SHA256</FileDigestAlgorithm>
</EncryptionInfo>
</ApplicationInfo>`;
const encryptedPayload = Buffer.from('this-stands-in-for-the-encrypted-intunewin-bytes');
const fixture = makeStoredZip([
{ name: 'IntuneWinPackage/Metadata/Detection.xml', data: Buffer.from(detectionXml, 'utf8') },
{ name: 'IntuneWinPackage/Contents/IntunePackage.intunewin', data: encryptedPayload }
]);
test('parses setup file, sizes, and encrypted payload', () => {
const parsed = parseIntunewin(fixture);
assert.equal(parsed.setupFile, 'ContosoVpnSetup.exe');
assert.equal(parsed.unencryptedContentSize, 2048);
assert.equal(parsed.encryptedContentSize, encryptedPayload.length);
assert.ok(parsed.encryptedContent.equals(encryptedPayload));
});
test('maps EncryptionInfo to Graph fileEncryptionInfo', () => {
const { fileEncryptionInfo } = parseIntunewin(fixture);
assert.equal(fileEncryptionInfo.encryptionKey, 'QUJDRA==');
assert.equal(fileEncryptionInfo.macKey, 'RUZHSA==');
assert.equal(fileEncryptionInfo.initializationVector, 'SUpLTA==');
assert.equal(fileEncryptionInfo.mac, 'TU5PUA==');
assert.equal(fileEncryptionInfo.profileIdentifier, 'ProfileVersion1');
assert.equal(fileEncryptionInfo.fileDigest, 'UVJTVA==');
assert.equal(fileEncryptionInfo.fileDigestAlgorithm, 'SHA256');
});
test('rejects a non-.intunewin buffer', () => {
assert.throws(() => parseIntunewin(Buffer.from('not a zip at all')));
});

37
server/test/jobs.test.mjs Normal file
View File

@@ -0,0 +1,37 @@
import { db, makeUser, adminUserId, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { listJobs, getJob } = await load('../models/jobModel.js');
const owner = makeUser({ email: 'job-owner@test.local' });
const stranger = makeUser({ email: 'job-stranger@test.local' });
const admin = adminUserId();
// Build a personal RunPlan owned by `owner` and a job they triggered, directly
// in SQL so the test never invokes PowerShell.
const ts = new Date().toISOString();
const scriptId = 'scr_job_test';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Job Test', 'Write-Output 1', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
const runplanId = 'run_job_test';
db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id, parallel, created_at, updated_at)
VALUES (?, 'Job Test Plan', ?, 'personal', ?, 1, ?, ?)`).run(runplanId, scriptId, owner, ts, ts);
const jobId = 'job_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at)
VALUES (?, ?, ?, 'completed', ?, ?)`).run(jobId, runplanId, scriptId, owner, ts);
test('the triggering operator can list and read their job', () => {
assert.ok(listJobs(owner, false).some((j) => j.id === jobId));
assert.ok(getJob(jobId, owner, false));
});
test('an unrelated user cannot see a personal job', () => {
assert.ok(!listJobs(stranger, false).some((j) => j.id === jobId));
assert.equal(getJob(jobId, stranger, false), null);
});
test('admins can see any job', () => {
assert.ok(listJobs(admin, true).some((j) => j.id === jobId));
assert.ok(getJob(jobId, admin, true));
});

View File

@@ -0,0 +1,65 @@
import { makeUser, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { createScript, cloneScript, scriptUsage } = await load('../models/libraryModel.js');
const { createRunPlan } = await load('../models/runPlanModel.js');
const { createIntuneDeployment } = await load('../models/psadtModel.js');
const owner = makeUser({ email: 'library-owner@test.local' });
test('scriptUsage reports linked RunPlans and Intune deployments', () => {
const script = createScript({
name: 'Usage Target.ps1',
description: 'Used by operational plans',
content: 'Write-Output usage',
visibility: 'personal'
}, owner);
const runplan = createRunPlan({
name: 'Usage RunPlan',
description: '',
scriptId: script.id,
visibility: 'personal',
parallel: true,
hostIds: []
}, owner);
const deployment = createIntuneDeployment({
name: 'Usage Intune Plan',
scriptId: script.id,
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
visibility: 'personal'
}, owner);
const usage = scriptUsage(script.id, owner);
assert.equal(usage.total, 2);
assert.deepEqual(usage.runplans.map((item) => item.id), [runplan.id]);
assert.deepEqual(usage.intuneDeployments.map((item) => item.id), [deployment.id]);
});
test('cloneScript creates an independent unassigned script copy', () => {
const script = createScript({
name: 'Clone Source.ps1',
description: 'Original',
content: 'Write-Output clone',
visibility: 'personal'
}, owner);
createRunPlan({
name: 'Original Only',
description: '',
scriptId: script.id,
visibility: 'personal',
parallel: true,
hostIds: []
}, owner);
const clone = cloneScript(script.id, { name: 'Clone Target.ps1' }, owner);
assert.notEqual(clone.id, script.id);
assert.equal(clone.name, 'Clone Target.ps1');
assert.equal(clone.content, script.content);
assert.equal(scriptUsage(clone.id, owner).total, 0);
assert.equal(scriptUsage(script.id, owner).total, 1);
});

View File

@@ -0,0 +1,80 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { analyzeInstaller, listInstallerTechnologies } = await load('../services/installerIntelService.js');
const { buildDetectionRule } = await load('../services/detectionRuleService.js');
// --- installer detection + silent switches --------------------------------
test('MSI is detected with high confidence and product-code uninstall', () => {
const result = analyzeInstaller({ fileName: 'ContosoVpn.msi', productCode: '{12345678-1234-1234-1234-123456789012}' });
assert.equal(result.confidence, 'high');
assert.equal(result.primary.id, 'msi');
assert.ok(result.primary.install.includes('/qn'));
assert.ok(result.primary.uninstall.includes('{12345678-1234-1234-1234-123456789012}'));
});
test('MSIX is detected from its extension', () => {
const result = analyzeInstaller({ fileName: 'App_1.0.0.0_x64.msixbundle' });
assert.equal(result.primary.id, 'msix');
assert.ok(result.primary.install.includes('Add-AppxProvisionedPackage'));
});
test('EXE with a name hint matches the technology at medium confidence', () => {
const result = analyzeInstaller({ fileName: 'app-nsis-setup.exe' });
assert.equal(result.confidence, 'medium');
assert.equal(result.primary.id, 'nsis');
assert.equal(result.primary.install, '"app-nsis-setup.exe" /S');
});
test('ambiguous EXE falls back to generic and offers candidates', () => {
const result = analyzeInstaller({ fileName: 'installer.exe' });
assert.equal(result.confidence, 'low');
assert.equal(result.primary.id, 'exe');
const ids = result.candidates.map((c) => c.id);
assert.ok(ids.includes('inno') && ids.includes('installshield'));
assert.equal(ids[ids.length - 1], 'exe', 'generic EXE is offered last');
});
test('unknown extension reports unknown', () => {
const result = analyzeInstaller({ fileName: 'notes.txt' });
assert.equal(result.confidence, 'unknown');
assert.equal(result.primary, null);
});
test('listInstallerTechnologies exposes the catalog', () => {
const list = listInstallerTechnologies();
assert.ok(list.find((t) => t.id === 'inno'));
assert.ok(list.every((t) => Array.isArray(t.extensions)));
});
// --- detection rule generation --------------------------------------------
test('MSI detection maps to a product-code rule', () => {
const rule = buildDetectionRule({ type: 'msi', productCode: '{abc}' });
assert.equal(rule.detectionType, 'msi-product-code');
assert.equal(rule.detectionRule, '{abc}');
});
test('file detection generates a versioned PowerShell script', () => {
const rule = buildDetectionRule({ type: 'file', path: 'C:\\Program Files\\App\\app.exe', version: '2.0.0' });
assert.equal(rule.detectionType, 'custom-script');
assert.ok(rule.detectionRule.includes('Test-Path'));
assert.ok(rule.detectionRule.includes("[version]'2.0.0'"));
assert.ok(rule.detectionRule.includes("Write-Output 'Detected'"));
});
test('registry detection with a value comparison', () => {
const rule = buildDetectionRule({ type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Contoso', valueName: 'Version', valueData: '2.0' });
assert.equal(rule.detectionType, 'custom-script');
assert.ok(rule.detectionRule.includes('HKLM:\\SOFTWARE\\Contoso'));
assert.ok(rule.detectionRule.includes("-eq '2.0'"));
});
test('detection generator validates required inputs', () => {
assert.throws(() => buildDetectionRule({ type: 'msi' }));
assert.throws(() => buildDetectionRule({ type: 'file' }));
assert.throws(() => buildDetectionRule({ type: 'bogus' }));
});

View File

@@ -0,0 +1,53 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { bumpVersion, cloneForNextVersion, setRingIntent } = await load('../services/intunePromotionService.js');
test('bumpVersion increments the trailing number', () => {
assert.equal(bumpVersion('1.2.3'), '1.2.4');
assert.equal(bumpVersion('126'), '127');
assert.equal(bumpVersion('3.1.0-x64'), '3.1.0-x65'); // bumps the last numeric run
assert.equal(bumpVersion(''), '1.0.0');
assert.equal(bumpVersion('stable'), 'stable.1');
});
test('cloneForNextVersion produces an unpublished next-version plan', () => {
const source = {
name: 'Contoso VPN 3.1.0',
profileId: 'p1', applicationId: 'app1', commandStyle: 'v4', installBehavior: 'system',
restartBehavior: 'return-code', uiMode: 'native-v4', requirements: { architecture: 'x64' },
installCommand: 'i', uninstallCommand: 'u', detectionType: 'msi-product-code', detectionRule: '{x}',
returnCodes: [{ code: 0, type: 'success' }], assignments: [{ ring: 'Pilot', intent: 'available' }],
relationships: [], graphAppId: 'live-app', status: 'published', visibility: 'shared'
};
const clone = cloneForNextVersion(source, { version: '3.2.0' });
assert.equal(clone.name, 'Contoso VPN 3.2.0');
assert.equal(clone.graphAppId, '');
assert.equal(clone.status, 'draft');
assert.equal(clone.applicationId, 'app1');
assert.equal(clone.detectionRule, '{x}');
assert.deepEqual(clone.assignments, source.assignments);
});
test('cloneForNextVersion auto-bumps the version from the name when not given', () => {
const clone = cloneForNextVersion({ name: 'Firefox 120.0', visibility: 'shared' }, {});
assert.equal(clone.name, 'Firefox 120.1');
});
test('setRingIntent promotes only the named ring and reports change', () => {
const assignments = [
{ ring: 'Pilot', intent: 'available' },
{ ring: 'Broad', intent: 'available' }
];
const result = setRingIntent(assignments, 'Broad', 'required');
assert.equal(result.changed, true);
assert.equal(result.assignments.find((a) => a.ring === 'Broad').intent, 'required');
assert.equal(result.assignments.find((a) => a.ring === 'Pilot').intent, 'available');
});
test('setRingIntent is a no-op for unknown rings', () => {
const result = setRingIntent([{ ring: 'Pilot', intent: 'available' }], 'Nope', 'required');
assert.equal(result.changed, false);
});

View File

@@ -0,0 +1,38 @@
import { db, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { getSetting, getBooleanSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js');
test('unpinned settings seed as editable defaults', () => {
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a
// default-sourced, admin-editable setting.
const source = db.prepare('SELECT source FROM settings WHERE key = ?').get('server_fqdn')?.source;
assert.equal(source, 'default');
});
test('structural settings are env-pinned and locked', () => {
const source = db.prepare('SELECT source FROM settings WHERE key = ?').get('database_path')?.source;
assert.equal(source, 'env');
});
test('updateSettings persists editable keys but rejects env-pinned keys', () => {
updateSettings({ server_fqdn: 'https://posh.example.com', database_path: '/evil/path.sqlite' });
assert.equal(getSetting('server_fqdn'), 'https://posh.example.com');
// The env-pinned database_path must be unchanged.
assert.notEqual(getSetting('database_path'), '/evil/path.sqlite');
});
test('allow_script_execution is readable as an effective boolean', () => {
updateSettings({ allow_script_execution: 'false' });
assert.equal(getBooleanSetting('allow_script_execution', true), false);
updateSettings({ allow_script_execution: 'true' });
assert.equal(getBooleanSetting('allow_script_execution', false), true);
});
test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
updateSettings({ trusted_origins: 'https://ops.example.com' });
const origins = effectiveTrustedOrigins();
assert.ok(origins.includes('https://ops.example.com'));
assert.ok(origins.includes('http://localhost:3000'));
});

Some files were not shown because too many files have changed in this diff Show More