Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions collaborative-private-comment-export-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Collaborative Private Comment Export Guard

This standalone SCIBASE issue #12 slice reviews collaborative editor export
packets before public PDF, HTML, or LaTeX release.

It checks:

- Private and reviewer-only comments do not leak into public exports.
- Public export profiles require unresolved comment threads to be closed.
- Reviewer mentions belong to the invited reviewer roster.
- Sensitive inline annotations have redaction evidence before release.
- Reviewer packets redact emails and phone numbers in thread text.

## Run

```bash
node collaborative-private-comment-export-guard/test.js
node collaborative-private-comment-export-guard/demo.js
python collaborative-private-comment-export-guard/make-demo-video.py
```

Generated artifacts are written under `reports/` for review.
127 changes: 127 additions & 0 deletions collaborative-private-comment-export-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const fs = require('fs');
const path = require('path');

const {
buildExportPacket,
reviewExportCommentPrivacy
} = require('./index');

const ROOT = __dirname;
const REPORTS = path.join(ROOT, 'reports');
fs.mkdirSync(REPORTS, { recursive: true });

const workspace = {
id: 'collab-editor-demo-12',
title: 'Quantum Materials Draft',
invitedReviewerIds: ['rv-alpha', 'rv-beta'],
exportProfiles: {
publicPdf: { allowedVisibilities: ['public'], requireResolvedThreads: true },
reviewerPackage: { allowedVisibilities: ['public', 'reviewer-only'], requireResolvedThreads: false }
},
documents: [
{
id: 'public-ready',
exportProfile: 'publicPdf',
threads: [
{
id: 'th-ready',
visibility: 'public',
resolved: true,
mentions: ['rv-alpha'],
text: 'Clarify method wording before public release.'
}
],
inlineAnnotations: [{ kind: 'public-note', text: 'Visible caption note.' }],
redactionEvidence: ['public-comment-scan']
},
{
id: 'public-hold',
exportProfile: 'publicPdf',
threads: [
{
id: 'th-private',
visibility: 'private',
resolved: false,
mentions: ['rv-gamma'],
text: 'Do not publish reviewer phone +1 555 010 3311 or delta@private.example'
}
],
inlineAnnotations: [{ kind: 'sensitive-inline', text: 'Embargo note.' }],
redactionEvidence: []
},
{
id: 'reviewer-ready',
exportProfile: 'reviewerPackage',
threads: [
{
id: 'th-reviewer',
visibility: 'reviewer-only',
resolved: false,
mentions: ['rv-alpha'],
text: 'Reviewer package can retain this note.'
}
],
inlineAnnotations: [],
redactionEvidence: ['reviewer-package-scan']
}
]
};

function markdownReport(packet) {
const lines = [
'# Collaborative Private Comment Export Guard',
'',
`Workspace: ${packet.title}`,
`Digest: ${packet.digest}`,
'',
'## Summary',
'',
`- Ready exports: ${packet.summary.ready}`,
`- Held exports: ${packet.summary.hold}`,
'',
'## Documents'
];

for (const document of packet.documents) {
lines.push('', `### ${document.documentId}`, '');
lines.push(`- Export profile: ${document.exportProfile}`);
lines.push(`- Decision: ${document.decision}`);
lines.push(`- Threads: ${document.metrics.threadCount}`);
lines.push(`- Private threads: ${document.metrics.privateThreadCount}`);
lines.push(`- Holds: ${document.holds.join(', ') || 'none'}`);
lines.push(`- Redacted thread count: ${document.redactedThreadText.length}`);
}

return `${lines.join('\n')}\n`;
}

function svgSummary(packet) {
const rows = packet.documents
.map((document, index) => {
const y = 168 + index * 68;
const color = document.decision === 'ready' ? '#22c55e' : '#ef4444';
return [
`<rect x="70" y="${y}" width="1020" height="46" rx="12" fill="#111827" stroke="${color}" stroke-width="3"/>`,
`<text x="96" y="${y + 30}" fill="#e5e7eb" font-size="21">${document.documentId}: ${document.decision}</text>`,
`<text x="590" y="${y + 30}" fill="#cbd5e1" font-size="18">holds ${document.holds.length} | private ${document.metrics.privateThreadCount}</text>`
].join('\n');
})
.join('\n');

return `<svg xmlns="http://www.w3.org/2000/svg" width="1160" height="420" viewBox="0 0 1160 420">
<rect width="1160" height="420" fill="#0f172a"/>
<text x="70" y="75" fill="#f8fafc" font-size="36" font-family="Arial">Private Comment Export Guard</text>
<text x="70" y="120" fill="#bae6fd" font-size="22" font-family="Arial">Ready ${packet.summary.ready} | Hold ${packet.summary.hold}</text>
${rows}
</svg>
`;
}

const review = reviewExportCommentPrivacy(workspace);
const packet = buildExportPacket(workspace, review);

fs.writeFileSync(path.join(REPORTS, 'readiness-report.json'), `${JSON.stringify(packet, null, 2)}\n`);
fs.writeFileSync(path.join(REPORTS, 'readiness-report.md'), markdownReport(packet));
fs.writeFileSync(path.join(REPORTS, 'readiness-summary.svg'), svgSummary(packet));

console.log(JSON.stringify(packet.summary));
148 changes: 148 additions & 0 deletions collaborative-private-comment-export-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
const crypto = require('crypto');

function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}

if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`;
}

return JSON.stringify(value);
}

function digest(value) {
return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`;
}

function redactText(value) {
return String(value || '')
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted-email]')
.replace(/\b(?:\+?\d[\d\s().-]{6,}\d)\b/g, '[redacted-phone]');
}

