feat: add workspace isolation and administration UI
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
// .next/static/ (NOT inside standalone — must be copied)
|
||||
// We copy everything into dist/ so the published package ships one folder that
|
||||
// `node dist/server.js` can run.
|
||||
import { cp, rm, readdir } from "fs/promises";
|
||||
import { cp, mkdir, readdir, rm, writeFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
const STANDALONE = ".next/standalone";
|
||||
@@ -26,6 +26,37 @@ if (existsSync(PUBLIC)) {
|
||||
await cp(PUBLIC, `${OUT}/public`, { recursive: true });
|
||||
}
|
||||
|
||||
// Turbopack represents serverExternalPackages with generated package aliases
|
||||
// under .next/node_modules. The standalone tree uses absolute symlinks for
|
||||
// those aliases, which npm omits from tarballs. Replace better-sqlite3 aliases
|
||||
// with portable shims and let npm install the real native dependency for the
|
||||
// target platform from the package root.
|
||||
const NEXT_NODE_MODULES = `${OUT}/.next/node_modules`;
|
||||
if (existsSync(NEXT_NODE_MODULES)) {
|
||||
for (const entry of await readdir(NEXT_NODE_MODULES)) {
|
||||
if (!entry.startsWith("better-sqlite3-")) continue;
|
||||
const alias = `${NEXT_NODE_MODULES}/${entry}`;
|
||||
await rm(alias, { recursive: true, force: true });
|
||||
await mkdir(alias, { recursive: true });
|
||||
await writeFile(
|
||||
`${alias}/package.json`,
|
||||
`${JSON.stringify(
|
||||
{ name: entry, private: true, main: "index.js" },
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
await writeFile(
|
||||
`${alias}/index.js`,
|
||||
'module.exports = require("better-sqlite3");\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
await rm(`${OUT}/node_modules/better-sqlite3`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Next copies the whole project root into the standalone bundle. Prune it down
|
||||
// to just the runtime essentials (drops src/, configs, and local notes so
|
||||
// they never get published).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const aliasesRoot = path.resolve("dist/.next/node_modules");
|
||||
const aliases = (await readdir(aliasesRoot)).filter((entry) =>
|
||||
entry.startsWith("better-sqlite3-")
|
||||
);
|
||||
if (aliases.length === 0) {
|
||||
throw new Error("standalone build contains no better-sqlite3 external alias");
|
||||
}
|
||||
if (existsSync(path.resolve("dist/node_modules/better-sqlite3"))) {
|
||||
throw new Error("standalone build contains a build-platform SQLite binary");
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
for (const alias of aliases) {
|
||||
const aliasPath = path.join(aliasesRoot, alias);
|
||||
if ((await lstat(aliasPath)).isSymbolicLink()) {
|
||||
throw new Error(`${alias} is an npm-incompatible symbolic link`);
|
||||
}
|
||||
const Database = require(aliasPath);
|
||||
const database = new Database(":memory:");
|
||||
try {
|
||||
database.prepare("SELECT 1 value").get();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Verified ${aliases.length} portable better-sqlite3 alias(es).`);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve("src");
|
||||
const forbiddenSdk = /\b(?:new\s+Client|useClient|ClientProvider|useStream)\b/;
|
||||
const forbiddenBrowserConfig =
|
||||
/\b(?:deploymentUrl|langsmithApiKey|NEXT_PUBLIC_LANGSMITH_API_KEY)\b/;
|
||||
const workspaceRoutes = [
|
||||
"app/api/workspace/route.ts",
|
||||
"app/api/workspace/file/route.ts",
|
||||
"app/api/workspace/upload/route.ts",
|
||||
"app/api/workspace/download/route.ts",
|
||||
];
|
||||
|
||||
async function sourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await sourceFiles(absolute)));
|
||||
else if (/\.(?:ts|tsx)$/.test(entry.name)) files.push(absolute);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of await sourceFiles(root)) {
|
||||
const relative = path.relative(root, file).replaceAll(path.sep, "/");
|
||||
// Server-side BFF clients are intentionally the only LangGraph SDK clients.
|
||||
if (relative.startsWith("lib/server/") || relative.startsWith("app/api/")) continue;
|
||||
const source = await readFile(file, "utf8");
|
||||
if (forbiddenSdk.test(source)) violations.push(`${relative} (LangGraph SDK)`);
|
||||
if (forbiddenBrowserConfig.test(source)) {
|
||||
violations.push(`${relative} (deployment credential or URL)`);
|
||||
}
|
||||
}
|
||||
if (violations.length) {
|
||||
throw new Error(`Browser BFF boundary violation: ${violations.join(", ")}`);
|
||||
}
|
||||
|
||||
for (const route of workspaceRoutes) {
|
||||
const source = await readFile(path.join(root, route), "utf8");
|
||||
if (!source.includes("resolveConversationWorkspace")) {
|
||||
throw new Error(`Workspace route is not scope-resolved: ${route}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Verified browser BFF boundary and conversation-scoped workspace routes.");
|
||||
Reference in New Issue
Block a user