/**
* OrgaSettings Service
*
* Manages per-organisation settings stored in Vault:
* - Domain mappings (orgas/data/{orgId}/domains)
* - App registry (orgas/data/{orgId}/apps)
* - Mail/SMTP config (orgas/data/{orgId}/mail)
*
* Vault path convention (KV v2):
* orgas/data/{orgId}/domains → { "subdomain": "internal", "custom.tld": "external", … }
* orgas/data/{orgId}/apps → { [appId]: { domain, roles, title, description?, port? } }
* orgas/data/{orgId}/mail → { host, port, user, password, userName, from, secure }
*/
import { getSecretsFromVault, saveSecretsToVault } from '@commtool/vault-secrets';
import { errorLoggerRead } from '../../utils/requestLogger.js';
import { myMinioClient, PUBLIC_BUCKET, DATA_BUCKET, publicObjectUrl } from '../../utils/s3Client.js';
import sharp from 'sharp';
// ── Generic Vault section helpers ─────────────────────────────────────────────
/**
* Load a named section for one organisation from Vault.
* Returns `null` on missing key or error.
* @param {string} orgId
* @param {string} section e.g. 'domains', 'apps', 'mail'
* @returns {Promise<object|null>}
*/
async function getOrgSection(orgId, section) {
try {
return await getSecretsFromVault(`orgas/data/${orgId}/${section}`) ?? null;
} catch (e) {
errorLoggerRead(e);
return null;
}
}
/**
* Persist a named section for one organisation in Vault.
* @param {string} orgId
* @param {string} section
* @param {object} data
*/
async function saveOrgSection(orgId, section, data) {
await saveSecretsToVault( data, `orgas/data/${orgId}/${section}`);
}
// ── Domain validation ─────────────────────────────────────────────────────────
/** Subdomain prefixes blocked for organisation use */
const RESERVED_PREFIXES = new Set([
'admin', 'api', 'auth', 'vault', 'www', 'mail', 'smtp', 'ns', 'ns1', 'ns2',
'ftp', 'ssh', 'vpn', 'git', 'registry', 'cdn', 'app', 'dev', 'test',
'staging', 'prod', 'support', 'help', 'status', 'monitor', 'ops',
]);
/** internal domain: lowercase alphanum + hyphens, no leading/trailing hyphen */
const INTERNAL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
/** external domain: at least two labels, valid TLD */
const EXTERNAL_RE = /^([a-z0-9][a-z0-9-]{0,61}[a-z0-9]\.)+[a-z]{2,}$/;
function validateSingleDomain(domain, type) {
if (type === 'internal') {
if (!INTERNAL_RE.test(domain))
return 'Only lowercase letters, digits and hyphens allowed; may not start or end with a hyphen.';
if (RESERVED_PREFIXES.has(domain))
return `"${domain}" is a reserved system name.`;
} else if (type === 'external') {
if (!EXTERNAL_RE.test(domain))
return 'Not a valid domain name (e.g. myclub.com).';
} else {
return `Unknown domain type "${type}". Use "internal" or "external".`;
}
return null; // valid
}
/**
* Detect conflicts between a new domain and all existing domains across other orgs.
*
* Conflict rules:
* - Same type + same value → direct conflict
* - internal "foo" vs external "foo.com" → prefix conflict
*/
function findConflict(domain, type, allOrgDomains, currentOrgId) {
for (const [orgId, domains] of Object.entries(allOrgDomains)) {
if (orgId === currentOrgId) continue;
for (const [existingDomain, existingType] of Object.entries(domains)) {
if (type === existingType && domain === existingDomain)
return orgId;
// internal "foo" ↔ external "foo.*"
if (type === 'internal' && existingType === 'external') {
if (existingDomain.split('.')[0] === domain) return orgId;
}
if (type === 'external' && existingType === 'internal') {
if (domain.split('.')[0] === existingDomain) return orgId;
}
}
}
return null;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Load all domain maps for every organisation (for conflict checking).
* @returns {Promise<Record<string, Record<string, string>>>}
*/
export async function getAllOrgDomains() {
const orgsList = await getSecretsFromVault('orgas/data', { list: true }) ?? [];
/** @type {Record<string, Record<string, string>>} */
const result = {};
await Promise.all(
orgsList.map(async (raw) => {
const orgId = raw.replace(/\/$/, '');
try {
const domains = await getSecretsFromVault(`orgas/data/${orgId}/domains`);
if (domains && Object.keys(domains).length > 0)
result[orgId] = domains;
} catch {
// org has no domains configured – skip silently
}
})
);
return result;
}
/**
* Load domain settings for one organisation.
* @param {string} orgId
* @returns {Promise<Record<string, string>>}
*/
export async function getDomains(orgId) {
return /** @type {Record<string, string>} */ (await getOrgSection(orgId, 'domains') ?? {});
}
/**
* Validate a proposed domains object.
* @param {Record<string, string>} newDomains
* @param {string} currentOrgId
* @returns {Promise<{ domain: string; error: string }[]>} Array of validation errors (empty = OK)
*/
export async function validateDomains(newDomains, currentOrgId) {
const errors = [];
// Basic per-domain checks first (fast, no Vault round-trip needed yet)
for (const [domain, type] of Object.entries(newDomains)) {
const err = validateSingleDomain(domain, type);
if (err) errors.push({ domain, error: err });
}
if (errors.length > 0) return errors; // don't bother fetching remote data
// Conflict check against all other organisations
const allOrgDomains = await getAllOrgDomains();
for (const [domain, type] of Object.entries(newDomains)) {
const conflictOrg = findConflict(domain, type, allOrgDomains, currentOrgId);
if (conflictOrg)
errors.push({ domain, error: 'Domain is already claimed by another organisation.' });
}
return errors;
}
/**
* Persist domain settings for one organisation.
* @param {string} orgId
* @param {Record<string, string>} domains
*/
export async function saveDomains(orgId, domains) {
await saveOrgSection(orgId, 'domains', domains);
}
// ── App settings ──────────────────────────────────────────────────────────────
/**
* @typedef {Object} AppEntry
* @property {string} domain - The app's URL or hostname
* @property {string[]} roles - Keycloak roles that grant access
* @property {string} title - Human-readable app name
* @property {string} [description]
* @property {number} [port]
*/
/**
* Load the app registry for one organisation.
* @param {string} orgId
* @returns {Promise<Record<string, AppEntry>>}
*/
export async function getApps(orgId) {
return /** @type {Record<string, AppEntry>} */ (await getOrgSection(orgId, 'apps') ?? {});
}
/**
* Persist the app registry for one organisation.
* @param {string} orgId
* @param {Record<string, AppEntry>} apps
*/
export async function saveApps(orgId, apps) {
await saveOrgSection(orgId, 'apps', apps);
}
/** MIME type → file extension map for accepted icon formats */
const ICON_EXT = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
'image/gif': 'gif',
'image/webp': 'webp',
};
/** Standard PWA icon variants written to the data bucket under manifests path */
const PWA_ICON_SIZES = [
{ name: 'icon-192.png', size: 192 },
{ name: 'icon-512.png', size: 512 },
{ name: 'icon-192-maskable.png', size: 192 },
{ name: 'icon-512-maskable.png', size: 512 },
{ name: 'favicon.png', size: 32 },
];
/** Put a Buffer into MinIO */
function minioput(bucket, key, buffer, mimeType) {
return new Promise((resolve, reject) => {
myMinioClient.putObject(bucket, key, buffer, buffer.length, { 'Content-Type': mimeType },
(err) => { if (err) reject(err); else resolve(); }
);
});
}
/**
* Upload an app icon:
* 1. Stores the original in the public bucket (URL saved in Vault apps[appId].icon).
* 2. Uses sharp to generate all standard PWA sizes and writes them to the data
* bucket at `{orgId}/manifests/{appId}/{iconName}` — the exact paths the
* static server looks up, so no redirect is needed after this.
*
* @param {string} orgId
* @param {string} appId
* @param {import('stream').Readable} fileStream
* @param {string} mimeType
* @returns {Promise<string>} The public icon URL
*/
export async function uploadAppIcon(orgId, appId, fileStream, mimeType) {
const ext = ICON_EXT[mimeType];
if (!ext) throw new Error(`Unsupported image type: ${mimeType}`);
// Buffer the stream once — needed for both original upload and sharp resizing
const chunks = [];
for await (const chunk of fileStream) chunks.push(chunk);
const inputBuffer = Buffer.concat(chunks);
// ── 1. Store original in public bucket ────────────────────────────────────
const originalKey = `${orgId}/icons/${appId}-${Date.now()}.${ext}`;
await minioput(PUBLIC_BUCKET, originalKey, inputBuffer, mimeType);
const iconUrl = publicObjectUrl(originalKey);
// ── 2. Generate and store all PWA icon sizes in data bucket ───────────────
const baseImage = sharp(inputBuffer);
await Promise.all(
PWA_ICON_SIZES.map(async ({ name, size }) => {
const resized = await baseImage.clone().resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
const key = `${orgId}/manifests/${appId}/${name}`;
await minioput(DATA_BUCKET, key, resized, 'image/png');
})
);
// ── 3. Persist URL in Vault ───────────────────────────────────────────────
const apps = await getOrgSection(orgId, 'apps') ?? {};
if (!apps[appId]) throw new Error(`App "${appId}" not found for this organisation.`);
// Delete old original from public bucket (best-effort)
const oldIconUrl = apps[appId].icon;
if (oldIconUrl) {
try {
const oldKey = oldIconUrl.replace(
`${process.env.S3publicBaseUrl ?? `https://${process.env.S3endPoint}:${process.env.S3port}`}/${PUBLIC_BUCKET}/`, ''
);
await myMinioClient.removeObject(PUBLIC_BUCKET, oldKey);
} catch { /* non-fatal */ }
}
apps[appId] = { ...apps[appId], icon: iconUrl };
await saveOrgSection(orgId, 'apps', apps);
return iconUrl;
}
// ── Mail settings ─────────────────────────────────────────────────────────────
const MASKED = '••••••••';
/**
* Load mail/SMTP settings for one organisation.
* The password is masked before being returned.
* @param {string} orgId
* @returns {Promise<object|null>}
*/
export async function getMailSettings(orgId) {
const settings = await getOrgSection(orgId, 'mail');
if (!settings) return null;
return {
...settings,
password: settings.password ? MASKED : '',
};
}
/**
* Persist mail/SMTP settings for one organisation.
* If the password is the mask sentinel, the existing password is preserved.
* @param {string} orgId
* @param {object} settings
*/
export async function saveMailSettings(orgId, settings) {
let dataToSave = { ...settings };
if (dataToSave.password === MASKED) {
// Password not changed – keep existing value from Vault
const existing = await getOrgSection(orgId, 'mail') ?? {};
dataToSave.password = existing.password ?? '';
}
await saveOrgSection(orgId, 'mail', dataToSave);
}