function exportProfile(workspace, document) {
return workspace.exportProfiles[document.exportProfile] || {
allowedVisibilities: ['public'],
requireResolvedThreads: true
};
}

function reviewThread(thread, profile, invitedReviewerIds) {
const holds = [];
const allowed = new Set(profile.allowedVisibilities || ['public']);
const invited = new Set(invitedReviewerIds || []);

if (!allowed.has(thread.visibility)) {
holds.push(`visibility-not-allowed:${thread.id}:${thread.visibility}`);
}

if (profile.requireResolvedThreads && thread.resolved !== true) {
holds.push(`unresolved-thread:${thread.id}`);
}

for (const mention of thread.mentions || []) {
if (!invited.has(mention)) {
holds.push(`uninvited-mention:${thread.id}:${mention}`);
}
}

return holds;
}

function reviewDocument(workspace, document) {
const profile = exportProfile(workspace, document);
const holds = [];

for (const thread of document.threads || []) {
holds.push(...reviewThread(thread, profile, workspace.invitedReviewerIds));
}

if ((document.inlineAnnotations || []).some((annotation) => annotation.kind === 'sensitive-inline')) {
holds.push('sensitive-inline-annotation');
}

if (holds.length > 0 && (document.redactionEvidence || []).length === 0) {
holds.push('missing-redaction-evidence');
}

const decision = holds.length === 0 ? 'ready' : 'hold';

return {
documentId: document.id,
exportProfile: document.exportProfile,
decision,
holds,
metrics: {
threadCount: (document.threads || []).length,
privateThreadCount: (document.threads || []).filter((thread) => thread.visibility !== 'public').length,
unresolvedThreadCount: (document.threads || []).filter((thread) => thread.resolved !== true).length,
inlineAnnotationCount: (document.inlineAnnotations || []).length
},
actions: {
releaseExport: decision === 'ready',
redactPrivateThreads: holds.some((hold) => hold.startsWith('visibility-not-allowed')),
requireCommentResolution: holds.some((hold) => hold.startsWith('unresolved-thread')),
notifyWorkspaceAdmins: holds.length > 0
}
};
}

function reviewExportCommentPrivacy(workspace) {
const documentReviews = (workspace.documents || []).map((document) =>
reviewDocument(workspace, document)
);
const readyCount = documentReviews.filter((review) => review.decision === 'ready').length;
const holdCount = documentReviews.length - readyCount;
const review = {
workspaceId: workspace.id,
title: workspace.title,
readyCount,
holdCount,
documentReviews
};

return {
...review,
digest: digest(review)
};
}

function buildExportPacket(workspace, review) {
return {
workspaceId: workspace.id,
title: workspace.title,
summary: {
ready: review.readyCount,
hold: review.holdCount
},
documents: review.documentReviews.map((documentReview) => {
const document = (workspace.documents || []).find(
(candidate) => candidate.id === documentReview.documentId
);

return {
documentId: documentReview.documentId,
exportProfile: documentReview.exportProfile,
decision: documentReview.decision,
holds: documentReview.holds,
metrics: documentReview.metrics,
redactedThreadText: (document && document.threads || []).map((thread) => ({
threadId: thread.id,
visibility: thread.visibility,
text: redactText(thread.text)
}))
};
}),
digest: review.digest
};
}

module.exports = {
buildExportPacket,
reviewExportCommentPrivacy
};
70 changes: 70 additions & 0 deletions collaborative-private-comment-export-guard/make-demo-video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont


ROOT = Path(__file__).resolve().parent
REPORTS = ROOT / "reports"
REPORTS.mkdir(exist_ok=True)
OUTPUT = REPORTS / "demo.mp4"


def load_font(size):
for candidate in [
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]:
path = Path(candidate)
if path.exists():
return ImageFont.truetype(str(path), size)
return ImageFont.load_default()


def frame(progress):
image = Image.new("RGB", (1280, 720), "#111827")
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((58, 70, 1222, 650), radius=18, fill="#1f2937", outline="#f472b6", width=4)

title = load_font(42)
heading = load_font(28)
body = load_font(24)
small = load_font(18)

draw.text((100, 125), "Collaborative Private Comment Export Guard", fill="white", font=title)
lines = [
"Checks public exports for private and reviewer-only comments",
"Blocks unresolved threads when public release requires resolution",
"Flags mentions of reviewers outside the invited roster",
"Holds sensitive inline annotations without redaction evidence",
"Redacts emails and phone numbers from reviewer packets",
"Emits deterministic JSON, Markdown, SVG, and demo evidence",
]

visible = min(len(lines), 1 + int(progress * len(lines)))
for index, line in enumerate(lines[:visible]):
y = 205 + index * 54
draw.text((100, y), line, fill="#fce7f3", font=heading if index < 3 else body)

draw.rounded_rectangle((100, 590, 1080, 615), radius=10, fill="#334155")
draw.rounded_rectangle((100, 590, 100 + int(980 * progress), 615), radius=10, fill="#f472b6")
draw.text((100, 635), "SCIBASE issue #12 collaborative editor - synthetic demo", fill="#fbcfe8", font=small)
return image


def main():
try:
import imageio.v3 as iio
except Exception as exc: # pragma: no cover - helper path for local artifact generation
raise SystemExit(
"imageio and imageio-ffmpeg are required to regenerate reports/demo.mp4. "
"The committed demo.mp4 is already generated for review."
) from exc

frames = [frame(index / 59) for index in range(60)]
iio.imwrite(OUTPUT, frames, fps=15, codec="libx264", quality=8, macro_block_size=16)
print(f"Wrote {OUTPUT.relative_to(ROOT)}")


if __name__ == "__main__":
main()
Binary file not shown.
Loading