diff --git a/README.md b/README.md index d338cf6..71c4bb3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Modules + +- [`user-project-management`](./user-project-management) - runnable prototype for identity, researcher profiles, scientific project spaces, permissions, audit logs, and reputation metrics. diff --git a/user-project-management/README.md b/user-project-management/README.md new file mode 100644 index 0000000..ab5b6ed --- /dev/null +++ b/user-project-management/README.md @@ -0,0 +1,60 @@ +# User & Project Management + +This module is a self-contained implementation for SCIBASE.AI issue #11. It models identity, researcher profiles, project spaces, permissions, sharing, audit logs, and reputation metrics for scientific collaboration. + +## What It Covers + +- Authentication through email, OAuth-style linked providers, ORCID, SAML, and MFA enforcement. +- Researcher profiles with ORCID sync, affiliations, keywords, activity, citation-style reputation metrics, and public/private profile mode. +- Scientific project spaces with documents, code, datasets, discussion threads, citations, funding metadata, and archive-ready status. +- Role-based access control for Owner, Admin, Contributor, Reviewer, and Viewer. +- Object-level permissions for project, documents, code, datasets, and review scopes. +- Time-limited collaborator invitations and project-level audit logs. +- Browser dashboard and JSON API for reviewer smoke testing. + +## Run Locally + +```bash +cd user-project-management +npm test +npm start +``` + +Then open `http://localhost:4130`. + +## API Surface + +- `GET /api/dashboard` +- `GET /api/auth/check` +- `GET /api/users/user-alice/orcid` +- `GET /api/users/user-alice/reputation` +- `GET /api/projects/create` +- `GET /api/access/check` + +## Requirement Mapping + +- Authentication and identity: implemented by `authenticateIdentity`. +- ORCID/OAuth/SAML/account linking: represented in user identity links and institutional SAML metadata. +- Researcher profiles: implemented by user records, `syncOrcidProfile`, activity, keywords, and profile visibility. +- Citation and reputation metrics: implemented by `computeReputation`. +- Project spaces: implemented by `createProjectSpace` and `demoWorkspace.projects`. +- Permissions and access control: implemented by `rolePermissions`, `evaluateAccess`, and object scopes. +- External collaborators and time-limited access: implemented by `inviteCollaborator`. +- Project audit log: implemented by per-project `auditLog`. + +## Verification + +```bash +npm test +node src/server.js +``` + +Optional smoke checks: + +```bash +curl -s http://localhost:4130/api/dashboard +curl -s http://localhost:4130/api/auth/check +curl -s http://localhost:4130/api/access/check +``` + +Demo artifacts are committed under `docs/demo/`, including `dashboard.png` and `user-project-management-demo.mp4`. diff --git a/user-project-management/docs/demo-script.md b/user-project-management/docs/demo-script.md new file mode 100644 index 0000000..e108687 --- /dev/null +++ b/user-project-management/docs/demo-script.md @@ -0,0 +1,6 @@ +# Demo Script + +1. Run `npm test` to verify authentication, ORCID sync, reputation, project creation, permissions, invitations, and dashboard payloads. +2. Run `npm start` and open `http://localhost:4130`. +3. Confirm the dashboard shows the project space, identity links, access matrix, and researcher profiles. +4. Smoke-test `/api/auth/check`, `/api/users/user-alice/reputation`, and `/api/access/check`. diff --git a/user-project-management/docs/demo/dashboard.png b/user-project-management/docs/demo/dashboard.png new file mode 100644 index 0000000..f817a0e Binary files /dev/null and b/user-project-management/docs/demo/dashboard.png differ diff --git a/user-project-management/docs/demo/user-project-management-demo.mp4 b/user-project-management/docs/demo/user-project-management-demo.mp4 new file mode 100644 index 0000000..09596d4 Binary files /dev/null and b/user-project-management/docs/demo/user-project-management-demo.mp4 differ diff --git a/user-project-management/package.json b/user-project-management/package.json new file mode 100644 index 0000000..e8b2cc6 --- /dev/null +++ b/user-project-management/package.json @@ -0,0 +1,14 @@ +{ + "name": "@scibase/user-project-management", + "version": "0.1.0", + "private": true, + "description": "Self-contained user and project management prototype for SCIBASE.AI issue #11.", + "type": "module", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/user-project-management/public/app.js b/user-project-management/public/app.js new file mode 100644 index 0000000..a4a0029 --- /dev/null +++ b/user-project-management/public/app.js @@ -0,0 +1,35 @@ +const dashboard = await fetch("/api/dashboard").then((response) => response.json()); + +document.querySelector("#projectTitle").textContent = dashboard.project.title; +document.querySelector("#project").innerHTML = [ + row("Visibility", dashboard.project.visibility), + row("Resources", Object.values(dashboard.project.resources).flat().length), + row("Collaborators", dashboard.project.collaborators.length), + row("Audit events", dashboard.project.auditLog.length) +].join(""); + +document.querySelector("#identity").innerHTML = ` + ${row("Authenticated", dashboard.auth.authenticated)} + ${row("Provider", dashboard.auth.provider)} + ${row("Linked identities", dashboard.auth.identityLinks.join(", "))} +`; + +document.querySelector("#access").innerHTML = dashboard.accessMatrix + .map((rule) => `
${rule.role}${rule.principalId}${rule.permissions.join(", ")}
`) + .join(""); + +document.querySelector("#profiles").innerHTML = dashboard.profiles + .map( + (profile) => ` +
+ ${profile.name} + ${profile.institution} + Score ${profile.reputation.score} ยท ${profile.orcid} +
+ ` + ) + .join(""); + +function row(label, value) { + return `
${label}${String(value)}
`; +} diff --git a/user-project-management/public/index.html b/user-project-management/public/index.html new file mode 100644 index 0000000..72f8457 --- /dev/null +++ b/user-project-management/public/index.html @@ -0,0 +1,40 @@ + + + + + + SCIBASE User Project Management + + + +
+
+

SCIBASE.AI / issue #11

+

User & Project Management

+
+
+
+

Project Space

+

Loading...

+
+
+
+

Identity

+

Authentication and ORCID sync

+
+
+
+

Access Control

+

Roles and object permissions

+
+
+
+

Profiles

+

Reputation and activity

+
+
+
+
+ + + diff --git a/user-project-management/public/styles.css b/user-project-management/public/styles.css new file mode 100644 index 0000000..0dafc17 --- /dev/null +++ b/user-project-management/public/styles.css @@ -0,0 +1,119 @@ +:root { + --ink: #161917; + --muted: #64716a; + --line: #d9e0dc; + --paper: #f5f2ec; + --panel: #ffffff; + --green: #176b4e; + --plum: #6f4568; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.shell { + max-width: 1280px; + margin: 0 auto; + padding: 32px; +} + +header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +p, +h1, +h2 { + margin: 0; +} + +header p, +.label { + color: var(--muted); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + max-width: 760px; + font-size: clamp(38px, 6vw, 76px); + line-height: 0.95; +} + +h2 { + margin-top: 8px; + font-size: 24px; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.panel { + min-height: 260px; + border: 1px solid var(--line); + background: var(--panel); + padding: 24px; +} + +.hero { + grid-row: span 2; +} + +.row, +.rule, +.profile { + border-top: 1px solid var(--line); + padding-top: 12px; + margin-top: 14px; +} + +.row span, +.rule span, +.profile span, +.profile small, +.rule small { + display: block; + color: var(--muted); + line-height: 1.4; +} + +.row strong, +.rule strong, +.profile strong { + display: block; + color: var(--green); + overflow-wrap: anywhere; +} + +.rule strong { + color: var(--plum); +} + +@media (max-width: 820px) { + .shell { + padding: 18px; + } + + header, + .grid { + display: grid; + grid-template-columns: 1fr; + } +} diff --git a/user-project-management/src/management-core.js b/user-project-management/src/management-core.js new file mode 100644 index 0000000..8ed5354 --- /dev/null +++ b/user-project-management/src/management-core.js @@ -0,0 +1,219 @@ +export const demoWorkspace = { + users: [ + { + id: "user-alice", + name: "Alice Chen", + email: "alice@scibase.example", + institution: "SCIBASE Oncology Lab", + field: "Cancer biology", + orcid: "0000-0002-1825-0097", + oauth: ["ORCID", "Google", "GitHub"], + mfaEnabled: true, + profileMode: "public", + keywords: ["organoids", "biomarkers", "reproducibility"], + metrics: { downloads: 1240, forks: 18, endorsements: 42, reproducibilityScore: 94 }, + activity: ["Created organoid response atlas", "Reviewed validation cohort", "Published v1 DOI"] + }, + { + id: "user-mateo", + name: "Mateo Rivera", + email: "mateo@open-repro.example", + institution: "Open Reproducibility Center", + field: "Computational biology", + orcid: "0000-0003-2201-4120", + oauth: ["ORCID", "GitHub", "LinkedIn"], + mfaEnabled: true, + profileMode: "public", + keywords: ["pipelines", "statistics", "notebooks"], + metrics: { downloads: 840, forks: 11, endorsements: 35, reproducibilityScore: 91 }, + activity: ["Added reproducibility workflow", "Opened peer review thread"] + } + ], + institutions: [ + { id: "inst-sci", name: "SCIBASE Oncology Lab", samlEntityId: "https://sso.scibase.example/saml", domains: ["scibase.example"] }, + { id: "inst-open", name: "Open Reproducibility Center", samlEntityId: "https://sso.open-repro.example/saml", domains: ["open-repro.example"] } + ], + projects: [ + { + id: "project-organoid-response", + title: "Organoid chemotherapy response atlas", + visibility: "institutional-only", + status: "active", + ownerId: "user-alice", + metadata: { + funding: ["Open Science Accelerator"], + citation: "Chen and Rivera, 2026", + tags: ["oncology", "organoids"] + }, + resources: { + documents: ["manuscript/main.md", "protocols/organoid-culture.md"], + code: ["code/run_analysis.py"], + datasets: ["data/growth_curves.csv"], + discussions: ["review-statistics", "data-access"], + citations: ["10.5555/scibase.project-organoid-response.v1"] + }, + collaborators: ["user-alice", "user-mateo"], + accessRules: [ + { principalId: "user-alice", role: "Owner", scope: "project", expiresAt: null }, + { principalId: "user-mateo", role: "Contributor", scope: "code", expiresAt: null }, + { principalId: "external-reviewer-1", role: "Reviewer", scope: "documents", expiresAt: "2026-06-01T00:00:00.000Z" } + ], + auditLog: [ + { actorId: "user-alice", action: "project.created", target: "project-organoid-response", createdAt: "2026-05-01T10:00:00.000Z" }, + { actorId: "user-alice", action: "access.invited", target: "external-reviewer-1", createdAt: "2026-05-04T13:00:00.000Z" }, + { actorId: "user-mateo", action: "code.updated", target: "code/run_analysis.py", createdAt: "2026-05-05T15:30:00.000Z" } + ] + } + ] +}; + +export const rolePermissions = { + Owner: ["manage_project", "manage_access", "edit_documents", "edit_code", "download_data", "review", "archive"], + Admin: ["manage_access", "edit_documents", "edit_code", "download_data", "review"], + Contributor: ["edit_documents", "edit_code", "review"], + Reviewer: ["read_documents", "review"], + Viewer: ["read_documents"] +}; + +export function authenticateIdentity({ provider, identifier, mfaCode }, workspace = demoWorkspace) { + const user = workspace.users.find((item) => item.email === identifier || item.orcid === identifier); + if (!user) return { authenticated: false, reason: "unknown_identity" }; + const providerAllowed = provider === "email" || user.oauth.includes(provider) || provider === "SAML"; + const mfaSatisfied = !user.mfaEnabled || /^\d{6}$/.test(mfaCode || ""); + return { + authenticated: providerAllowed && mfaSatisfied, + userId: user.id, + provider, + identityLinks: [...user.oauth, "email", "SAML"], + reason: providerAllowed ? (mfaSatisfied ? "ok" : "mfa_required") : "provider_not_linked" + }; +} + +export function syncOrcidProfile(userId, workspace = demoWorkspace) { + const user = getUser(userId, workspace); + return { + userId, + orcid: user.orcid, + importedPublications: [ + `${user.name}. Reproducible organoid assays. 2026.`, + `${user.name}. Scientific workflow governance. 2025.` + ], + affiliations: [user.institution], + keywords: user.keywords + }; +} + +export function computeReputation(userId, workspace = demoWorkspace) { + const user = getUser(userId, workspace); + const score = Math.round( + Math.log1p(user.metrics.downloads) * 5 + + user.metrics.forks * 1.5 + + user.metrics.endorsements * 2 + + user.metrics.reproducibilityScore * 0.9 + ); + return { + userId, + score, + breakdown: [ + { label: "downloads", value: user.metrics.downloads }, + { label: "forks", value: user.metrics.forks }, + { label: "endorsements", value: user.metrics.endorsements }, + { label: "reproducibility", value: user.metrics.reproducibilityScore } + ] + }; +} + +export function createProjectSpace({ title, ownerId, visibility = "private", tags = [] }, workspace = demoWorkspace) { + getUser(ownerId, workspace); + const id = `project-${slug(title)}`; + const project = { + id, + title, + visibility, + status: "active", + ownerId, + metadata: { funding: [], citation: "", tags }, + resources: { documents: [], code: [], datasets: [], discussions: [], citations: [] }, + collaborators: [ownerId], + accessRules: [{ principalId: ownerId, role: "Owner", scope: "project", expiresAt: null }], + auditLog: [{ actorId: ownerId, action: "project.created", target: id, createdAt: "2026-05-09T00:00:00.000Z" }] + }; + return { workspace: { ...workspace, projects: [...workspace.projects, project] }, project }; +} + +export function evaluateAccess({ userId, projectId, action, objectType = "project" }, workspace = demoWorkspace) { + const project = getProject(projectId, workspace); + const rules = project.accessRules.filter((rule) => rule.principalId === userId && !isExpired(rule.expiresAt)); + const allowedRules = rules.filter((rule) => rule.scope === "project" || rule.scope === objectType); + const granted = allowedRules.some((rule) => (rolePermissions[rule.role] || []).includes(action)); + return { + granted, + userId, + projectId, + action, + objectType, + matchedRoles: allowedRules.map((rule) => rule.role) + }; +} + +export function inviteCollaborator({ actorId, projectId, principalId, role, scope = "project", expiresAt = null }, workspace = demoWorkspace) { + const access = evaluateAccess({ userId: actorId, projectId, action: "manage_access" }, workspace); + if (!access.granted) throw new Error("Actor cannot manage project access"); + const project = getProject(projectId, workspace); + const updatedProject = { + ...project, + collaborators: [...new Set([...project.collaborators, principalId])], + accessRules: [...project.accessRules, { principalId, role, scope, expiresAt }], + auditLog: [ + ...project.auditLog, + { actorId, action: "access.invited", target: principalId, createdAt: "2026-05-09T00:00:00.000Z" } + ] + }; + return replaceProject(workspace, updatedProject); +} + +export function buildProjectDashboard(workspace = demoWorkspace) { + const project = workspace.projects[0]; + return { + auth: authenticateIdentity({ provider: "ORCID", identifier: workspace.users[0].orcid, mfaCode: "123456" }, workspace), + profiles: workspace.users.map((user) => ({ + ...user, + orcidSync: syncOrcidProfile(user.id, workspace), + reputation: computeReputation(user.id, workspace) + })), + project, + accessMatrix: project.accessRules.map((rule) => ({ + ...rule, + permissions: rolePermissions[rule.role] || [] + })), + policyChecks: [ + evaluateAccess({ userId: "user-alice", projectId: project.id, action: "manage_access" }, workspace), + evaluateAccess({ userId: "user-mateo", projectId: project.id, action: "download_data", objectType: "datasets" }, workspace), + evaluateAccess({ userId: "external-reviewer-1", projectId: project.id, action: "review", objectType: "documents" }, workspace) + ] + }; +} + +function getUser(userId, workspace) { + const user = workspace.users.find((item) => item.id === userId); + if (!user) throw new Error(`Unknown user: ${userId}`); + return user; +} + +function getProject(projectId, workspace) { + const project = workspace.projects.find((item) => item.id === projectId); + if (!project) throw new Error(`Unknown project: ${projectId}`); + return project; +} + +function replaceProject(workspace, project) { + return { ...workspace, projects: workspace.projects.map((item) => (item.id === project.id ? project : item)) }; +} + +function isExpired(expiresAt) { + return Boolean(expiresAt && new Date(expiresAt).getTime() < new Date("2026-05-09T00:00:00.000Z").getTime()); +} + +function slug(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); +} diff --git a/user-project-management/src/server.js b/user-project-management/src/server.js new file mode 100644 index 0000000..e7b367e --- /dev/null +++ b/user-project-management/src/server.js @@ -0,0 +1,63 @@ +import http from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + authenticateIdentity, + buildProjectDashboard, + computeReputation, + createProjectSpace, + demoWorkspace, + evaluateAccess, + syncOrcidProfile +} from "./management-core.js"; + +const root = join(fileURLToPath(new URL("..", import.meta.url)), "public"); +const port = Number(process.env.PORT || 4130); +const contentTypes = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8" }; + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host}`); + if (url.pathname === "/api/dashboard") return json(response, buildProjectDashboard()); + if (url.pathname === "/api/auth/check") return json(response, authenticateIdentity({ provider: "ORCID", identifier: demoWorkspace.users[0].orcid, mfaCode: "123456" })); + if (url.pathname.startsWith("/api/users/") && url.pathname.endsWith("/orcid")) return json(response, syncOrcidProfile(url.pathname.split("/")[3])); + if (url.pathname.startsWith("/api/users/") && url.pathname.endsWith("/reputation")) return json(response, computeReputation(url.pathname.split("/")[3])); + if (url.pathname === "/api/projects/create") return json(response, createProjectSpace({ title: "Prospective validation cohort", ownerId: "user-alice", tags: ["validation"] }).project); + if (url.pathname === "/api/access/check") return json(response, evaluateAccess({ userId: "user-mateo", projectId: "project-organoid-response", action: "edit_code", objectType: "code" })); + return await serveStatic(url.pathname === "/" ? "/index.html" : url.pathname, response); + } catch (error) { + response.writeHead(500, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: error.message })); + } +}); + +server.listen(port, () => { + console.log(`User project management demo running at http://localhost:${port}`); +}); + +function json(response, body) { + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(body, null, 2)); +} + +async function serveStatic(pathname, response) { + const filePath = resolve(root, pathname.replace(/^\/+/, "")); + if (!filePath.startsWith(`${root}${sep}`)) { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + try { + const body = await readFile(filePath); + response.writeHead(200, { "content-type": contentTypes[extname(filePath)] || "application/octet-stream" }); + response.end(body); + } catch (error) { + if (error.code === "ENOENT") { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + throw error; + } +} diff --git a/user-project-management/test/management-core.test.js b/user-project-management/test/management-core.test.js new file mode 100644 index 0000000..06005da --- /dev/null +++ b/user-project-management/test/management-core.test.js @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + authenticateIdentity, + buildProjectDashboard, + computeReputation, + createProjectSpace, + demoWorkspace, + evaluateAccess, + inviteCollaborator, + syncOrcidProfile +} from "../src/management-core.js"; + +test("authenticates linked identities with MFA", () => { + const ok = authenticateIdentity({ provider: "ORCID", identifier: demoWorkspace.users[0].orcid, mfaCode: "123456" }); + const missingMfa = authenticateIdentity({ provider: "ORCID", identifier: demoWorkspace.users[0].orcid }); + + assert.equal(ok.authenticated, true); + assert.equal(missingMfa.authenticated, false); + assert.equal(missingMfa.reason, "mfa_required"); +}); + +test("syncs ORCID profile data and computes reputation", () => { + const sync = syncOrcidProfile("user-alice"); + const reputation = computeReputation("user-alice"); + + assert.equal(sync.orcid, demoWorkspace.users[0].orcid); + assert.ok(sync.importedPublications.length >= 2); + assert.ok(reputation.score > 100); +}); + +test("creates project spaces and evaluates object-level permissions", () => { + const { workspace, project } = createProjectSpace({ title: "Metabolomics validation", ownerId: "user-alice", tags: ["metabolomics"] }); + const ownerAccess = evaluateAccess({ userId: "user-alice", projectId: project.id, action: "archive" }, workspace); + const contributorAccess = evaluateAccess({ userId: "user-mateo", projectId: "project-organoid-response", action: "edit_code", objectType: "code" }); + + assert.equal(project.visibility, "private"); + assert.equal(ownerAccess.granted, true); + assert.equal(contributorAccess.granted, true); +}); + +test("invites collaborators with audit logs and builds dashboard payload", () => { + const workspace = inviteCollaborator({ + actorId: "user-alice", + projectId: "project-organoid-response", + principalId: "external-statistician", + role: "Reviewer", + scope: "documents" + }); + const project = workspace.projects[0]; + const dashboard = buildProjectDashboard(workspace); + + assert.ok(project.collaborators.includes("external-statistician")); + assert.ok(project.auditLog.some((entry) => entry.action === "access.invited")); + assert.equal(dashboard.auth.authenticated, true); + assert.ok(dashboard.policyChecks.length >= 3); +});