@realtimex/folio 0.1.2
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/.env.example +20 -0
- package/README.md +63 -0
- package/api/server.ts +130 -0
- package/api/src/config/index.ts +96 -0
- package/api/src/middleware/auth.ts +128 -0
- package/api/src/middleware/errorHandler.ts +88 -0
- package/api/src/middleware/index.ts +4 -0
- package/api/src/middleware/rateLimit.ts +71 -0
- package/api/src/middleware/validation.ts +58 -0
- package/api/src/routes/accounts.ts +142 -0
- package/api/src/routes/baseline-config.ts +124 -0
- package/api/src/routes/chat.ts +154 -0
- package/api/src/routes/health.ts +61 -0
- package/api/src/routes/index.ts +35 -0
- package/api/src/routes/ingestions.ts +275 -0
- package/api/src/routes/migrate.ts +112 -0
- package/api/src/routes/policies.ts +121 -0
- package/api/src/routes/processing.ts +90 -0
- package/api/src/routes/rules.ts +11 -0
- package/api/src/routes/sdk.ts +100 -0
- package/api/src/routes/settings.ts +80 -0
- package/api/src/routes/setup.ts +389 -0
- package/api/src/routes/stats.ts +81 -0
- package/api/src/routes/tts.ts +190 -0
- package/api/src/services/BaselineConfigService.ts +208 -0
- package/api/src/services/ChatService.ts +204 -0
- package/api/src/services/GoogleDriveService.ts +331 -0
- package/api/src/services/GoogleSheetsService.ts +1107 -0
- package/api/src/services/IngestionService.ts +1187 -0
- package/api/src/services/ModelCapabilityService.ts +248 -0
- package/api/src/services/PolicyEngine.ts +1625 -0
- package/api/src/services/PolicyLearningService.ts +527 -0
- package/api/src/services/PolicyLoader.ts +249 -0
- package/api/src/services/RAGService.ts +391 -0
- package/api/src/services/SDKService.ts +249 -0
- package/api/src/services/supabase.ts +113 -0
- package/api/src/utils/Actuator.ts +284 -0
- package/api/src/utils/actions/ActionHandler.ts +34 -0
- package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
- package/api/src/utils/actions/AutoRenameAction.ts +58 -0
- package/api/src/utils/actions/CopyAction.ts +120 -0
- package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
- package/api/src/utils/actions/LogCsvAction.ts +48 -0
- package/api/src/utils/actions/NotifyAction.ts +39 -0
- package/api/src/utils/actions/RenameAction.ts +57 -0
- package/api/src/utils/actions/WebhookAction.ts +58 -0
- package/api/src/utils/actions/utils.ts +293 -0
- package/api/src/utils/llmResponse.ts +61 -0
- package/api/src/utils/logger.ts +67 -0
- package/bin/folio-deploy.js +12 -0
- package/bin/folio-setup.js +45 -0
- package/bin/folio.js +65 -0
- package/dist/api/server.js +106 -0
- package/dist/api/src/config/index.js +81 -0
- package/dist/api/src/middleware/auth.js +93 -0
- package/dist/api/src/middleware/errorHandler.js +73 -0
- package/dist/api/src/middleware/index.js +4 -0
- package/dist/api/src/middleware/rateLimit.js +43 -0
- package/dist/api/src/middleware/validation.js +54 -0
- package/dist/api/src/routes/accounts.js +110 -0
- package/dist/api/src/routes/baseline-config.js +91 -0
- package/dist/api/src/routes/chat.js +114 -0
- package/dist/api/src/routes/health.js +52 -0
- package/dist/api/src/routes/index.js +31 -0
- package/dist/api/src/routes/ingestions.js +207 -0
- package/dist/api/src/routes/migrate.js +91 -0
- package/dist/api/src/routes/policies.js +86 -0
- package/dist/api/src/routes/processing.js +75 -0
- package/dist/api/src/routes/rules.js +8 -0
- package/dist/api/src/routes/sdk.js +80 -0
- package/dist/api/src/routes/settings.js +68 -0
- package/dist/api/src/routes/setup.js +315 -0
- package/dist/api/src/routes/stats.js +62 -0
- package/dist/api/src/routes/tts.js +178 -0
- package/dist/api/src/services/BaselineConfigService.js +168 -0
- package/dist/api/src/services/ChatService.js +166 -0
- package/dist/api/src/services/GoogleDriveService.js +280 -0
- package/dist/api/src/services/GoogleSheetsService.js +795 -0
- package/dist/api/src/services/IngestionService.js +990 -0
- package/dist/api/src/services/ModelCapabilityService.js +179 -0
- package/dist/api/src/services/PolicyEngine.js +1353 -0
- package/dist/api/src/services/PolicyLearningService.js +397 -0
- package/dist/api/src/services/PolicyLoader.js +159 -0
- package/dist/api/src/services/RAGService.js +295 -0
- package/dist/api/src/services/SDKService.js +212 -0
- package/dist/api/src/services/supabase.js +72 -0
- package/dist/api/src/utils/Actuator.js +225 -0
- package/dist/api/src/utils/actions/ActionHandler.js +1 -0
- package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
- package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
- package/dist/api/src/utils/actions/CopyAction.js +112 -0
- package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
- package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
- package/dist/api/src/utils/actions/NotifyAction.js +32 -0
- package/dist/api/src/utils/actions/RenameAction.js +51 -0
- package/dist/api/src/utils/actions/WebhookAction.js +51 -0
- package/dist/api/src/utils/actions/utils.js +237 -0
- package/dist/api/src/utils/llmResponse.js +63 -0
- package/dist/api/src/utils/logger.js +51 -0
- package/dist/assets/index-DzN8-j-e.css +1 -0
- package/dist/assets/index-Uy-ai3Dh.js +113 -0
- package/dist/favicon.svg +31 -0
- package/dist/folio-logo.svg +46 -0
- package/dist/index.html +14 -0
- package/docs-dev/FPE-spec.md +196 -0
- package/docs-dev/folio-prd.md +47 -0
- package/docs-dev/foundation-checklist.md +30 -0
- package/docs-dev/hybrid-routing-architecture.md +205 -0
- package/docs-dev/ingestion-engine.md +69 -0
- package/docs-dev/port-from-email-automator.md +32 -0
- package/docs-dev/tech-spec.md +98 -0
- package/index.html +13 -0
- package/package.json +101 -0
- package/public/favicon.svg +31 -0
- package/public/folio-logo.svg +46 -0
- package/scripts/dev-task.mjs +51 -0
- package/scripts/get-latest-migration-timestamp.mjs +34 -0
- package/scripts/migrate.sh +91 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/config.toml +64 -0
- package/supabase/functions/_shared/auth.ts +35 -0
- package/supabase/functions/_shared/cors.ts +12 -0
- package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
- package/supabase/functions/api-v1-settings/index.ts +66 -0
- package/supabase/functions/setup/index.ts +91 -0
- package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
- package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
- package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
- package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
- package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
- package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
- package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
- package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
- package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
- package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
- package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
- package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
- package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
- package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
- package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
- package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
- package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
- package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
- package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
- package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
- package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
- package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
- package/supabase/migrations/29991231235959_test_migration.sql +0 -0
- package/supabase/templates/confirmation.html +76 -0
- package/supabase/templates/email-change.html +76 -0
- package/supabase/templates/invite.html +72 -0
- package/supabase/templates/magic-link.html +68 -0
- package/supabase/templates/recovery.html +82 -0
- package/tsconfig.api.json +16 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +146 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import cors from "cors";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { config, validateConfig } from "./src/config/index.js";
|
|
7
|
+
import { errorHandler } from "./src/middleware/errorHandler.js";
|
|
8
|
+
import { apiRateLimit } from "./src/middleware/rateLimit.js";
|
|
9
|
+
import routes from "./src/routes/index.js";
|
|
10
|
+
import { SDKService } from "./src/services/SDKService.js";
|
|
11
|
+
import { createLogger } from "./src/utils/logger.js";
|
|
12
|
+
const logger = createLogger("Server");
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
const configValidation = validateConfig();
|
|
16
|
+
if (!configValidation.valid) {
|
|
17
|
+
logger.warn("Configuration warnings", { errors: configValidation.errors });
|
|
18
|
+
}
|
|
19
|
+
SDKService.initialize();
|
|
20
|
+
const app = express();
|
|
21
|
+
app.use((req, res, next) => {
|
|
22
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
23
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
24
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
25
|
+
if (config.isProduction) {
|
|
26
|
+
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
27
|
+
}
|
|
28
|
+
next();
|
|
29
|
+
});
|
|
30
|
+
app.use(cors({
|
|
31
|
+
origin: config.isProduction ? config.security.corsOrigins : true,
|
|
32
|
+
credentials: true,
|
|
33
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
34
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Supabase-Url", "X-Supabase-Anon-Key"]
|
|
35
|
+
}));
|
|
36
|
+
app.use(express.json({ limit: "10mb" }));
|
|
37
|
+
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
|
38
|
+
app.use((req, res, next) => {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
res.on("finish", () => {
|
|
41
|
+
const duration = Date.now() - start;
|
|
42
|
+
logger.debug(`${req.method} ${req.path}`, { status: res.statusCode, durationMs: duration });
|
|
43
|
+
});
|
|
44
|
+
next();
|
|
45
|
+
});
|
|
46
|
+
app.use("/api", apiRateLimit);
|
|
47
|
+
app.use("/api", routes);
|
|
48
|
+
function getDistPath() {
|
|
49
|
+
if (process.env.ELECTRON_STATIC_PATH &&
|
|
50
|
+
existsSync(path.join(process.env.ELECTRON_STATIC_PATH, "index.html"))) {
|
|
51
|
+
return process.env.ELECTRON_STATIC_PATH;
|
|
52
|
+
}
|
|
53
|
+
const fromRoot = path.join(config.rootDir || process.cwd(), "dist");
|
|
54
|
+
if (existsSync(path.join(fromRoot, "index.html"))) {
|
|
55
|
+
return fromRoot;
|
|
56
|
+
}
|
|
57
|
+
let current = __dirname;
|
|
58
|
+
for (let i = 0; i < 4; i += 1) {
|
|
59
|
+
const candidate = path.join(current, "dist");
|
|
60
|
+
if (existsSync(path.join(candidate, "index.html"))) {
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
current = path.dirname(current);
|
|
64
|
+
}
|
|
65
|
+
return fromRoot;
|
|
66
|
+
}
|
|
67
|
+
const distUiPath = getDistPath();
|
|
68
|
+
if (existsSync(path.join(distUiPath, "index.html"))) {
|
|
69
|
+
logger.info("Serving static UI", { distUiPath });
|
|
70
|
+
app.use(express.static(distUiPath));
|
|
71
|
+
app.get(/.*/, (req, res, next) => {
|
|
72
|
+
if (req.path.startsWith("/api")) {
|
|
73
|
+
return next();
|
|
74
|
+
}
|
|
75
|
+
res.sendFile(path.join(distUiPath, "index.html"), (error) => {
|
|
76
|
+
if (error) {
|
|
77
|
+
res.status(404).json({
|
|
78
|
+
success: false,
|
|
79
|
+
error: {
|
|
80
|
+
code: "NOT_FOUND",
|
|
81
|
+
message: "Frontend not built or route not found"
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
logger.warn("No dist/index.html found. API will run without bundled UI.", { distUiPath });
|
|
90
|
+
}
|
|
91
|
+
app.use(errorHandler);
|
|
92
|
+
const server = app.listen(config.port, () => {
|
|
93
|
+
logger.info("Folio API started", {
|
|
94
|
+
port: config.port,
|
|
95
|
+
environment: config.nodeEnv,
|
|
96
|
+
packageRoot: config.packageRoot
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
function shutdown(signal) {
|
|
100
|
+
logger.info(`Shutting down (${signal})`);
|
|
101
|
+
server.close(() => {
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
106
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path, { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = dirname(__filename);
|
|
7
|
+
function findPackageRoot(startDir) {
|
|
8
|
+
let current = startDir;
|
|
9
|
+
while (current !== path.parse(current).root) {
|
|
10
|
+
if (existsSync(join(current, "package.json")) && existsSync(join(current, "bin"))) {
|
|
11
|
+
return current;
|
|
12
|
+
}
|
|
13
|
+
current = dirname(current);
|
|
14
|
+
}
|
|
15
|
+
return process.cwd();
|
|
16
|
+
}
|
|
17
|
+
const packageRoot = findPackageRoot(__dirname);
|
|
18
|
+
function loadEnvironment() {
|
|
19
|
+
const cwdEnv = join(process.cwd(), ".env");
|
|
20
|
+
const rootEnv = join(packageRoot, ".env");
|
|
21
|
+
if (existsSync(cwdEnv)) {
|
|
22
|
+
dotenv.config({ path: cwdEnv, override: true });
|
|
23
|
+
}
|
|
24
|
+
else if (existsSync(rootEnv)) {
|
|
25
|
+
dotenv.config({ path: rootEnv, override: true });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
dotenv.config();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
loadEnvironment();
|
|
32
|
+
function parseArgs(args) {
|
|
33
|
+
const portIndex = args.indexOf("--port");
|
|
34
|
+
let port = null;
|
|
35
|
+
if (portIndex !== -1 && args[portIndex + 1]) {
|
|
36
|
+
const candidate = Number.parseInt(args[portIndex + 1], 10);
|
|
37
|
+
if (!Number.isNaN(candidate) && candidate > 0 && candidate < 65536) {
|
|
38
|
+
port = candidate;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
port,
|
|
43
|
+
noUi: args.includes("--no-ui")
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const cliArgs = parseArgs(process.argv.slice(2));
|
|
47
|
+
export const config = {
|
|
48
|
+
packageRoot,
|
|
49
|
+
port: cliArgs.port || (process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3006),
|
|
50
|
+
noUi: cliArgs.noUi,
|
|
51
|
+
nodeEnv: process.env.NODE_ENV || "development",
|
|
52
|
+
isProduction: process.env.NODE_ENV === "production",
|
|
53
|
+
rootDir: packageRoot,
|
|
54
|
+
scriptsDir: join(packageRoot, "scripts"),
|
|
55
|
+
supabase: {
|
|
56
|
+
url: process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || "",
|
|
57
|
+
anonKey: process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || "",
|
|
58
|
+
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || ""
|
|
59
|
+
},
|
|
60
|
+
security: {
|
|
61
|
+
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production",
|
|
62
|
+
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY || "",
|
|
63
|
+
corsOrigins: process.env.CORS_ORIGINS?.split(",") || ["http://localhost:5173", "http://localhost:3006"],
|
|
64
|
+
rateLimitWindowMs: 60 * 1000,
|
|
65
|
+
rateLimitMax: 60,
|
|
66
|
+
disableAuth: process.env.DISABLE_AUTH === "true"
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
export function validateConfig() {
|
|
70
|
+
const errors = [];
|
|
71
|
+
if (config.isProduction && config.security.jwtSecret === "dev-secret-change-in-production") {
|
|
72
|
+
errors.push("JWT_SECRET must be set in production");
|
|
73
|
+
}
|
|
74
|
+
if (config.isProduction && !config.security.encryptionKey) {
|
|
75
|
+
errors.push("TOKEN_ENCRYPTION_KEY must be set in production");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
valid: errors.length === 0,
|
|
79
|
+
errors
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createClient } from "@supabase/supabase-js";
|
|
2
|
+
import { config } from "../config/index.js";
|
|
3
|
+
import { getServerSupabase, getSupabaseConfigFromHeaders } from "../services/supabase.js";
|
|
4
|
+
import { Logger, createLogger } from "../utils/logger.js";
|
|
5
|
+
import { AuthenticationError, AuthorizationError } from "./errorHandler.js";
|
|
6
|
+
const logger = createLogger("AuthMiddleware");
|
|
7
|
+
function resolveSupabaseConfig(req) {
|
|
8
|
+
const headerConfig = getSupabaseConfigFromHeaders(req.headers);
|
|
9
|
+
const envUrl = config.supabase.url;
|
|
10
|
+
const envKey = config.supabase.anonKey;
|
|
11
|
+
const envIsValid = envUrl.startsWith("http://") || envUrl.startsWith("https://");
|
|
12
|
+
if (envIsValid && envKey) {
|
|
13
|
+
return { url: envUrl, anonKey: envKey };
|
|
14
|
+
}
|
|
15
|
+
return headerConfig;
|
|
16
|
+
}
|
|
17
|
+
export async function authMiddleware(req, _res, next) {
|
|
18
|
+
try {
|
|
19
|
+
const supabaseConfig = resolveSupabaseConfig(req);
|
|
20
|
+
if (!supabaseConfig) {
|
|
21
|
+
throw new AuthenticationError("Supabase not configured");
|
|
22
|
+
}
|
|
23
|
+
if (config.security.disableAuth && !config.isProduction) {
|
|
24
|
+
const user = {
|
|
25
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
26
|
+
email: "dev@folio.local",
|
|
27
|
+
user_metadata: {},
|
|
28
|
+
app_metadata: {},
|
|
29
|
+
aud: "authenticated",
|
|
30
|
+
created_at: new Date().toISOString()
|
|
31
|
+
};
|
|
32
|
+
const supabase = getServerSupabase() ||
|
|
33
|
+
createClient(supabaseConfig.url, supabaseConfig.anonKey, {
|
|
34
|
+
auth: {
|
|
35
|
+
autoRefreshToken: false,
|
|
36
|
+
persistSession: false
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
req.user = user;
|
|
40
|
+
req.supabase = supabase;
|
|
41
|
+
Logger.setPersistence(supabase, user.id);
|
|
42
|
+
return next();
|
|
43
|
+
}
|
|
44
|
+
const authHeader = req.headers.authorization;
|
|
45
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
46
|
+
throw new AuthenticationError("Missing bearer token");
|
|
47
|
+
}
|
|
48
|
+
const token = authHeader.slice(7);
|
|
49
|
+
const supabase = createClient(supabaseConfig.url, supabaseConfig.anonKey, {
|
|
50
|
+
global: {
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${token}`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const { data: { user }, error } = await supabase.auth.getUser(token);
|
|
57
|
+
if (error || !user) {
|
|
58
|
+
throw new AuthenticationError("Invalid or expired token");
|
|
59
|
+
}
|
|
60
|
+
req.user = user;
|
|
61
|
+
req.supabase = supabase;
|
|
62
|
+
Logger.setPersistence(supabase, user.id);
|
|
63
|
+
next();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
logger.error("Auth middleware error", {
|
|
67
|
+
error: error instanceof Error ? error.message : String(error)
|
|
68
|
+
});
|
|
69
|
+
next(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function optionalAuth(req, res, next) {
|
|
73
|
+
const authHeader = req.headers.authorization;
|
|
74
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
75
|
+
next();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
void authMiddleware(req, res, next);
|
|
79
|
+
}
|
|
80
|
+
export function requireRole(roles) {
|
|
81
|
+
return (req, _res, next) => {
|
|
82
|
+
if (!req.user) {
|
|
83
|
+
next(new AuthenticationError());
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const role = req.user.user_metadata?.role || "user";
|
|
87
|
+
if (!roles.includes(role)) {
|
|
88
|
+
next(new AuthorizationError(`Requires one of: ${roles.join(", ")}`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
next();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { config } from "../config/index.js";
|
|
2
|
+
import { createLogger } from "../utils/logger.js";
|
|
3
|
+
const logger = createLogger("ErrorHandler");
|
|
4
|
+
export class AppError extends Error {
|
|
5
|
+
statusCode;
|
|
6
|
+
isOperational;
|
|
7
|
+
code;
|
|
8
|
+
constructor(message, statusCode = 500, code) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.isOperational = true;
|
|
12
|
+
this.code = code;
|
|
13
|
+
Error.captureStackTrace(this, this.constructor);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class ValidationError extends AppError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message, 400, "VALIDATION_ERROR");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class AuthenticationError extends AppError {
|
|
22
|
+
constructor(message = "Authentication required") {
|
|
23
|
+
super(message, 401, "AUTHENTICATION_ERROR");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class AuthorizationError extends AppError {
|
|
27
|
+
constructor(message = "Insufficient permissions") {
|
|
28
|
+
super(message, 403, "AUTHORIZATION_ERROR");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class NotFoundError extends AppError {
|
|
32
|
+
constructor(resource = "Resource") {
|
|
33
|
+
super(`${resource} not found`, 404, "NOT_FOUND");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class RateLimitError extends AppError {
|
|
37
|
+
constructor() {
|
|
38
|
+
super("Too many requests", 429, "RATE_LIMIT_EXCEEDED");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function errorHandler(err, req, res, _next) {
|
|
42
|
+
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
|
43
|
+
const code = err instanceof AppError ? err.code : "INTERNAL_ERROR";
|
|
44
|
+
if (statusCode >= 500) {
|
|
45
|
+
logger.error("Server error", {
|
|
46
|
+
method: req.method,
|
|
47
|
+
path: req.path,
|
|
48
|
+
statusCode,
|
|
49
|
+
message: err.message
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
logger.warn("Client error", {
|
|
54
|
+
method: req.method,
|
|
55
|
+
path: req.path,
|
|
56
|
+
statusCode,
|
|
57
|
+
message: err.message
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
res.status(statusCode).json({
|
|
61
|
+
success: false,
|
|
62
|
+
error: {
|
|
63
|
+
code,
|
|
64
|
+
message: !config.isProduction ? err.message : statusCode >= 500 ? "Unexpected error" : err.message,
|
|
65
|
+
...(config.isProduction ? {} : { stack: err.stack })
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function asyncHandler(fn) {
|
|
70
|
+
return (req, res, next) => {
|
|
71
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { config } from "../config/index.js";
|
|
2
|
+
import { RateLimitError } from "./errorHandler.js";
|
|
3
|
+
const rateLimitStore = new Map();
|
|
4
|
+
setInterval(() => {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
7
|
+
if (entry.resetAt < now) {
|
|
8
|
+
rateLimitStore.delete(key);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}, 60_000);
|
|
12
|
+
export function rateLimit(options = {}) {
|
|
13
|
+
const { windowMs = config.security.rateLimitWindowMs, max = config.security.rateLimitMax, keyGenerator = (req) => req.ip || String(req.headers["x-forwarded-for"] || "unknown"), skip = () => false } = options;
|
|
14
|
+
return (req, res, next) => {
|
|
15
|
+
if (skip(req)) {
|
|
16
|
+
return next();
|
|
17
|
+
}
|
|
18
|
+
const key = keyGenerator(req);
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
let entry = rateLimitStore.get(key);
|
|
21
|
+
if (!entry || entry.resetAt < now) {
|
|
22
|
+
entry = {
|
|
23
|
+
count: 1,
|
|
24
|
+
resetAt: now + windowMs
|
|
25
|
+
};
|
|
26
|
+
rateLimitStore.set(key, entry);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
entry.count += 1;
|
|
30
|
+
}
|
|
31
|
+
res.setHeader("X-RateLimit-Limit", max);
|
|
32
|
+
res.setHeader("X-RateLimit-Remaining", Math.max(0, max - entry.count));
|
|
33
|
+
res.setHeader("X-RateLimit-Reset", Math.ceil(entry.resetAt / 1000));
|
|
34
|
+
if (entry.count > max) {
|
|
35
|
+
return next(new RateLimitError());
|
|
36
|
+
}
|
|
37
|
+
next();
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export const apiRateLimit = rateLimit({
|
|
41
|
+
windowMs: 60_000,
|
|
42
|
+
max: 60
|
|
43
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z, ZodError } from "zod";
|
|
2
|
+
import { ValidationError } from "./errorHandler.js";
|
|
3
|
+
export function validateBody(schema) {
|
|
4
|
+
return (req, _res, next) => {
|
|
5
|
+
try {
|
|
6
|
+
req.body = schema.parse(req.body);
|
|
7
|
+
next();
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error instanceof ZodError) {
|
|
11
|
+
const message = error.errors.map((item) => `${item.path.join(".")}: ${item.message}`).join(", ");
|
|
12
|
+
next(new ValidationError(message));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
next(error);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function validateQuery(schema) {
|
|
20
|
+
return (req, _res, next) => {
|
|
21
|
+
try {
|
|
22
|
+
req.query = schema.parse(req.query);
|
|
23
|
+
next();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof ZodError) {
|
|
27
|
+
const message = error.errors.map((item) => `${item.path.join(".")}: ${item.message}`).join(", ");
|
|
28
|
+
next(new ValidationError(message));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
next(error);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const schemas = {
|
|
36
|
+
testSupabase: z.object({
|
|
37
|
+
url: z.string().url(),
|
|
38
|
+
anonKey: z.string().min(10)
|
|
39
|
+
}),
|
|
40
|
+
autoProvision: z.object({
|
|
41
|
+
orgId: z.string().min(1),
|
|
42
|
+
projectName: z.string().min(1).max(64).optional(),
|
|
43
|
+
region: z.string().min(1).max(64).optional()
|
|
44
|
+
}),
|
|
45
|
+
migrate: z.object({
|
|
46
|
+
projectRef: z.string().min(1),
|
|
47
|
+
accessToken: z.string().min(1),
|
|
48
|
+
anonKey: z.string().min(1).optional()
|
|
49
|
+
}),
|
|
50
|
+
dispatchProcessing: z.object({
|
|
51
|
+
source_type: z.string().min(1),
|
|
52
|
+
payload: z.record(z.unknown())
|
|
53
|
+
})
|
|
54
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { asyncHandler } from "../middleware/errorHandler.js";
|
|
4
|
+
import { optionalAuth } from "../middleware/auth.js";
|
|
5
|
+
const router = Router();
|
|
6
|
+
router.use(optionalAuth);
|
|
7
|
+
// GET /api/accounts
|
|
8
|
+
router.get("/", asyncHandler(async (req, res) => {
|
|
9
|
+
if (!req.user || !req.supabase) {
|
|
10
|
+
res.status(401).json({ error: "Authentication required" });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const { data, error } = await req.supabase
|
|
14
|
+
.from("integrations")
|
|
15
|
+
.select("id, provider, is_enabled, created_at, updated_at")
|
|
16
|
+
.eq("user_id", req.user.id);
|
|
17
|
+
if (error) {
|
|
18
|
+
res.status(500).json({ error: error.message });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Map to expected frontend format
|
|
22
|
+
const accounts = data?.map(integration => ({
|
|
23
|
+
id: integration.id,
|
|
24
|
+
email_address: integration.provider, // Just for UI display
|
|
25
|
+
provider: integration.provider,
|
|
26
|
+
is_connected: integration.is_enabled,
|
|
27
|
+
sync_enabled: integration.is_enabled,
|
|
28
|
+
created_at: integration.created_at,
|
|
29
|
+
updated_at: integration.updated_at
|
|
30
|
+
})) || [];
|
|
31
|
+
res.json({ accounts });
|
|
32
|
+
}));
|
|
33
|
+
// POST /api/accounts/google-drive/auth-url
|
|
34
|
+
router.post("/google-drive/auth-url", asyncHandler(async (req, res) => {
|
|
35
|
+
const { clientId } = req.body;
|
|
36
|
+
if (!clientId) {
|
|
37
|
+
res.status(400).json({ error: "Missing clientId" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const redirectUri = "urn:ietf:wg:oauth:2.0:oob"; // Desktop/local app flow
|
|
41
|
+
// We request drive (full access) to allow downloading and uploading (moving) files
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
client_id: clientId,
|
|
44
|
+
redirect_uri: redirectUri,
|
|
45
|
+
response_type: "code",
|
|
46
|
+
scope: "https://www.googleapis.com/auth/drive",
|
|
47
|
+
access_type: "offline",
|
|
48
|
+
prompt: "consent",
|
|
49
|
+
});
|
|
50
|
+
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
51
|
+
res.json({ authUrl });
|
|
52
|
+
}));
|
|
53
|
+
// POST /api/accounts/google-drive/connect
|
|
54
|
+
router.post("/google-drive/connect", asyncHandler(async (req, res) => {
|
|
55
|
+
if (!req.supabase || !req.user) {
|
|
56
|
+
res.status(401).json({ error: "Authentication required" });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const { authCode, clientId, clientSecret } = req.body;
|
|
60
|
+
if (!authCode || !clientId || !clientSecret) {
|
|
61
|
+
res.status(400).json({ error: "Missing authCode, clientId, or clientSecret" });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
// Exchange code for tokens
|
|
66
|
+
const tokenResponse = await axios.post("https://oauth2.googleapis.com/token", null, {
|
|
67
|
+
params: {
|
|
68
|
+
client_id: clientId,
|
|
69
|
+
client_secret: clientSecret,
|
|
70
|
+
code: authCode,
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const { access_token, refresh_token, expires_in } = tokenResponse.data;
|
|
76
|
+
const credentials = {
|
|
77
|
+
access_token,
|
|
78
|
+
refresh_token,
|
|
79
|
+
expires_at: Date.now() + expires_in * 1000,
|
|
80
|
+
client_id: clientId,
|
|
81
|
+
client_secret: clientSecret
|
|
82
|
+
};
|
|
83
|
+
// Save to integrations
|
|
84
|
+
const { data: integration, error } = await req.supabase
|
|
85
|
+
.from("integrations")
|
|
86
|
+
.upsert({
|
|
87
|
+
user_id: req.user.id,
|
|
88
|
+
provider: "google_drive",
|
|
89
|
+
credentials,
|
|
90
|
+
is_enabled: true
|
|
91
|
+
}, { onConflict: "user_id,provider" })
|
|
92
|
+
.select()
|
|
93
|
+
.single();
|
|
94
|
+
if (error) {
|
|
95
|
+
throw new Error(`Database error: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
res.json({ success: true, account: integration });
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const errorMessage = error.response?.data?.error_description
|
|
102
|
+
|| error.response?.data?.error
|
|
103
|
+
|| error.message
|
|
104
|
+
|| "Failed to connect Google Drive";
|
|
105
|
+
res.status(500).json({
|
|
106
|
+
error: errorMessage
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
export default router;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { asyncHandler } from "../middleware/errorHandler.js";
|
|
3
|
+
import { optionalAuth } from "../middleware/auth.js";
|
|
4
|
+
import { BaselineConfigService, DEFAULT_BASELINE_FIELDS } from "../services/BaselineConfigService.js";
|
|
5
|
+
import { PolicyEngine } from "../services/PolicyEngine.js";
|
|
6
|
+
const router = Router();
|
|
7
|
+
router.use(optionalAuth);
|
|
8
|
+
// GET /api/baseline-config
|
|
9
|
+
// Returns the active config. If none exists, returns the built-in defaults
|
|
10
|
+
// with id: null so the UI can distinguish "never saved" from "saved and active".
|
|
11
|
+
router.get("/", asyncHandler(async (req, res) => {
|
|
12
|
+
if (!req.supabase || !req.user) {
|
|
13
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const config = await BaselineConfigService.getActive(req.supabase, req.user.id);
|
|
17
|
+
res.json({
|
|
18
|
+
success: true,
|
|
19
|
+
config,
|
|
20
|
+
defaults: DEFAULT_BASELINE_FIELDS,
|
|
21
|
+
});
|
|
22
|
+
}));
|
|
23
|
+
// GET /api/baseline-config/history
|
|
24
|
+
// Returns all saved versions for the user, newest first.
|
|
25
|
+
router.get("/history", asyncHandler(async (req, res) => {
|
|
26
|
+
if (!req.supabase || !req.user) {
|
|
27
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const history = await BaselineConfigService.list(req.supabase, req.user.id);
|
|
31
|
+
res.json({ success: true, history });
|
|
32
|
+
}));
|
|
33
|
+
// POST /api/baseline-config
|
|
34
|
+
// Save a new version. Body: { context?, fields[], activate? }
|
|
35
|
+
// Always creates a new row — never mutates an existing version.
|
|
36
|
+
router.post("/", asyncHandler(async (req, res) => {
|
|
37
|
+
if (!req.supabase || !req.user) {
|
|
38
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const { context, fields, activate = true } = req.body;
|
|
42
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
43
|
+
res.status(400).json({ success: false, error: "fields array is required and must not be empty" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const config = await BaselineConfigService.save(req.supabase, req.user.id, { context, fields }, activate);
|
|
47
|
+
res.status(201).json({ success: true, config });
|
|
48
|
+
}));
|
|
49
|
+
// POST /api/baseline-config/:id/activate
|
|
50
|
+
// Activate a previously saved version.
|
|
51
|
+
router.post("/:id/activate", asyncHandler(async (req, res) => {
|
|
52
|
+
if (!req.supabase || !req.user) {
|
|
53
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const ok = await BaselineConfigService.activate(req.supabase, req.user.id, req.params.id);
|
|
57
|
+
if (!ok) {
|
|
58
|
+
res.status(404).json({ success: false, error: "Config version not found" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res.json({ success: true });
|
|
62
|
+
}));
|
|
63
|
+
// POST /api/baseline-config/suggest
|
|
64
|
+
// Body: { description, provider?, model? }
|
|
65
|
+
// Returns a draft { context, fields[] } for the user to review before saving.
|
|
66
|
+
router.post("/suggest", asyncHandler(async (req, res) => {
|
|
67
|
+
if (!req.supabase || !req.user) {
|
|
68
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const { description, provider, model } = req.body;
|
|
72
|
+
if (!description || typeof description !== "string") {
|
|
73
|
+
res.status(400).json({ success: false, error: "description is required" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Pass current active fields so the LLM avoids duplicating them
|
|
77
|
+
const activeConfig = await BaselineConfigService.getActive(req.supabase, req.user.id);
|
|
78
|
+
const currentFields = activeConfig?.fields ?? DEFAULT_BASELINE_FIELDS;
|
|
79
|
+
const result = await PolicyEngine.suggestBaseline(description, currentFields, {
|
|
80
|
+
provider,
|
|
81
|
+
model,
|
|
82
|
+
userId: req.user.id,
|
|
83
|
+
supabase: req.supabase,
|
|
84
|
+
});
|
|
85
|
+
if (!result.suggestion) {
|
|
86
|
+
res.status(503).json({ success: false, error: result.error ?? "Suggestion failed. SDK may be unavailable." });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
res.json({ success: true, suggestion: result.suggestion });
|
|
90
|
+
}));
|
|
91
|
+
export default router;
|