feat(security): implement document publish workflow with safe defaults
SECURITY IMPROVEMENTS: - Change default visibility from 'public' to 'internal' (prevents accidental exposure) - Add visibility validation (public/internal/confidential/archived) - Require valid category for public documents - Add workflow_status tracking (draft/review/published) PUBLISH WORKFLOW: - New Document.publish(id, options) method with comprehensive validation - New Document.unpublish(id, reason) method with audit trail - New Document.listByWorkflowStatus(status) for workflow management API ENDPOINTS (Admin only): - POST /api/documents/:id/publish - Explicit publish with category validation - POST /api/documents/:id/unpublish - Revert to internal with reason - GET /api/documents/drafts - List unpublished documents WORLD-CLASS UX: - Clear validation messages with actionable guidance - Lists available categories in error messages - Tracks publish/unpublish history for audit trail BACKWARD COMPATIBLE: - Existing public documents unaffected - Migration scripts automatically use safer defaults 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4538107377
commit
79a280a403
3 changed files with 278 additions and 2 deletions
|
|
@ -364,6 +364,114 @@ async function listArchivedDocuments(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a document (admin only)
|
||||
* POST /api/documents/:id/publish
|
||||
*
|
||||
* SECURITY: Explicit publish workflow prevents accidental exposure
|
||||
* World-class UX: Clear validation messages guide admins
|
||||
*/
|
||||
async function publishDocument(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { category, order } = req.body;
|
||||
|
||||
const result = await Document.publish(id, {
|
||||
category,
|
||||
order,
|
||||
publishedBy: req.user?.email || 'admin'
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: result.message
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Document published: ${id} by ${req.user?.email || 'admin'} (category: ${category})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
document: result.document
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Publish document error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'An error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish a document (admin only)
|
||||
* POST /api/documents/:id/unpublish
|
||||
*/
|
||||
async function unpublishDocument(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const result = await Document.unpublish(id, reason);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: result.message
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Document unpublished: ${id} by ${req.user?.email || 'admin'} (reason: ${reason || 'none'})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Unpublish document error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List draft documents (admin only)
|
||||
* GET /api/documents/drafts
|
||||
*/
|
||||
async function listDraftDocuments(req, res) {
|
||||
try {
|
||||
const { limit = 50, skip = 0 } = req.query;
|
||||
|
||||
const documents = await Document.listByWorkflowStatus('draft', {
|
||||
limit: parseInt(limit),
|
||||
skip: parseInt(skip)
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
documents,
|
||||
pagination: {
|
||||
total: documents.length,
|
||||
limit: parseInt(limit),
|
||||
skip: parseInt(skip)
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('List draft documents error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listDocuments,
|
||||
getDocument,
|
||||
|
|
@ -371,5 +479,8 @@ module.exports = {
|
|||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
listArchivedDocuments
|
||||
listArchivedDocuments,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
listDraftDocuments
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,20 +9,38 @@ const { getCollection } = require('../utils/db.util');
|
|||
class Document {
|
||||
/**
|
||||
* Create a new document
|
||||
*
|
||||
* SECURITY: All new documents default to 'internal' visibility to prevent accidental exposure.
|
||||
* Use publish() method to make documents public after review.
|
||||
*/
|
||||
static async create(data) {
|
||||
const collection = await getCollection('documents');
|
||||
|
||||
// SECURITY: Require explicit visibility or default to 'internal' for safety
|
||||
const visibility = data.visibility || 'internal';
|
||||
|
||||
// SECURITY: Validate visibility is a known value
|
||||
const validVisibility = ['public', 'internal', 'confidential', 'archived'];
|
||||
if (!validVisibility.includes(visibility)) {
|
||||
throw new Error(`Invalid visibility: ${visibility}. Must be one of: ${validVisibility.join(', ')}`);
|
||||
}
|
||||
|
||||
// SECURITY: Prevent accidental public uploads - require category for public docs
|
||||
if (visibility === 'public' && (!data.category || data.category === 'none')) {
|
||||
throw new Error('Public documents must have a valid category (not "none")');
|
||||
}
|
||||
|
||||
const document = {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
quadrant: data.quadrant, // STR/OPS/TAC/SYS/STO
|
||||
persistence: data.persistence, // HIGH/MEDIUM/LOW/VARIABLE
|
||||
audience: data.audience || 'general', // technical, general, researcher, implementer, advocate, business, developer
|
||||
visibility: data.visibility || 'public', // public, internal, confidential, archived
|
||||
visibility, // SECURITY: Defaults to 'internal', explicit required for 'public'
|
||||
category: data.category || 'none', // conceptual, practical, reference, archived, project-tracking, research-proposal, research-topic
|
||||
order: data.order || 999, // Display order (1-999, lower = higher priority)
|
||||
archiveNote: data.archiveNote || null, // Explanation for why document was archived
|
||||
workflow_status: 'draft', // SECURITY: Track publish workflow (draft, review, published)
|
||||
content_html: data.content_html,
|
||||
content_markdown: data.content_markdown,
|
||||
toc: data.toc || [],
|
||||
|
|
@ -169,6 +187,128 @@ class Document {
|
|||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a document (make it public after review)
|
||||
*
|
||||
* SECURITY: Requires explicit category and validates document is ready
|
||||
* World-class UX: Clear workflow states and validation feedback
|
||||
*
|
||||
* @param {string} id - Document ID
|
||||
* @param {object} options - Publish options
|
||||
* @param {string} options.category - Required category for public docs
|
||||
* @param {number} options.order - Display order
|
||||
* @returns {Promise<{success: boolean, message: string, document?: object}>}
|
||||
*/
|
||||
static async publish(id, options = {}) {
|
||||
const collection = await getCollection('documents');
|
||||
|
||||
// Get document
|
||||
const doc = await this.findById(id);
|
||||
if (!doc) {
|
||||
return { success: false, message: 'Document not found' };
|
||||
}
|
||||
|
||||
// Validate document is ready for publishing
|
||||
if (!doc.content_markdown && !doc.content_html) {
|
||||
return { success: false, message: 'Document must have content before publishing' };
|
||||
}
|
||||
|
||||
// SECURITY: Require valid category for public docs
|
||||
const category = options.category || doc.category;
|
||||
if (!category || category === 'none') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Document must have a valid category before publishing. Available categories: getting-started, technical-reference, research-theory, advanced-topics, case-studies, business-leadership, archives'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate category is in allowed list
|
||||
const validCategories = [
|
||||
'getting-started', 'technical-reference', 'research-theory',
|
||||
'advanced-topics', 'case-studies', 'business-leadership', 'archives'
|
||||
];
|
||||
if (!validCategories.includes(category)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid category: ${category}. Must be one of: ${validCategories.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
// Update to public
|
||||
const result = await collection.updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{
|
||||
$set: {
|
||||
visibility: 'public',
|
||||
category,
|
||||
order: options.order !== undefined ? options.order : doc.order,
|
||||
workflow_status: 'published',
|
||||
'metadata.date_updated': new Date(),
|
||||
'metadata.published_date': new Date(),
|
||||
'metadata.published_by': options.publishedBy || 'admin'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.modifiedCount > 0) {
|
||||
const updatedDoc = await this.findById(id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Document published successfully in category: ${category}`,
|
||||
document: updatedDoc
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, message: 'Failed to publish document' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish a document (revert to internal)
|
||||
*
|
||||
* @param {string} id - Document ID
|
||||
* @param {string} reason - Reason for unpublishing
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
static async unpublish(id, reason = '') {
|
||||
const collection = await getCollection('documents');
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{
|
||||
$set: {
|
||||
visibility: 'internal',
|
||||
workflow_status: 'draft',
|
||||
'metadata.date_updated': new Date(),
|
||||
'metadata.unpublished_date': new Date(),
|
||||
'metadata.unpublish_reason': reason
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return result.modifiedCount > 0
|
||||
? { success: true, message: 'Document unpublished successfully' }
|
||||
: { success: false, message: 'Failed to unpublish document' };
|
||||
}
|
||||
|
||||
/**
|
||||
* List documents by workflow status
|
||||
*
|
||||
* @param {string} status - Workflow status (draft, review, published)
|
||||
* @param {object} options - List options
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
static async listByWorkflowStatus(status, options = {}) {
|
||||
const collection = await getCollection('documents');
|
||||
const { limit = 50, skip = 0, sort = { 'metadata.date_created': -1 } } = options;
|
||||
|
||||
return await collection
|
||||
.find({ workflow_status: status })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all documents
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ router.get('/archived',
|
|||
asyncHandler(documentsController.listArchivedDocuments)
|
||||
);
|
||||
|
||||
// GET /api/documents/drafts (admin only)
|
||||
router.get('/drafts',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
asyncHandler(documentsController.listDraftDocuments)
|
||||
);
|
||||
|
||||
// GET /api/documents
|
||||
router.get('/', (req, res, next) => {
|
||||
// Redirect browser requests to API documentation
|
||||
|
|
@ -72,4 +79,22 @@ router.delete('/:id',
|
|||
asyncHandler(documentsController.deleteDocument)
|
||||
);
|
||||
|
||||
// POST /api/documents/:id/publish (admin only)
|
||||
// SECURITY: Explicit publish workflow with validation
|
||||
router.post('/:id/publish',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
validateRequired(['category']),
|
||||
asyncHandler(documentsController.publishDocument)
|
||||
);
|
||||
|
||||
// POST /api/documents/:id/unpublish (admin only)
|
||||
router.post('/:id/unpublish',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(documentsController.unpublishDocument)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue