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
23 changes: 23 additions & 0 deletions collab-presence-privacy-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Collaborative Presence Privacy Guard

This module is a focused Real-Time Collaborative Editor slice for issue #12. It validates live cursor and user-presence broadcasts before they are sent to collaborators, so private sections, embargoed notebook cells, anonymous reviewer identities, and locked dataset areas are not leaked through real-time UI metadata.

The guard checks:

- viewer role and section access before broadcasting cursor anchors
- anonymous reviewer masking
- private, embargoed, and locked section title redaction
- restricted collaborator location suppression
- stale presence sessions
- sanitized room snapshots for live presence sidebars

It uses only Node.js built-ins and synthetic data.

## Run

```bash
node collab-presence-privacy-guard/test.js
node collab-presence-privacy-guard/demo.js
```

The demo writes reviewer artifacts to `collab-presence-privacy-guard/reports/`.
90 changes: 90 additions & 0 deletions collab-presence-privacy-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const fs = require('fs');
const path = require('path');
const { evaluatePresencePrivacy } = require('./index');
const { riskyPresenceRoom } = require('./sample-data');

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

const result = evaluatePresencePrivacy(riskyPresenceRoom);
const packetPath = path.join(reportDir, 'presence-privacy-packet.json');
const reportPath = path.join(reportDir, 'presence-privacy-report.md');
const svgPath = path.join(reportDir, 'summary.svg');

fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`);

const evaluationRows = result.evaluations
.map((entry) => `| ${entry.viewerId} | ${entry.userId} | ${entry.status} | ${entry.sanitizedLocation.sectionTitle} | ${entry.blockers.join(', ') || '-'} |`)
.join('\n');

fs.writeFileSync(
reportPath,
`# Collaborative Presence Privacy Report

Status: ${result.status}
Audit digest: ${result.auditDigest}

## Room

Title: ${result.room.title}

## Broadcast Summary

- Safe broadcasts: ${result.safeBroadcasts}
- Warning broadcasts: ${result.warningBroadcasts}
- Blocked broadcasts: ${result.blockedBroadcasts}

## Viewer Evaluation

| Viewer | Presence user | Status | Broadcast location | Blockers |
| --- | --- | --- | --- | --- |
${evaluationRows}

## Blockers

${result.blockers.map((blocker) => `- ${blocker}`).join('\n')}

## Warnings

${result.warnings.map((warning) => `- ${warning}`).join('\n')}

## Reviewer Actions

${result.reviewerActions.map((action) => `- ${action}`).join('\n')}
`,
);

fs.writeFileSync(
svgPath,
`<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#f8fafc"/>
<rect x="58" y="52" width="844" height="436" rx="18" fill="#ffffff" stroke="#cbd5e1"/>
<text x="90" y="112" font-family="Arial, sans-serif" font-size="32" font-weight="700" fill="#0f172a">Collaborative Presence Privacy Guard</text>
<text x="90" y="154" font-family="Arial, sans-serif" font-size="18" fill="#475569">Status: ${result.status} | Room: ${result.room.id}</text>
<g transform="translate(90 202)">
<rect width="234" height="126" rx="12" fill="#fee2e2" stroke="#ef4444"/>
<text x="24" y="52" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#991b1b">${result.blockedBroadcasts}</text>
<text x="24" y="90" font-family="Arial, sans-serif" font-size="17" fill="#991b1b">blocked broadcasts</text>
</g>
<g transform="translate(363 202)">
<rect width="234" height="126" rx="12" fill="#fef3c7" stroke="#f59e0b"/>
<text x="24" y="52" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#92400e">${result.warningBroadcasts}</text>
<text x="24" y="90" font-family="Arial, sans-serif" font-size="17" fill="#92400e">warning broadcasts</text>
</g>
<g transform="translate(636 202)">
<rect width="234" height="126" rx="12" fill="#dcfce7" stroke="#22c55e"/>
<text x="24" y="52" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#166534">${result.safeBroadcasts}</text>
<text x="24" y="90" font-family="Arial, sans-serif" font-size="17" fill="#166534">safe broadcasts</text>
</g>
<text x="90" y="392" font-family="Arial, sans-serif" font-size="17" fill="#334155">Redacts section titles, cursor anchors, and anonymous identities before broadcast.</text>
<text x="90" y="426" font-family="Arial, sans-serif" font-size="17" fill="#334155">Digest: ${result.auditDigest.slice(0, 32)}...</text>
</svg>
`,
);

console.log(`status=${result.status}`);
console.log(`safe=${result.safeBroadcasts}`);
console.log(`warnings=${result.warningBroadcasts}`);
console.log(`blocked=${result.blockedBroadcasts}`);
console.log(`auditDigest=${result.auditDigest}`);
console.log(`reports=${reportDir}`);
213 changes: 213 additions & 0 deletions collab-presence-privacy-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
const crypto = require('crypto');

const DEFAULT_POLICY = {
staleAfterSeconds: 120,
anonymousRoles: ['anonymous_reviewer', 'blind_reviewer'],
restrictedSectionVisibilities: ['private', 'embargoed', 'restricted'],
lockSensitiveSectionKinds: ['dataset', 'notebook', 'figure'],
};

function asArray(value) {
return Array.isArray(value) ? value : [];
}

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

function digest(value) {
return crypto.createHash('sha256').update(stableJson(value)).digest('hex');
}

function unique(values) {
return [...new Set(values.filter(Boolean))];
}

function indexById(items) {
const out = new Map();
for (const item of asArray(items)) {
if (item && item.id) {
out.set(item.id, item);
}
}
return out;
}

function hasSectionAccess(user, section) {
if (!section) {
return false;
}
const visibility = section.visibility || 'public';
if (visibility === 'public') {
return true;
}
if (visibility === 'institutional') {
return user.institution && user.institution === section.institution;
}
if (visibility === 'private' || visibility === 'restricted') {
return asArray(section.allowedUsers).includes(user.id) || asArray(user.roles).includes('owner');
}
if (visibility === 'embargoed') {
return asArray(section.allowedUsers).includes(user.id) || asArray(user.roles).includes('admin');
}
return false;
}

function publicName(user, policy) {
const roles = asArray(user.roles);
if (roles.some((role) => policy.anonymousRoles.includes(role)) || user.anonymous === true) {
return user.alias || 'Anonymous reviewer';
}
return user.name || user.id;
}

function sanitizeLocationForViewer(presence, section, viewer, policy) {
const canSee = hasSectionAccess(viewer, section);
if (!section || !canSee) {
return {
visible: false,
sectionId: '',
sectionTitle: 'Restricted section',
cursorAnchor: '',
reason: section ? 'viewer_lacks_section_access' : 'unknown_section',
};
}

const restrictedTitle = policy.restrictedSectionVisibilities.includes(section.visibility);
const lockedSensitive = section.locked && policy.lockSensitiveSectionKinds.includes(section.kind);
return {
visible: true,
sectionId: section.id,
sectionTitle: restrictedTitle || lockedSensitive ? 'Restricted section' : section.title,
cursorAnchor: restrictedTitle || lockedSensitive ? '' : presence.cursorAnchor,
reason: restrictedTitle || lockedSensitive ? 'location_redacted' : 'visible',
};
}

function evaluatePresenceForViewer(presence, usersById, sectionsById, viewer, policy) {
const user = usersById.get(presence.userId) || { id: presence.userId, roles: [] };
const section = sectionsById.get(presence.sectionId);
const blockers = [];
const warnings = [];
const actions = [];

if (!section) {
blockers.push('unknown_section');
actions.push('drop_unknown_cursor_anchor');
}

if (Number(presence.ageSeconds || 0) > policy.staleAfterSeconds) {
warnings.push('stale_presence');
actions.push('expire_stale_presence_session');
}

if (section && !hasSectionAccess(viewer, section)) {
blockers.push('viewer_lacks_section_access');
actions.push('suppress_cursor_location_for_viewer');
}

if (section && section.locked && policy.lockSensitiveSectionKinds.includes(section.kind) && !asArray(section.allowedUsers).includes(viewer.id)) {
blockers.push('locked_sensitive_section_cursor');
actions.push('redact_locked_section_cursor_anchor');
}

if (user.anonymous && presence.displayName && presence.displayName === user.name) {
blockers.push('anonymous_identity_leak');
actions.push('replace_display_name_with_alias');
}

const sanitized = sanitizeLocationForViewer(presence, section, viewer, policy);
return {
userId: user.id,
displayName: publicName(user, policy),
originalDisplayName: presence.displayName || '',
viewerId: viewer.id,
status: blockers.length ? 'blocked' : warnings.length ? 'warn' : 'safe',
sanitizedLocation: sanitized,
blockers,
warnings,
actions: unique(actions),
};
}

function evaluatePresencePrivacy(input) {
const policy = { ...DEFAULT_POLICY, ...(input.policy || {}) };
const room = input.room || {};
const users = asArray(room.users);
const sections = asArray(room.sections);
const presences = asArray(room.presences);
const viewers = asArray(room.viewers);
const usersById = indexById(users);
const sectionsById = indexById(sections);
const evaluations = [];
const blockers = [];
const warnings = [];
const actions = [];

for (const viewer of viewers) {
for (const presence of presences) {
if (presence.userId === viewer.id) {
continue;
}
const result = evaluatePresenceForViewer(presence, usersById, sectionsById, viewer, policy);
evaluations.push(result);
for (const blocker of result.blockers) {
blockers.push(`${viewer.id}_${presence.userId}_${blocker}`);
}
for (const warning of result.warnings) {
warnings.push(`${viewer.id}_${presence.userId}_${warning}`);
}
for (const action of result.actions) {
actions.push(`${viewer.id}_${presence.userId}_${action}`);
}
}
}

const sanitizedSnapshots = viewers.map((viewer) => ({
viewerId: viewer.id,
entries: evaluations
.filter((entry) => entry.viewerId === viewer.id)
.map((entry) => ({
userId: entry.userId,
displayName: entry.displayName,
status: entry.status,
location: entry.sanitizedLocation,
})),
}));

const packet = {
room: {
id: room.id,
title: room.title,
},
status: blockers.length ? 'hold' : warnings.length ? 'broadcast_with_expiry' : 'broadcast',
blockedBroadcasts: evaluations.filter((entry) => entry.status === 'blocked').length,
warningBroadcasts: evaluations.filter((entry) => entry.status === 'warn').length,
safeBroadcasts: evaluations.filter((entry) => entry.status === 'safe').length,
blockers: unique(blockers),
warnings: unique(warnings),
reviewerActions: unique(actions),
evaluations,
sanitizedSnapshots,
};

return {
...packet,
auditDigest: digest(packet),
};
}

module.exports = {
DEFAULT_POLICY,
digest,
evaluatePresencePrivacy,
};
Binary file added collab-presence-privacy-guard/reports/demo.mp4
Binary file not shown.
Loading