godcrm/backend/services/SpaceVisibilityService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

411 lines
12 KiB
JavaScript

// Space Visibility Service - v0.001.000
// Manages space visibility levels: internal, open, external (ADR-105)
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { dbRun, dbGet, dbAll } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
/**
* Valid visibility levels
* - internal: visible only to explicit members (default)
* - open: visible to all authenticated users in the workspace
* - external: accessible via a public_slug link, optionally password-protected
*/
const VISIBILITY_LEVELS = ['internal', 'open', 'external'];
/**
* bcrypt salt rounds (consistent with AuthService)
*/
const SALT_ROUNDS = 10;
/**
* Get current visibility of a space
* @param {number} spaceId - Space ID
* @returns {Promise<{visibility, public_slug, has_password, public_sidebar}|null>}
* Returns null if space not found
*/
export async function getVisibility(spaceId) {
const space = await dbGet(
'SELECT visibility, public_slug, public_password_hash, settings FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) {
return null;
}
return {
visibility: space.visibility || 'internal',
public_slug: space.public_slug || null,
has_password: !!space.public_password_hash,
public_sidebar: readPublicSidebarPrefs(space.settings)
};
}
/**
* Parse spaces.settings JSON and extract the public_sidebar preferences with
* defaults. Used by tree endpoint + getVisibility so the public viewer and the
* owner settings UI agree on the canonical shape.
*
* Defaults: open by default (default_open=true), menu visible (hidden=false).
*/
export function readPublicSidebarPrefs(settingsText) {
let parsed = null;
if (settingsText) {
if (typeof settingsText === 'string') {
try {
parsed = JSON.parse(settingsText);
} catch {
parsed = null;
}
} else if (typeof settingsText === 'object') {
parsed = settingsText;
}
}
const raw = parsed?.public_sidebar ?? {};
return {
default_open: raw.default_open !== false,
hidden: raw.hidden === true
};
}
/**
* Merge new public_sidebar preferences into spaces.settings JSON.
* Owner-edited from the External visibility panel.
*/
export async function setPublicSidebarPrefs(spaceId, prefs) {
const space = await dbGet(
'SELECT id, settings FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) {
throw new Error('Space not found');
}
let current = {};
if (space.settings) {
if (typeof space.settings === 'string') {
try {
current = JSON.parse(space.settings) || {};
} catch {
current = {};
}
} else if (typeof space.settings === 'object') {
current = { ...space.settings };
}
}
const next = {
...current,
public_sidebar: {
default_open: prefs.default_open !== false,
hidden: prefs.hidden === true
}
};
await dbRun(
'UPDATE spaces SET settings = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[JSON.stringify(next), spaceId]
);
return next.public_sidebar;
}
/**
* Update space visibility
* When setting to 'external': auto-generates public_slug if not present.
* When changing FROM 'external' to another level: optionally clears public_slug.
*
* @param {number} spaceId - Space ID
* @param {string} visibility - Target visibility level ('internal' | 'open' | 'external')
* @param {object} [options] - Additional options
* @param {boolean} [options.clearSlugOnDowngrade=false] - Clear public_slug when leaving 'external'
* @param {string} [options.customSlug] - Custom slug to use instead of auto-generated one
* @returns {Promise<{visibility: string, public_slug: string|null, has_password: boolean}>}
* @throws {Error} If space not found or visibility value invalid
*/
export async function setVisibility(spaceId, visibility, options = {}) {
const { clearSlugOnDowngrade = false, customSlug } = options;
// Validate visibility
if (!VISIBILITY_LEVELS.includes(visibility)) {
throw new Error(`Invalid visibility. Must be one of: ${VISIBILITY_LEVELS.join(', ')}`);
}
// Verify space exists and get current state
const space = await dbGet(
'SELECT id, name, visibility, public_slug FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) {
throw new Error('Space not found');
}
const previousVisibility = space.visibility || 'internal';
let newSlug = space.public_slug;
// When setting to 'external': auto-generate slug if missing
if (visibility === 'external' && !newSlug) {
newSlug = customSlug || await generatePublicSlug(space.name, spaceId);
}
// When custom slug is provided for an external space, use it
if (visibility === 'external' && customSlug) {
// Validate custom slug format
const sanitized = sanitizeSlug(customSlug);
if (!sanitized) {
throw new Error('Invalid custom slug: must contain at least one URL-safe character');
}
// Ensure uniqueness
const existing = await dbGet(
'SELECT id FROM spaces WHERE public_slug = ? AND id != ?',
[sanitized, spaceId]
);
if (existing) {
throw new Error('Custom slug is already in use');
}
newSlug = sanitized;
}
// When changing FROM 'external' to another level: optionally clear slug
if (previousVisibility === 'external' && visibility !== 'external' && clearSlugOnDowngrade) {
newSlug = null;
}
// Update the space
await dbRun(
'UPDATE spaces SET visibility = ?, public_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[visibility, newSlug, spaceId]
);
apiLogger.info(
{ spaceId, from: previousVisibility, to: visibility, slug: newSlug },
'Space visibility updated'
);
return getVisibility(spaceId);
}
/**
* Generate a unique, URL-friendly slug from a space name
*
* Sanitises the name to lowercase ASCII + hyphens, then appends a short random
* suffix to guarantee uniqueness across all spaces.
*
* @param {string} spaceName - Human-readable space name
* @param {number} spaceId - Space ID (used to exclude self when checking uniqueness)
* @returns {Promise<string>} Unique slug
*/
export async function generatePublicSlug(spaceName, spaceId) {
const base = sanitizeSlug(spaceName) || 'space';
// Append a short random suffix for uniqueness
const suffix = crypto.randomBytes(4).toString('hex'); // 8 hex chars
let candidate = `${base}-${suffix}`;
// Double-check uniqueness (collision is extremely unlikely but we guard anyway)
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const existing = await dbGet(
'SELECT id FROM spaces WHERE public_slug = ? AND id != ?',
[candidate, spaceId]
);
if (!existing) {
return candidate;
}
// Regenerate on collision
const retrySuffix = crypto.randomBytes(4).toString('hex');
candidate = `${base}-${retrySuffix}`;
}
// Fallback: use spaceId to guarantee uniqueness
return `${base}-${spaceId}-${crypto.randomBytes(3).toString('hex')}`;
}
/**
* Set password protection for an external space
* Hashes the password with bcrypt and stores in public_password_hash.
*
* @param {number} spaceId - Space ID
* @param {string} password - Plain-text password to set
* @returns {Promise<void>}
* @throws {Error} If space not found or password is empty
*/
export async function setPassword(spaceId, password) {
if (!password || typeof password !== 'string' || password.trim().length === 0) {
throw new Error('Password must be a non-empty string');
}
const space = await dbGet('SELECT id FROM spaces WHERE id = ?', [spaceId]);
if (!space) {
throw new Error('Space not found');
}
const hash = await bcrypt.hash(password, SALT_ROUNDS);
await dbRun(
'UPDATE spaces SET public_password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[hash, spaceId]
);
apiLogger.info({ spaceId }, 'Space password set');
}
/**
* Remove password protection from a space
*
* @param {number} spaceId - Space ID
* @returns {Promise<void>}
* @throws {Error} If space not found
*/
export async function removePassword(spaceId) {
const space = await dbGet('SELECT id FROM spaces WHERE id = ?', [spaceId]);
if (!space) {
throw new Error('Space not found');
}
await dbRun(
'UPDATE spaces SET public_password_hash = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[spaceId]
);
apiLogger.info({ spaceId }, 'Space password removed');
}
/**
* Verify a password against a space's stored hash
*
* @param {number} spaceId - Space ID
* @param {string} password - Plain-text password to verify
* @returns {Promise<boolean>} true if password matches, false otherwise
* @throws {Error} If space not found
*/
export async function verifyPassword(spaceId, password) {
const space = await dbGet(
'SELECT public_password_hash FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) {
throw new Error('Space not found');
}
// No password set means no protection - always pass
if (!space.public_password_hash) {
return true;
}
if (!password || typeof password !== 'string') {
return false;
}
return bcrypt.compare(password, space.public_password_hash);
}
/**
* Get a space by its public slug (external access)
* Only returns spaces with visibility = 'external'.
*
* @param {string} slug - Public slug to look up
* @returns {Promise<object|null>} Space object (without password hash) or null
*/
export async function getPublicSpaceBySlug(slug) {
if (!slug || typeof slug !== 'string') {
return null;
}
const space = await dbGet(
"SELECT * FROM spaces WHERE public_slug = ? AND visibility = 'external'",
[slug]
);
if (!space) {
return null;
}
// Strip the password hash from the response - callers should use verifyPassword()
const { public_password_hash, ...safeSpace } = space;
return {
...safeSpace,
has_password: !!public_password_hash
};
}
/**
* Check if a space has 'open' visibility
*
* @param {number} spaceId - Space ID
* @returns {Promise<boolean>} true if visibility is 'open'
*/
export async function isOpenSpace(spaceId) {
const space = await dbGet(
'SELECT visibility FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) {
return false;
}
return space.visibility === 'open';
}
/**
* List all spaces with 'open' visibility (for authenticated users)
* Returns basic space info without sensitive data.
*
* @returns {Promise<Array<object>>} Array of open spaces
*/
export async function getOpenSpaces() {
const spaces = await dbAll(`
SELECT
s.id,
s.name,
s.description,
s.icon,
s.type,
s.theme_primary,
s.theme_secondary,
s.theme_tertiary,
s.owner_id,
s.created_at,
s.updated_at,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.space_id = s.id), 0) as projects_count
FROM spaces s
WHERE s.visibility = 'open'
ORDER BY s.name ASC
`);
return spaces;
}
// ============================================================
// Internal helpers
// ============================================================
/**
* Sanitize a string into a URL-friendly slug
* - Converts to lowercase
* - Replaces non-alphanumeric characters with hyphens
* - Collapses consecutive hyphens
* - Trims leading/trailing hyphens
* - Truncates to 60 characters (leaving room for suffix)
*
* @param {string} text - Raw text to sanitize
* @returns {string} Sanitized slug (may be empty if input has no usable characters)
*/
function sanitizeSlug(text) {
if (!text || typeof text !== 'string') {
return '';
}
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric -> hyphen
.replace(/-+/g, '-') // collapse consecutive hyphens
.replace(/^-|-$/g, '') // trim leading/trailing hyphens
.slice(0, 60); // cap length to leave room for suffix
}