prism-mcp-server 20.0.7 → 20.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/tools/behavioralVerifierHandler.js +9 -0
- package/dist/utils/groundingVerifier.js +3 -3
- package/package.json +1 -1
- package/dist/agent/agentTools.js +0 -453
- package/dist/agent/mcpBridge.js +0 -234
- package/dist/agent/platformUtils.js +0 -470
- package/dist/agent/terminalUI.js +0 -198
- package/dist/auth.js +0 -218
- package/dist/darkfactory/cloudDelegate.js +0 -173
- package/dist/plugins/pluginManager.js +0 -199
- package/dist/prism-cloud.js +0 -110
- package/dist/scm/ciPipeline.js +0 -220
- package/dist/sync/encryptedSync.js +0 -172
- package/dist/sync/synaluxProxy.js +0 -177
- package/dist/tools/adaptiveDefinitions.js +0 -148
- package/dist/tools/interactiveDefinitions.js +0 -87
- package/dist/tools/interactiveHandlers.js +0 -164
- package/dist/tools/projects.js +0 -214
- package/dist/utils/changelogGenerator.js +0 -158
- package/dist/utils/fallbackClient.js +0 -52
- package/dist/utils/memoryAttestation.js +0 -163
- package/dist/utils/rbac.js +0 -321
- package/dist/utils/tavilyApi.js +0 -70
- package/dist/vm/quotaEnforcer.js +0 -192
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v12.4: AI-Generated Changelogs from Session Ledger History
|
|
3
|
-
*
|
|
4
|
-
* Reads session ledger entries and generates human-readable changelogs
|
|
5
|
-
* in Keep a Changelog format. Uses local LLM (if available) for
|
|
6
|
-
* natural language summarization, with rule-based fallback.
|
|
7
|
-
*/
|
|
8
|
-
import { debugLog } from "./logger.js";
|
|
9
|
-
// ─── Changelog Generation ────────────────────────────────────
|
|
10
|
-
/**
|
|
11
|
-
* Classify a ledger summary into a changelog section.
|
|
12
|
-
*/
|
|
13
|
-
export function classifySummary(summary) {
|
|
14
|
-
const lower = summary.toLowerCase();
|
|
15
|
-
if (/\b(fix|bug|patch|resolve|repair|correct)\b/.test(lower))
|
|
16
|
-
return "fixed";
|
|
17
|
-
if (/\b(remov|delet|drop|deprecat|rip out)\b/.test(lower))
|
|
18
|
-
return "removed";
|
|
19
|
-
if (/\b(secur|vulnerab|cve|auth|encrypt|permission)\b/.test(lower))
|
|
20
|
-
return "security";
|
|
21
|
-
if (/\b(deprecat|sunset|legacy|phase out)\b/.test(lower))
|
|
22
|
-
return "deprecated";
|
|
23
|
-
if (/\b(add|creat|new|implement|introduc|support|feat)\b/.test(lower))
|
|
24
|
-
return "added";
|
|
25
|
-
return "changed";
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Generate a changelog from ledger entries (rule-based).
|
|
29
|
-
*/
|
|
30
|
-
export function generateChangelog(entries, options = {}) {
|
|
31
|
-
const { fromDate, toDate, groupBy = "date", includeFileChanges = false, } = options;
|
|
32
|
-
// Filter by date range
|
|
33
|
-
let filtered = entries;
|
|
34
|
-
if (fromDate) {
|
|
35
|
-
filtered = filtered.filter(e => new Date(e.created_at) >= new Date(fromDate));
|
|
36
|
-
}
|
|
37
|
-
if (toDate) {
|
|
38
|
-
filtered = filtered.filter(e => new Date(e.created_at) <= new Date(toDate));
|
|
39
|
-
}
|
|
40
|
-
// Sort by date (newest first)
|
|
41
|
-
filtered.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
42
|
-
const sections = {
|
|
43
|
-
added: [],
|
|
44
|
-
changed: [],
|
|
45
|
-
fixed: [],
|
|
46
|
-
removed: [],
|
|
47
|
-
security: [],
|
|
48
|
-
deprecated: [],
|
|
49
|
-
};
|
|
50
|
-
for (const entry of filtered) {
|
|
51
|
-
const section = classifySummary(entry.summary);
|
|
52
|
-
let line = entry.summary;
|
|
53
|
-
if (includeFileChanges && entry.files_changed?.length) {
|
|
54
|
-
line += ` (${entry.files_changed.join(", ")})`;
|
|
55
|
-
}
|
|
56
|
-
sections[section].push(line);
|
|
57
|
-
// Also classify decisions
|
|
58
|
-
if (entry.decisions) {
|
|
59
|
-
for (const decision of entry.decisions) {
|
|
60
|
-
const dSection = classifySummary(decision);
|
|
61
|
-
sections[dSection].push(`Decision: ${decision}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
const now = new Date().toISOString().split("T")[0];
|
|
66
|
-
return {
|
|
67
|
-
version: "Unreleased",
|
|
68
|
-
date: now,
|
|
69
|
-
sections,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Format a changelog entry as Markdown (Keep a Changelog format).
|
|
74
|
-
*/
|
|
75
|
-
export function formatChangelogMarkdown(entry) {
|
|
76
|
-
const lines = [];
|
|
77
|
-
lines.push(`## [${entry.version}] - ${entry.date}`);
|
|
78
|
-
lines.push("");
|
|
79
|
-
const sectionNames = [
|
|
80
|
-
["added", "Added"],
|
|
81
|
-
["changed", "Changed"],
|
|
82
|
-
["fixed", "Fixed"],
|
|
83
|
-
["removed", "Removed"],
|
|
84
|
-
["security", "Security"],
|
|
85
|
-
["deprecated", "Deprecated"],
|
|
86
|
-
];
|
|
87
|
-
for (const [key, label] of sectionNames) {
|
|
88
|
-
if (entry.sections[key].length > 0) {
|
|
89
|
-
lines.push(`### ${label}`);
|
|
90
|
-
for (const item of entry.sections[key]) {
|
|
91
|
-
lines.push(`- ${item}`);
|
|
92
|
-
}
|
|
93
|
-
lines.push("");
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return lines.join("\n");
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Format a changelog entry as HTML.
|
|
100
|
-
*/
|
|
101
|
-
export function formatChangelogHtml(entry) {
|
|
102
|
-
let html = `<h2>[${entry.version}] - ${entry.date}</h2>\n`;
|
|
103
|
-
const sectionNames = [
|
|
104
|
-
["added", "Added"],
|
|
105
|
-
["changed", "Changed"],
|
|
106
|
-
["fixed", "Fixed"],
|
|
107
|
-
["removed", "Removed"],
|
|
108
|
-
["security", "Security"],
|
|
109
|
-
["deprecated", "Deprecated"],
|
|
110
|
-
];
|
|
111
|
-
for (const [key, label] of sectionNames) {
|
|
112
|
-
if (entry.sections[key].length > 0) {
|
|
113
|
-
html += `<h3>${label}</h3>\n<ul>\n`;
|
|
114
|
-
for (const item of entry.sections[key]) {
|
|
115
|
-
html += ` <li>${escapeHtml(item)}</li>\n`;
|
|
116
|
-
}
|
|
117
|
-
html += `</ul>\n`;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return html;
|
|
121
|
-
}
|
|
122
|
-
function escapeHtml(str) {
|
|
123
|
-
return str
|
|
124
|
-
.replace(/&/g, "&")
|
|
125
|
-
.replace(/</g, "<")
|
|
126
|
-
.replace(/>/g, ">")
|
|
127
|
-
.replace(/"/g, """);
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Generate a changelog with optional LLM summarization.
|
|
131
|
-
*/
|
|
132
|
-
export async function generateChangelogWithLlm(entries, options = {}) {
|
|
133
|
-
const changelog = generateChangelog(entries, options);
|
|
134
|
-
const format = options.format || "markdown";
|
|
135
|
-
if (!options.useLlm) {
|
|
136
|
-
return format === "html"
|
|
137
|
-
? formatChangelogHtml(changelog)
|
|
138
|
-
: format === "json"
|
|
139
|
-
? JSON.stringify(changelog, null, 2)
|
|
140
|
-
: formatChangelogMarkdown(changelog);
|
|
141
|
-
}
|
|
142
|
-
// LLM-enhanced: summarize each section
|
|
143
|
-
try {
|
|
144
|
-
const { callLocalLlm } = await import("./localLlm.js");
|
|
145
|
-
const rawMarkdown = formatChangelogMarkdown(changelog);
|
|
146
|
-
const enhanced = await callLocalLlm(`You are a technical writer. Rewrite this changelog to be more concise and professional. ` +
|
|
147
|
-
`Keep the Keep a Changelog format. Combine duplicate entries. ` +
|
|
148
|
-
`Use action verbs (Added, Fixed, Changed). Output markdown only:\n\n${rawMarkdown}`);
|
|
149
|
-
return enhanced || rawMarkdown;
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
debugLog("Changelog: LLM unavailable, using rule-based output");
|
|
153
|
-
return format === "html"
|
|
154
|
-
? formatChangelogHtml(changelog)
|
|
155
|
-
: formatChangelogMarkdown(changelog);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
debugLog("v12.4: Changelog generator loaded");
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Supabase Direct Fallback Client
|
|
3
|
-
*
|
|
4
|
-
* When prism-mcp Railway/Fly endpoints are unreachable, this module
|
|
5
|
-
* allows critical tools (load_context, save_ledger, save_handoff) to
|
|
6
|
-
* call Supabase REST API directly — bypassing the prism-mcp server.
|
|
7
|
-
*
|
|
8
|
-
* Used by smithery-bridge.mjs health monitor: if /healthz fails 3× in
|
|
9
|
-
* a row, responses include a Retry-After + x-fallback-mode: supabase
|
|
10
|
-
* header so Claude Code can switch to direct-Supabase mode.
|
|
11
|
-
*
|
|
12
|
-
* Architecture:
|
|
13
|
-
* Normal: Agent → Railway prism-mcp → Supabase
|
|
14
|
-
* Fallback: Agent → Supabase REST API directly (read-only for safety)
|
|
15
|
-
*/
|
|
16
|
-
const SUPABASE_URL = process.env.SUPABASE_URL || '';
|
|
17
|
-
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || '';
|
|
18
|
-
/**
|
|
19
|
-
* Direct Supabase read — returns last handoff for a project.
|
|
20
|
-
* Called when prism-mcp is unreachable.
|
|
21
|
-
*/
|
|
22
|
-
export async function directLoadContext(project) {
|
|
23
|
-
if (!SUPABASE_URL || !SUPABASE_ANON_KEY)
|
|
24
|
-
return null;
|
|
25
|
-
try {
|
|
26
|
-
const res = await fetch(`${SUPABASE_URL}/rest/v1/session_handoffs?project=eq.${encodeURIComponent(project)}&order=updated_at.desc&limit=1`, { headers: { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` } });
|
|
27
|
-
if (!res.ok)
|
|
28
|
-
return null;
|
|
29
|
-
const [row] = await res.json();
|
|
30
|
-
if (!row)
|
|
31
|
-
return null;
|
|
32
|
-
return {
|
|
33
|
-
project,
|
|
34
|
-
lastSummary: row.last_summary ?? '',
|
|
35
|
-
openTodos: row.open_todos ?? [],
|
|
36
|
-
keyContext: row.key_context ?? '',
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/** Returns true if Railway prism-mcp primary is reachable. */
|
|
44
|
-
export async function isPrimaryHealthy(primaryUrl) {
|
|
45
|
-
try {
|
|
46
|
-
const res = await fetch(`${primaryUrl}/healthz`, { signal: AbortSignal.timeout(3000) });
|
|
47
|
-
return res.ok;
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v12.4: Memory Attestation — SHA-256 Merkle Tree
|
|
3
|
-
*
|
|
4
|
-
* Provides cryptographic proof of memory integrity.
|
|
5
|
-
* Builds a Merkle tree from session entries, enabling:
|
|
6
|
-
* - Tamper detection on individual entries
|
|
7
|
-
* - Provable audit trail for compliance
|
|
8
|
-
* - Efficient partial verification
|
|
9
|
-
*/
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
|
-
import { debugLog } from "./logger.js";
|
|
12
|
-
// ─── Hashing ─────────────────────────────────────────────────
|
|
13
|
-
/**
|
|
14
|
-
* SHA-256 hash of a string.
|
|
15
|
-
*/
|
|
16
|
-
export function sha256(data) {
|
|
17
|
-
return createHash("sha256").update(data, "utf8").digest("hex");
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Hash two child hashes into a parent hash.
|
|
21
|
-
*/
|
|
22
|
-
export function hashPair(left, right) {
|
|
23
|
-
return sha256(left + right);
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Hash a memory entry's content for use as a leaf node.
|
|
27
|
-
*/
|
|
28
|
-
export function hashEntry(entryId, content, timestamp) {
|
|
29
|
-
return sha256(`${entryId}:${content}:${timestamp}`);
|
|
30
|
-
}
|
|
31
|
-
// ─── Merkle Tree Construction ────────────────────────────────
|
|
32
|
-
/**
|
|
33
|
-
* Build a Merkle tree from leaf hashes.
|
|
34
|
-
*/
|
|
35
|
-
export function buildMerkleTree(entries) {
|
|
36
|
-
if (entries.length === 0)
|
|
37
|
-
return null;
|
|
38
|
-
// Create leaf nodes
|
|
39
|
-
let nodes = entries.map(e => ({
|
|
40
|
-
hash: e.hash,
|
|
41
|
-
entryId: e.id,
|
|
42
|
-
depth: 0,
|
|
43
|
-
}));
|
|
44
|
-
// If odd number of leaves, duplicate the last one
|
|
45
|
-
if (nodes.length % 2 !== 0) {
|
|
46
|
-
nodes.push({ ...nodes[nodes.length - 1] });
|
|
47
|
-
}
|
|
48
|
-
let depth = 0;
|
|
49
|
-
// Build tree bottom-up
|
|
50
|
-
while (nodes.length > 1) {
|
|
51
|
-
depth++;
|
|
52
|
-
const next = [];
|
|
53
|
-
for (let i = 0; i < nodes.length; i += 2) {
|
|
54
|
-
const left = nodes[i];
|
|
55
|
-
const right = nodes[i + 1] || left; // Duplicate last if odd
|
|
56
|
-
next.push({
|
|
57
|
-
hash: hashPair(left.hash, right.hash),
|
|
58
|
-
left,
|
|
59
|
-
right,
|
|
60
|
-
depth,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
nodes = next;
|
|
64
|
-
}
|
|
65
|
-
return nodes[0];
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Get the Merkle root hash.
|
|
69
|
-
*/
|
|
70
|
-
export function getMerkleRoot(entries) {
|
|
71
|
-
const tree = buildMerkleTree(entries);
|
|
72
|
-
return tree?.hash || sha256("empty");
|
|
73
|
-
}
|
|
74
|
-
// ─── Proof Generation & Verification ─────────────────────────
|
|
75
|
-
/**
|
|
76
|
-
* Generate a Merkle proof for a specific entry.
|
|
77
|
-
*/
|
|
78
|
-
export function generateProof(entries, targetEntryId) {
|
|
79
|
-
const targetIdx = entries.findIndex(e => e.id === targetEntryId);
|
|
80
|
-
if (targetIdx === -1)
|
|
81
|
-
return null;
|
|
82
|
-
// Pad to even length
|
|
83
|
-
const padded = [...entries];
|
|
84
|
-
if (padded.length % 2 !== 0) {
|
|
85
|
-
padded.push({ ...padded[padded.length - 1] });
|
|
86
|
-
}
|
|
87
|
-
const proof = [];
|
|
88
|
-
let idx = targetIdx;
|
|
89
|
-
let currentLevel = padded.map(e => e.hash);
|
|
90
|
-
while (currentLevel.length > 1) {
|
|
91
|
-
const nextLevel = [];
|
|
92
|
-
const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1;
|
|
93
|
-
if (siblingIdx < currentLevel.length) {
|
|
94
|
-
proof.push({
|
|
95
|
-
hash: currentLevel[siblingIdx],
|
|
96
|
-
position: idx % 2 === 0 ? "right" : "left",
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
100
|
-
const left = currentLevel[i];
|
|
101
|
-
const right = currentLevel[i + 1] || left;
|
|
102
|
-
nextLevel.push(hashPair(left, right));
|
|
103
|
-
}
|
|
104
|
-
idx = Math.floor(idx / 2);
|
|
105
|
-
currentLevel = nextLevel;
|
|
106
|
-
}
|
|
107
|
-
const root = currentLevel[0];
|
|
108
|
-
const entryHash = entries[targetIdx].hash;
|
|
109
|
-
return {
|
|
110
|
-
entryId: targetEntryId,
|
|
111
|
-
entryHash,
|
|
112
|
-
proof,
|
|
113
|
-
root,
|
|
114
|
-
verified: verifyProof(entryHash, proof, root),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Verify a Merkle proof.
|
|
119
|
-
*/
|
|
120
|
-
export function verifyProof(entryHash, proof, expectedRoot) {
|
|
121
|
-
let currentHash = entryHash;
|
|
122
|
-
for (const step of proof) {
|
|
123
|
-
if (step.position === "right") {
|
|
124
|
-
currentHash = hashPair(currentHash, step.hash);
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
currentHash = hashPair(step.hash, currentHash);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return currentHash === expectedRoot;
|
|
131
|
-
}
|
|
132
|
-
// ─── Attestation Report ──────────────────────────────────────
|
|
133
|
-
/**
|
|
134
|
-
* Generate a full attestation report for a project's memory.
|
|
135
|
-
*/
|
|
136
|
-
export function generateAttestationReport(project, entries) {
|
|
137
|
-
const hashedEntries = entries.map(e => ({
|
|
138
|
-
id: e.id,
|
|
139
|
-
hash: hashEntry(e.id, e.content, e.timestamp),
|
|
140
|
-
}));
|
|
141
|
-
const tree = buildMerkleTree(hashedEntries);
|
|
142
|
-
const report = {
|
|
143
|
-
root: tree?.hash || sha256("empty"),
|
|
144
|
-
entryCount: entries.length,
|
|
145
|
-
treeDepth: tree?.depth || 0,
|
|
146
|
-
generatedAt: new Date().toISOString(),
|
|
147
|
-
project,
|
|
148
|
-
entries: hashedEntries,
|
|
149
|
-
};
|
|
150
|
-
debugLog(`Attestation: Generated report for '${project}' — ${entries.length} entries, root: ${report.root.slice(0, 16)}...`);
|
|
151
|
-
return report;
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Verify that a single entry hasn't been tampered with.
|
|
155
|
-
*/
|
|
156
|
-
export function verifyEntry(entryId, content, timestamp, report) {
|
|
157
|
-
const expectedHash = hashEntry(entryId, content, timestamp);
|
|
158
|
-
const storedEntry = report.entries.find(e => e.id === entryId);
|
|
159
|
-
if (!storedEntry)
|
|
160
|
-
return false;
|
|
161
|
-
return storedEntry.hash === expectedHash;
|
|
162
|
-
}
|
|
163
|
-
debugLog("v12.4: Memory attestation (Merkle tree) loaded");
|
package/dist/utils/rbac.js
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v12.3: Role-Based Access Control (RBAC) Engine
|
|
3
|
-
*
|
|
4
|
-
* Enforces project-level and partition-level access control.
|
|
5
|
-
* Roles: admin, editor, viewer (extensible via custom roles).
|
|
6
|
-
* Permissions are checked per-project, per-memory-partition.
|
|
7
|
-
*
|
|
8
|
-
* Storage: Local SQLite table `rbac_roles` + `rbac_assignments` (auto-created).
|
|
9
|
-
*/
|
|
10
|
-
import { debugLog } from "./logger.js";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
// ─── Built-in Role Definitions ───────────────────────────────
|
|
14
|
-
const BUILTIN_ROLES = {
|
|
15
|
-
admin: {
|
|
16
|
-
name: "admin",
|
|
17
|
-
displayName: "Administrator",
|
|
18
|
-
permissions: { read: true, write: true, delete: true, admin: true },
|
|
19
|
-
isBuiltin: true,
|
|
20
|
-
createdAt: "2026-01-01T00:00:00Z",
|
|
21
|
-
},
|
|
22
|
-
editor: {
|
|
23
|
-
name: "editor",
|
|
24
|
-
displayName: "Editor",
|
|
25
|
-
permissions: { read: true, write: true, delete: false, admin: false },
|
|
26
|
-
isBuiltin: true,
|
|
27
|
-
createdAt: "2026-01-01T00:00:00Z",
|
|
28
|
-
},
|
|
29
|
-
viewer: {
|
|
30
|
-
name: "viewer",
|
|
31
|
-
displayName: "Viewer",
|
|
32
|
-
permissions: { read: true, write: false, delete: false, admin: false },
|
|
33
|
-
isBuiltin: true,
|
|
34
|
-
createdAt: "2026-01-01T00:00:00Z",
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
// ─── In-Memory State ─────────────────────────────────────────
|
|
38
|
-
const customRoles = new Map();
|
|
39
|
-
const assignments = new Map(); // keyed by `userId:project`
|
|
40
|
-
// ─── Role Management ─────────────────────────────────────────
|
|
41
|
-
export function getRole(name) {
|
|
42
|
-
return BUILTIN_ROLES[name] || customRoles.get(name);
|
|
43
|
-
}
|
|
44
|
-
export function listRoles() {
|
|
45
|
-
return [
|
|
46
|
-
...Object.values(BUILTIN_ROLES),
|
|
47
|
-
...Array.from(customRoles.values()),
|
|
48
|
-
];
|
|
49
|
-
}
|
|
50
|
-
export function createCustomRole(name, displayName, permissions) {
|
|
51
|
-
if (BUILTIN_ROLES[name]) {
|
|
52
|
-
throw new Error(`Cannot override built-in role: ${name}`);
|
|
53
|
-
}
|
|
54
|
-
const role = {
|
|
55
|
-
name,
|
|
56
|
-
displayName,
|
|
57
|
-
permissions,
|
|
58
|
-
isBuiltin: false,
|
|
59
|
-
createdAt: new Date().toISOString(),
|
|
60
|
-
};
|
|
61
|
-
customRoles.set(name, role);
|
|
62
|
-
debugLog(`RBAC: Created custom role '${name}' with permissions: ${JSON.stringify(permissions)}`);
|
|
63
|
-
return role;
|
|
64
|
-
}
|
|
65
|
-
export function deleteCustomRole(name) {
|
|
66
|
-
if (BUILTIN_ROLES[name]) {
|
|
67
|
-
throw new Error(`Cannot delete built-in role: ${name}`);
|
|
68
|
-
}
|
|
69
|
-
return customRoles.delete(name);
|
|
70
|
-
}
|
|
71
|
-
// ─── Assignment Management ───────────────────────────────────
|
|
72
|
-
function assignmentKey(userId, project) {
|
|
73
|
-
return `${userId}:${project}`;
|
|
74
|
-
}
|
|
75
|
-
export function assignRole(userId, role, project, assignedBy, partition, expiresAt) {
|
|
76
|
-
const roleDef = getRole(role);
|
|
77
|
-
if (!roleDef) {
|
|
78
|
-
throw new Error(`Unknown role: ${role}. Available: ${listRoles().map(r => r.name).join(", ")}`);
|
|
79
|
-
}
|
|
80
|
-
const assignment = {
|
|
81
|
-
id: `ra_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
82
|
-
userId,
|
|
83
|
-
role,
|
|
84
|
-
project,
|
|
85
|
-
partition,
|
|
86
|
-
assignedBy,
|
|
87
|
-
assignedAt: new Date().toISOString(),
|
|
88
|
-
expiresAt,
|
|
89
|
-
};
|
|
90
|
-
const key = assignmentKey(userId, project);
|
|
91
|
-
const existing = assignments.get(key) || [];
|
|
92
|
-
// Remove existing assignment for same partition (upsert)
|
|
93
|
-
const filtered = existing.filter(a => a.partition !== partition || a.role !== role);
|
|
94
|
-
filtered.push(assignment);
|
|
95
|
-
assignments.set(key, filtered);
|
|
96
|
-
debugLog(`RBAC: Assigned role '${role}' to user '${userId}' on project '${project}'${partition ? ` partition '${partition}'` : ""}`);
|
|
97
|
-
return assignment;
|
|
98
|
-
}
|
|
99
|
-
export function revokeRole(userId, role, project, partition) {
|
|
100
|
-
const key = assignmentKey(userId, project);
|
|
101
|
-
const existing = assignments.get(key) || [];
|
|
102
|
-
const filtered = existing.filter(a => !(a.role === role && a.partition === partition));
|
|
103
|
-
if (filtered.length === existing.length)
|
|
104
|
-
return false;
|
|
105
|
-
assignments.set(key, filtered);
|
|
106
|
-
debugLog(`RBAC: Revoked role '${role}' from user '${userId}' on project '${project}'`);
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
export function getUserAssignments(userId, project) {
|
|
110
|
-
if (project) {
|
|
111
|
-
return assignments.get(assignmentKey(userId, project)) || [];
|
|
112
|
-
}
|
|
113
|
-
// All assignments for this user across projects
|
|
114
|
-
const all = [];
|
|
115
|
-
for (const [key, assigns] of assignments) {
|
|
116
|
-
if (key.startsWith(`${userId}:`)) {
|
|
117
|
-
all.push(...assigns);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return all;
|
|
121
|
-
}
|
|
122
|
-
export function getProjectMembers(project) {
|
|
123
|
-
const members = [];
|
|
124
|
-
for (const [key, assigns] of assignments) {
|
|
125
|
-
if (key.endsWith(`:${project}`)) {
|
|
126
|
-
members.push(...assigns);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return members;
|
|
130
|
-
}
|
|
131
|
-
// ─── Access Control Checks ───────────────────────────────────
|
|
132
|
-
export function checkAccess(userId, project, permission, partition) {
|
|
133
|
-
const userAssignments = getUserAssignments(userId, project);
|
|
134
|
-
if (userAssignments.length === 0) {
|
|
135
|
-
return {
|
|
136
|
-
allowed: false,
|
|
137
|
-
role: "none",
|
|
138
|
-
permission,
|
|
139
|
-
project,
|
|
140
|
-
reason: `User '${userId}' has no role on project '${project}'`,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
// Check for expired assignments
|
|
144
|
-
const now = new Date();
|
|
145
|
-
const validAssignments = userAssignments.filter(a => {
|
|
146
|
-
if (!a.expiresAt)
|
|
147
|
-
return true;
|
|
148
|
-
return new Date(a.expiresAt) > now;
|
|
149
|
-
});
|
|
150
|
-
if (validAssignments.length === 0) {
|
|
151
|
-
return {
|
|
152
|
-
allowed: false,
|
|
153
|
-
role: "expired",
|
|
154
|
-
permission,
|
|
155
|
-
project,
|
|
156
|
-
reason: `All role assignments for '${userId}' on '${project}' have expired`,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
// If partition is specified, check partition-specific assignments first
|
|
160
|
-
if (partition) {
|
|
161
|
-
const partitionAssigns = validAssignments.filter(a => a.partition === partition);
|
|
162
|
-
if (partitionAssigns.length > 0) {
|
|
163
|
-
// Use highest-privilege partition assignment
|
|
164
|
-
for (const assign of partitionAssigns) {
|
|
165
|
-
const role = getRole(assign.role);
|
|
166
|
-
if (role && role.permissions[permission]) {
|
|
167
|
-
return {
|
|
168
|
-
allowed: true,
|
|
169
|
-
role: assign.role,
|
|
170
|
-
permission,
|
|
171
|
-
project,
|
|
172
|
-
reason: `Granted via partition-level role '${assign.role}'`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Check project-level assignments (no partition filter)
|
|
179
|
-
const projectAssigns = validAssignments.filter(a => !a.partition);
|
|
180
|
-
for (const assign of projectAssigns) {
|
|
181
|
-
const role = getRole(assign.role);
|
|
182
|
-
if (role && role.permissions[permission]) {
|
|
183
|
-
return {
|
|
184
|
-
allowed: true,
|
|
185
|
-
role: assign.role,
|
|
186
|
-
permission,
|
|
187
|
-
project,
|
|
188
|
-
reason: `Granted via project-level role '${assign.role}'`,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
// No matching permission found
|
|
193
|
-
const highestRole = validAssignments[0]?.role || "none";
|
|
194
|
-
return {
|
|
195
|
-
allowed: false,
|
|
196
|
-
role: highestRole,
|
|
197
|
-
permission,
|
|
198
|
-
project,
|
|
199
|
-
reason: `Role '${highestRole}' does not have '${permission}' permission on '${project}'`,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Middleware-style check: throws if access denied.
|
|
204
|
-
*/
|
|
205
|
-
export function requireAccess(userId, project, permission, partition) {
|
|
206
|
-
const result = checkAccess(userId, project, permission, partition);
|
|
207
|
-
if (!result.allowed) {
|
|
208
|
-
throw new Error(`Access denied: ${result.reason}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// ─── SQLite Persistence (lazy-init) ──────────────────────────
|
|
212
|
-
let dbInitialized = false;
|
|
213
|
-
function getRbacDbPath() {
|
|
214
|
-
return join(process.env.PRISM_DATA_DIR || join(homedir(), ".prism"), "rbac.db");
|
|
215
|
-
}
|
|
216
|
-
export async function persistToDb() {
|
|
217
|
-
try {
|
|
218
|
-
// @ts-ignore — dynamic import of optional dependency
|
|
219
|
-
const Database = (await import("better-sqlite3")).default;
|
|
220
|
-
const db = new Database(getRbacDbPath());
|
|
221
|
-
db.exec(`
|
|
222
|
-
CREATE TABLE IF NOT EXISTS rbac_custom_roles (
|
|
223
|
-
name TEXT PRIMARY KEY,
|
|
224
|
-
display_name TEXT NOT NULL,
|
|
225
|
-
perm_read INTEGER DEFAULT 0,
|
|
226
|
-
perm_write INTEGER DEFAULT 0,
|
|
227
|
-
perm_delete INTEGER DEFAULT 0,
|
|
228
|
-
perm_admin INTEGER DEFAULT 0,
|
|
229
|
-
created_at TEXT NOT NULL
|
|
230
|
-
);
|
|
231
|
-
CREATE TABLE IF NOT EXISTS rbac_assignments (
|
|
232
|
-
id TEXT PRIMARY KEY,
|
|
233
|
-
user_id TEXT NOT NULL,
|
|
234
|
-
role TEXT NOT NULL,
|
|
235
|
-
project TEXT NOT NULL,
|
|
236
|
-
partition TEXT,
|
|
237
|
-
assigned_by TEXT NOT NULL,
|
|
238
|
-
assigned_at TEXT NOT NULL,
|
|
239
|
-
expires_at TEXT,
|
|
240
|
-
UNIQUE(user_id, role, project, partition)
|
|
241
|
-
);
|
|
242
|
-
`);
|
|
243
|
-
// Persist custom roles
|
|
244
|
-
const upsertRole = db.prepare(`
|
|
245
|
-
INSERT OR REPLACE INTO rbac_custom_roles
|
|
246
|
-
(name, display_name, perm_read, perm_write, perm_delete, perm_admin, created_at)
|
|
247
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
248
|
-
`);
|
|
249
|
-
for (const role of customRoles.values()) {
|
|
250
|
-
upsertRole.run(role.name, role.displayName, role.permissions.read ? 1 : 0, role.permissions.write ? 1 : 0, role.permissions.delete ? 1 : 0, role.permissions.admin ? 1 : 0, role.createdAt);
|
|
251
|
-
}
|
|
252
|
-
// Persist assignments
|
|
253
|
-
const upsertAssign = db.prepare(`
|
|
254
|
-
INSERT OR REPLACE INTO rbac_assignments
|
|
255
|
-
(id, user_id, role, project, partition, assigned_by, assigned_at, expires_at)
|
|
256
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
257
|
-
`);
|
|
258
|
-
for (const assigns of assignments.values()) {
|
|
259
|
-
for (const a of assigns) {
|
|
260
|
-
upsertAssign.run(a.id, a.userId, a.role, a.project, a.partition || null, a.assignedBy, a.assignedAt, a.expiresAt || null);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
db.close();
|
|
264
|
-
dbInitialized = true;
|
|
265
|
-
debugLog("RBAC: Persisted state to SQLite");
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
debugLog(`RBAC: Persistence failed (non-fatal): ${err}`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
export async function loadFromDb() {
|
|
272
|
-
try {
|
|
273
|
-
// @ts-ignore
|
|
274
|
-
const Database = (await import("better-sqlite3")).default;
|
|
275
|
-
const dbPath = getRbacDbPath();
|
|
276
|
-
// Check if DB exists before trying to open
|
|
277
|
-
const { existsSync } = await import("node:fs");
|
|
278
|
-
if (!existsSync(dbPath))
|
|
279
|
-
return;
|
|
280
|
-
const db = new Database(dbPath);
|
|
281
|
-
// Load custom roles
|
|
282
|
-
const roles = db.prepare("SELECT * FROM rbac_custom_roles").all();
|
|
283
|
-
for (const r of roles) {
|
|
284
|
-
customRoles.set(r.name, {
|
|
285
|
-
name: r.name,
|
|
286
|
-
displayName: r.display_name,
|
|
287
|
-
permissions: {
|
|
288
|
-
read: !!r.perm_read,
|
|
289
|
-
write: !!r.perm_write,
|
|
290
|
-
delete: !!r.perm_delete,
|
|
291
|
-
admin: !!r.perm_admin,
|
|
292
|
-
},
|
|
293
|
-
isBuiltin: false,
|
|
294
|
-
createdAt: r.created_at,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
// Load assignments
|
|
298
|
-
const assigns = db.prepare("SELECT * FROM rbac_assignments").all();
|
|
299
|
-
for (const a of assigns) {
|
|
300
|
-
const key = assignmentKey(a.user_id, a.project);
|
|
301
|
-
const existing = assignments.get(key) || [];
|
|
302
|
-
existing.push({
|
|
303
|
-
id: a.id,
|
|
304
|
-
userId: a.user_id,
|
|
305
|
-
role: a.role,
|
|
306
|
-
project: a.project,
|
|
307
|
-
partition: a.partition || undefined,
|
|
308
|
-
assignedBy: a.assigned_by,
|
|
309
|
-
assignedAt: a.assigned_at,
|
|
310
|
-
expiresAt: a.expires_at || undefined,
|
|
311
|
-
});
|
|
312
|
-
assignments.set(key, existing);
|
|
313
|
-
}
|
|
314
|
-
db.close();
|
|
315
|
-
debugLog(`RBAC: Loaded ${roles.length} custom roles, ${assigns.length} assignments from DB`);
|
|
316
|
-
}
|
|
317
|
-
catch (err) {
|
|
318
|
-
debugLog(`RBAC: Load failed (non-fatal, using defaults): ${err}`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
debugLog("v12.3: RBAC engine loaded");
|