speclock 5.0.2 → 5.2.0
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 +6 -6
- package/package.json +2 -2
- package/src/cli/index.js +1 -1
- package/src/core/compliance.js +1 -1
- package/src/core/diff-analyzer.js +547 -0
- package/src/core/diff-parser.js +349 -0
- package/src/core/engine.js +12 -0
- package/src/core/patch-gateway.js +565 -0
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +87 -6
- package/src/mcp/server.js +180 -1
package/src/mcp/http-server.js
CHANGED
|
@@ -52,6 +52,12 @@ import {
|
|
|
52
52
|
mapLocksToFiles,
|
|
53
53
|
getModules,
|
|
54
54
|
getCriticalPaths,
|
|
55
|
+
reviewPatch,
|
|
56
|
+
reviewPatchAsync,
|
|
57
|
+
reviewPatchDiff,
|
|
58
|
+
reviewPatchDiffAsync,
|
|
59
|
+
reviewPatchUnified,
|
|
60
|
+
parseUnifiedDiff,
|
|
55
61
|
} from "../core/engine.js";
|
|
56
62
|
import { generateContext, generateContextPack } from "../core/context.js";
|
|
57
63
|
import {
|
|
@@ -107,7 +113,7 @@ import { fileURLToPath } from "url";
|
|
|
107
113
|
import _path from "path";
|
|
108
114
|
|
|
109
115
|
const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
110
|
-
const VERSION = "5.
|
|
116
|
+
const VERSION = "5.2.0";
|
|
111
117
|
const AUTHOR = "Sandeep Roy";
|
|
112
118
|
const START_TIME = Date.now();
|
|
113
119
|
|
|
@@ -881,7 +887,7 @@ app.get("/health", (req, res) => {
|
|
|
881
887
|
status: "healthy",
|
|
882
888
|
version: VERSION,
|
|
883
889
|
uptime: Math.floor((Date.now() - START_TIME) / 1000),
|
|
884
|
-
tools:
|
|
890
|
+
tools: 42,
|
|
885
891
|
auditChain: auditStatus,
|
|
886
892
|
authEnabled: isAuthEnabled(PROJECT_ROOT),
|
|
887
893
|
rateLimit: { limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS },
|
|
@@ -895,8 +901,8 @@ app.get("/", (req, res) => {
|
|
|
895
901
|
name: "speclock",
|
|
896
902
|
version: VERSION,
|
|
897
903
|
author: AUTHOR,
|
|
898
|
-
description: "AI Constraint Engine for autonomous systems governance. Spec Compiler (NL→constraints), Code Graph (blast radius, lock-to-file mapping), Typed constraints (numerical, range, state, temporal), REST API v2 with batch checking & SSE streaming. Python SDK + ROS2 integration. Policy-as-Code, RBAC, AES-256-GCM encryption, hard enforcement, HMAC audit chain, SOC 2/HIPAA compliance.
|
|
899
|
-
tools:
|
|
904
|
+
description: "AI Constraint Engine for autonomous systems governance. Spec Compiler (NL→constraints), Code Graph (blast radius, lock-to-file mapping), Typed constraints (numerical, range, state, temporal), REST API v2 with batch checking & SSE streaming. Python SDK + ROS2 integration. Policy-as-Code, RBAC, AES-256-GCM encryption, hard enforcement, HMAC audit chain, SOC 2/HIPAA compliance. 42 MCP tools. 940 tests, 99.4% accuracy.",
|
|
905
|
+
tools: 42,
|
|
900
906
|
mcp_endpoint: "/mcp",
|
|
901
907
|
health_endpoint: "/health",
|
|
902
908
|
npm: "https://www.npmjs.com/package/speclock",
|
|
@@ -910,7 +916,7 @@ app.get("/.well-known/mcp/server-card.json", (req, res) => {
|
|
|
910
916
|
res.json({
|
|
911
917
|
name: "SpecLock",
|
|
912
918
|
version: VERSION,
|
|
913
|
-
description: "AI Constraint Engine for autonomous systems governance. Spec Compiler (NL→constraints via Gemini Flash), Code Graph (dependency parsing, blast radius, lock-to-file mapping), Typed constraints (numerical, range, state, temporal), REST API v2, Python SDK + ROS2 Guardian Node. Hybrid heuristic + Gemini LLM. Policy-as-Code, RBAC, AES-256-GCM encryption, hard enforcement, HMAC audit chain, SOC 2/HIPAA compliance.
|
|
919
|
+
description: "AI Constraint Engine for autonomous systems governance. Spec Compiler (NL→constraints via Gemini Flash), Code Graph (dependency parsing, blast radius, lock-to-file mapping), Typed constraints (numerical, range, state, temporal), REST API v2, Python SDK + ROS2 Guardian Node. Hybrid heuristic + Gemini LLM. Policy-as-Code, RBAC, AES-256-GCM encryption, hard enforcement, HMAC audit chain, SOC 2/HIPAA compliance. 42 MCP tools. 940 tests, 99.4% accuracy. Works with Claude Code, Cursor, Windsurf, Cline, Bolt.new, Lovable.",
|
|
914
920
|
author: {
|
|
915
921
|
name: "Sandeep Roy",
|
|
916
922
|
url: "https://github.com/sgroy10",
|
|
@@ -919,7 +925,7 @@ app.get("/.well-known/mcp/server-card.json", (req, res) => {
|
|
|
919
925
|
homepage: "https://sgroy10.github.io/speclock/",
|
|
920
926
|
license: "MIT",
|
|
921
927
|
capabilities: {
|
|
922
|
-
tools:
|
|
928
|
+
tools: 42,
|
|
923
929
|
categories: [
|
|
924
930
|
"Memory Management",
|
|
925
931
|
"Change Tracking",
|
|
@@ -1509,6 +1515,81 @@ app.get("/api/v2/graph/lock-map", (req, res) => {
|
|
|
1509
1515
|
}
|
|
1510
1516
|
});
|
|
1511
1517
|
|
|
1518
|
+
// ========================================
|
|
1519
|
+
// PATCH GATEWAY (v5.1)
|
|
1520
|
+
// ========================================
|
|
1521
|
+
|
|
1522
|
+
app.post("/api/v2/gateway/review", async (req, res) => {
|
|
1523
|
+
setCorsHeaders(res);
|
|
1524
|
+
|
|
1525
|
+
const clientIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
|
|
1526
|
+
if (!checkRateLimit(clientIp)) {
|
|
1527
|
+
return res.status(429).json({ error: "Rate limit exceeded", api_version: "v2" });
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const { description, files, useLLM } = req.body || {};
|
|
1531
|
+
if (!description || typeof description !== "string") {
|
|
1532
|
+
return res.status(400).json({ error: "Missing 'description' field (describe what the change does)", api_version: "v2" });
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
try {
|
|
1536
|
+
ensureInit(PROJECT_ROOT);
|
|
1537
|
+
const fileList = Array.isArray(files) ? files : [];
|
|
1538
|
+
const result = useLLM
|
|
1539
|
+
? await reviewPatchAsync(PROJECT_ROOT, { description, files: fileList })
|
|
1540
|
+
: reviewPatch(PROJECT_ROOT, { description, files: fileList });
|
|
1541
|
+
|
|
1542
|
+
return res.json({ ...result, api_version: "v2" });
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
return res.status(500).json({ error: err.message, api_version: "v2" });
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
app.post("/api/v2/gateway/review-diff", async (req, res) => {
|
|
1549
|
+
setCorsHeaders(res);
|
|
1550
|
+
|
|
1551
|
+
const clientIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
|
|
1552
|
+
if (!checkRateLimit(clientIp)) {
|
|
1553
|
+
return res.status(429).json({ error: "Rate limit exceeded", api_version: "v2" });
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const { description, files, diff, useLLM, options } = req.body || {};
|
|
1557
|
+
if (!description || typeof description !== "string") {
|
|
1558
|
+
return res.status(400).json({ error: "Missing 'description' field", api_version: "v2" });
|
|
1559
|
+
}
|
|
1560
|
+
if (!diff || typeof diff !== "string") {
|
|
1561
|
+
return res.status(400).json({ error: "Missing 'diff' field (provide git diff output)", api_version: "v2" });
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
try {
|
|
1565
|
+
ensureInit(PROJECT_ROOT);
|
|
1566
|
+
const fileList = Array.isArray(files) ? files : [];
|
|
1567
|
+
const result = useLLM
|
|
1568
|
+
? await reviewPatchDiffAsync(PROJECT_ROOT, { description, files: fileList, diff, options })
|
|
1569
|
+
: reviewPatchUnified(PROJECT_ROOT, { description, files: fileList, diff, options });
|
|
1570
|
+
|
|
1571
|
+
return res.json({ ...result, api_version: "v2" });
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
return res.status(500).json({ error: err.message, api_version: "v2" });
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
app.post("/api/v2/gateway/parse-diff", (req, res) => {
|
|
1578
|
+
setCorsHeaders(res);
|
|
1579
|
+
|
|
1580
|
+
const { diff } = req.body || {};
|
|
1581
|
+
if (!diff || typeof diff !== "string") {
|
|
1582
|
+
return res.status(400).json({ error: "Missing 'diff' field", api_version: "v2" });
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
try {
|
|
1586
|
+
const parsed = parseUnifiedDiff(diff);
|
|
1587
|
+
return res.json({ ...parsed, api_version: "v2" });
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
return res.status(500).json({ error: err.message, api_version: "v2" });
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1512
1593
|
// ========================================
|
|
1513
1594
|
// SSO ENDPOINTS (v3.5)
|
|
1514
1595
|
// ========================================
|
package/src/mcp/server.js
CHANGED
|
@@ -57,6 +57,12 @@ import {
|
|
|
57
57
|
mapLocksToFiles,
|
|
58
58
|
getModules,
|
|
59
59
|
getCriticalPaths,
|
|
60
|
+
reviewPatch,
|
|
61
|
+
reviewPatchAsync,
|
|
62
|
+
reviewPatchDiff,
|
|
63
|
+
reviewPatchDiffAsync,
|
|
64
|
+
reviewPatchUnified,
|
|
65
|
+
parseUnifiedDiff,
|
|
60
66
|
} from "../core/engine.js";
|
|
61
67
|
import { generateContext, generateContextPack } from "../core/context.js";
|
|
62
68
|
import {
|
|
@@ -114,7 +120,7 @@ const PROJECT_ROOT =
|
|
|
114
120
|
args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
115
121
|
|
|
116
122
|
// --- MCP Server ---
|
|
117
|
-
const VERSION = "5.
|
|
123
|
+
const VERSION = "5.2.0";
|
|
118
124
|
const AUTHOR = "Sandeep Roy";
|
|
119
125
|
|
|
120
126
|
const server = new McpServer(
|
|
@@ -1716,6 +1722,179 @@ server.tool(
|
|
|
1716
1722
|
}
|
|
1717
1723
|
);
|
|
1718
1724
|
|
|
1725
|
+
// --- Patch Gateway (v5.1) ---
|
|
1726
|
+
|
|
1727
|
+
server.tool(
|
|
1728
|
+
"speclock_review_patch",
|
|
1729
|
+
"Review a proposed code change and get an ALLOW/WARN/BLOCK verdict. Combines semantic conflict detection, lock-to-file mapping, blast radius analysis, and typed constraint awareness into a single risk assessment. The patch-time decision engine that gates every change.",
|
|
1730
|
+
{
|
|
1731
|
+
description: z.string().describe("What the change does (e.g. 'Add social login to auth page')"),
|
|
1732
|
+
files: z.array(z.string()).optional().default([]).describe("Files being changed (project-relative paths, e.g. ['src/auth/login.js'])"),
|
|
1733
|
+
useLLM: z.boolean().optional().default(false).describe("If true, uses LLM for enhanced conflict detection on ambiguous cases"),
|
|
1734
|
+
},
|
|
1735
|
+
async ({ description, files, useLLM }) => {
|
|
1736
|
+
const perm = requirePermission("speclock_review_patch");
|
|
1737
|
+
if (!perm.allowed) return { content: [{ type: "text", text: perm.error }], isError: true };
|
|
1738
|
+
|
|
1739
|
+
const result = useLLM
|
|
1740
|
+
? await reviewPatchAsync(PROJECT_ROOT, { description, files })
|
|
1741
|
+
: reviewPatch(PROJECT_ROOT, { description, files });
|
|
1742
|
+
|
|
1743
|
+
if (result.verdict === "ERROR") {
|
|
1744
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
const lines = [
|
|
1748
|
+
`Patch Gateway Verdict: ${result.verdict}`,
|
|
1749
|
+
`Risk Score: ${result.riskScore}/100`,
|
|
1750
|
+
`Source: ${result.source || "heuristic"}`,
|
|
1751
|
+
`Active Locks: ${result.lockCount}`,
|
|
1752
|
+
`Files Reviewed: ${result.fileCount}`,
|
|
1753
|
+
``,
|
|
1754
|
+
result.summary,
|
|
1755
|
+
];
|
|
1756
|
+
|
|
1757
|
+
if (result.reasons.length > 0) {
|
|
1758
|
+
lines.push(``, `Reasons:`);
|
|
1759
|
+
for (const r of result.reasons) {
|
|
1760
|
+
const icon = r.severity === "block" ? "BLOCK" : r.severity === "warn" ? "WARN" : "INFO";
|
|
1761
|
+
lines.push(` [${icon}] ${r.type}: "${r.lockText || r.details?.[0] || ""}" (confidence: ${r.confidence}%)`);
|
|
1762
|
+
if (r.details && r.details.length > 0) {
|
|
1763
|
+
r.details.forEach(d => lines.push(` - ${d}`));
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
if (result.blastRadius) {
|
|
1769
|
+
lines.push(``, `Blast Radius:`);
|
|
1770
|
+
for (const b of result.blastRadius.files) {
|
|
1771
|
+
lines.push(` ${b.file}: ${b.transitiveDependents} dependents (${b.impactPercent.toFixed(1)}% impact)`);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (result.lockFileOverlaps) {
|
|
1776
|
+
lines.push(``, `Lock-File Overlaps:`);
|
|
1777
|
+
for (const o of result.lockFileOverlaps) {
|
|
1778
|
+
lines.push(` Lock: "${o.lockText}" → Files: ${o.overlappingFiles.join(", ")}`);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
return {
|
|
1783
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1784
|
+
isError: result.verdict === "BLOCK",
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1789
|
+
// --- Diff-Native Patch Review (v5.2) ---
|
|
1790
|
+
|
|
1791
|
+
server.tool(
|
|
1792
|
+
"speclock_review_patch_diff",
|
|
1793
|
+
"Review a code change using actual diff content (git diff output). Analyzes interface breaks, protected symbol edits, dependency drift, schema changes, and public API impact. Returns ALLOW/WARN/BLOCK with per-signal scoring. When diff is provided, runs unified review (intent + diff merged, 35/65 weight).",
|
|
1794
|
+
{
|
|
1795
|
+
description: z.string().describe("What the change does"),
|
|
1796
|
+
files: z.array(z.string()).optional().default([]).describe("Files being changed"),
|
|
1797
|
+
diff: z.string().describe("Raw unified diff (git diff output)"),
|
|
1798
|
+
useLLM: z.boolean().optional().default(false).describe("Use LLM for enhanced detection"),
|
|
1799
|
+
options: z.object({
|
|
1800
|
+
includeSymbolAnalysis: z.boolean().optional().default(true),
|
|
1801
|
+
includeDependencyAnalysis: z.boolean().optional().default(true),
|
|
1802
|
+
includeSchemaAnalysis: z.boolean().optional().default(true),
|
|
1803
|
+
includeApiAnalysis: z.boolean().optional().default(true),
|
|
1804
|
+
}).optional().default({}),
|
|
1805
|
+
},
|
|
1806
|
+
async ({ description, files, diff, useLLM, options }) => {
|
|
1807
|
+
const perm = requirePermission("speclock_review_patch_diff");
|
|
1808
|
+
if (!perm.allowed) return { content: [{ type: "text", text: perm.error }], isError: true };
|
|
1809
|
+
|
|
1810
|
+
const result = useLLM
|
|
1811
|
+
? await reviewPatchDiffAsync(PROJECT_ROOT, { description, files, diff, options })
|
|
1812
|
+
: reviewPatchUnified(PROJECT_ROOT, { description, files, diff, options });
|
|
1813
|
+
|
|
1814
|
+
if (result.verdict === "ERROR") {
|
|
1815
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const lines = [
|
|
1819
|
+
`Patch Review Verdict: ${result.verdict}`,
|
|
1820
|
+
`Risk Score: ${result.riskScore}/100`,
|
|
1821
|
+
`Review Mode: ${result.reviewMode}`,
|
|
1822
|
+
`Source: ${result.source || "diff-native"}`,
|
|
1823
|
+
``,
|
|
1824
|
+
result.summary,
|
|
1825
|
+
];
|
|
1826
|
+
|
|
1827
|
+
if (result.intentVerdict) {
|
|
1828
|
+
lines.push(``, `Layer Breakdown:`, ` Intent: ${result.intentVerdict} (${result.intentRisk}/100)`, ` Diff: ${result.diffVerdict} (${result.diffRisk}/100)`);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (result.parsedDiff) {
|
|
1832
|
+
lines.push(``, `Diff Stats:`, ` Files: ${result.parsedDiff.filesChanged}`, ` Additions: +${result.parsedDiff.additions}`, ` Deletions: -${result.parsedDiff.deletions}`, ` Hunks: ${result.parsedDiff.hunks}`);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
if (result.signals) {
|
|
1836
|
+
const activeSignals = Object.entries(result.signals).filter(([_, s]) => s.score > 0);
|
|
1837
|
+
if (activeSignals.length > 0) {
|
|
1838
|
+
lines.push(``, `Active Signals:`);
|
|
1839
|
+
for (const [name, sig] of activeSignals) {
|
|
1840
|
+
lines.push(` ${name}: ${sig.score} pts`);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (result.reasons && result.reasons.length > 0) {
|
|
1846
|
+
lines.push(``, `Reasons:`);
|
|
1847
|
+
for (const r of result.reasons) {
|
|
1848
|
+
const icon = r.severity === "critical" ? "CRITICAL" : r.severity === "high" ? "HIGH" : r.severity === "medium" ? "MEDIUM" : "LOW";
|
|
1849
|
+
lines.push(` [${icon}] ${r.type}: ${r.message} (conf: ${typeof r.confidence === "number" ? (r.confidence > 1 ? r.confidence + "%" : Math.round(r.confidence * 100) + "%") : "N/A"})`);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (result.recommendation) {
|
|
1854
|
+
lines.push(``, `Recommendation: ${result.recommendation.action}`, ` ${result.recommendation.why}`);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
return {
|
|
1858
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1859
|
+
isError: result.verdict === "BLOCK",
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
);
|
|
1863
|
+
|
|
1864
|
+
server.tool(
|
|
1865
|
+
"speclock_parse_diff",
|
|
1866
|
+
"Parse a raw unified diff into structured changes. Shows imports added/removed, exports changed, symbols touched, route changes, and schema file detection. Useful for debugging, observability, and inspecting what SpecLock thinks changed.",
|
|
1867
|
+
{
|
|
1868
|
+
diff: z.string().describe("Raw unified diff (git diff output)"),
|
|
1869
|
+
},
|
|
1870
|
+
async ({ diff }) => {
|
|
1871
|
+
const parsed = parseUnifiedDiff(diff);
|
|
1872
|
+
|
|
1873
|
+
const lines = [
|
|
1874
|
+
`Parsed Diff:`,
|
|
1875
|
+
` Files Changed: ${parsed.stats.filesChanged}`,
|
|
1876
|
+
` Additions: +${parsed.stats.additions}`,
|
|
1877
|
+
` Deletions: -${parsed.stats.deletions}`,
|
|
1878
|
+
` Hunks: ${parsed.stats.hunks}`,
|
|
1879
|
+
];
|
|
1880
|
+
|
|
1881
|
+
for (const file of parsed.files) {
|
|
1882
|
+
lines.push(``, `File: ${file.path} (${file.language})`);
|
|
1883
|
+
lines.push(` +${file.additions} / -${file.deletions}`);
|
|
1884
|
+
if (file.importsAdded.length > 0) lines.push(` Imports Added: ${file.importsAdded.join(", ")}`);
|
|
1885
|
+
if (file.importsRemoved.length > 0) lines.push(` Imports Removed: ${file.importsRemoved.join(", ")}`);
|
|
1886
|
+
if (file.exportsAdded.length > 0) lines.push(` Exports Added: ${file.exportsAdded.map(e => e.symbol).join(", ")}`);
|
|
1887
|
+
if (file.exportsRemoved.length > 0) lines.push(` Exports Removed: ${file.exportsRemoved.map(e => e.symbol).join(", ")}`);
|
|
1888
|
+
if (file.exportsModified.length > 0) lines.push(` Exports Modified: ${file.exportsModified.map(e => e.symbol).join(", ")}`);
|
|
1889
|
+
if (file.symbolsTouched.length > 0) lines.push(` Symbols Touched: ${file.symbolsTouched.map(s => `${s.symbol} (${s.changeType})`).join(", ")}`);
|
|
1890
|
+
if (file.routeChanges.length > 0) lines.push(` Route Changes: ${file.routeChanges.map(r => `${r.method} ${r.path} [${r.changeType}]`).join(", ")}`);
|
|
1891
|
+
if (file.isSchemaFile) lines.push(` ** Schema/Migration File **`);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1895
|
+
}
|
|
1896
|
+
);
|
|
1897
|
+
|
|
1719
1898
|
// --- Smithery sandbox export ---
|
|
1720
1899
|
export default function createSandboxServer() {
|
|
1721
1900
|
return server;
|