@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.
Files changed (163) hide show
  1. package/.env.example +20 -0
  2. package/README.md +63 -0
  3. package/api/server.ts +130 -0
  4. package/api/src/config/index.ts +96 -0
  5. package/api/src/middleware/auth.ts +128 -0
  6. package/api/src/middleware/errorHandler.ts +88 -0
  7. package/api/src/middleware/index.ts +4 -0
  8. package/api/src/middleware/rateLimit.ts +71 -0
  9. package/api/src/middleware/validation.ts +58 -0
  10. package/api/src/routes/accounts.ts +142 -0
  11. package/api/src/routes/baseline-config.ts +124 -0
  12. package/api/src/routes/chat.ts +154 -0
  13. package/api/src/routes/health.ts +61 -0
  14. package/api/src/routes/index.ts +35 -0
  15. package/api/src/routes/ingestions.ts +275 -0
  16. package/api/src/routes/migrate.ts +112 -0
  17. package/api/src/routes/policies.ts +121 -0
  18. package/api/src/routes/processing.ts +90 -0
  19. package/api/src/routes/rules.ts +11 -0
  20. package/api/src/routes/sdk.ts +100 -0
  21. package/api/src/routes/settings.ts +80 -0
  22. package/api/src/routes/setup.ts +389 -0
  23. package/api/src/routes/stats.ts +81 -0
  24. package/api/src/routes/tts.ts +190 -0
  25. package/api/src/services/BaselineConfigService.ts +208 -0
  26. package/api/src/services/ChatService.ts +204 -0
  27. package/api/src/services/GoogleDriveService.ts +331 -0
  28. package/api/src/services/GoogleSheetsService.ts +1107 -0
  29. package/api/src/services/IngestionService.ts +1187 -0
  30. package/api/src/services/ModelCapabilityService.ts +248 -0
  31. package/api/src/services/PolicyEngine.ts +1625 -0
  32. package/api/src/services/PolicyLearningService.ts +527 -0
  33. package/api/src/services/PolicyLoader.ts +249 -0
  34. package/api/src/services/RAGService.ts +391 -0
  35. package/api/src/services/SDKService.ts +249 -0
  36. package/api/src/services/supabase.ts +113 -0
  37. package/api/src/utils/Actuator.ts +284 -0
  38. package/api/src/utils/actions/ActionHandler.ts +34 -0
  39. package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
  40. package/api/src/utils/actions/AutoRenameAction.ts +58 -0
  41. package/api/src/utils/actions/CopyAction.ts +120 -0
  42. package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
  43. package/api/src/utils/actions/LogCsvAction.ts +48 -0
  44. package/api/src/utils/actions/NotifyAction.ts +39 -0
  45. package/api/src/utils/actions/RenameAction.ts +57 -0
  46. package/api/src/utils/actions/WebhookAction.ts +58 -0
  47. package/api/src/utils/actions/utils.ts +293 -0
  48. package/api/src/utils/llmResponse.ts +61 -0
  49. package/api/src/utils/logger.ts +67 -0
  50. package/bin/folio-deploy.js +12 -0
  51. package/bin/folio-setup.js +45 -0
  52. package/bin/folio.js +65 -0
  53. package/dist/api/server.js +106 -0
  54. package/dist/api/src/config/index.js +81 -0
  55. package/dist/api/src/middleware/auth.js +93 -0
  56. package/dist/api/src/middleware/errorHandler.js +73 -0
  57. package/dist/api/src/middleware/index.js +4 -0
  58. package/dist/api/src/middleware/rateLimit.js +43 -0
  59. package/dist/api/src/middleware/validation.js +54 -0
  60. package/dist/api/src/routes/accounts.js +110 -0
  61. package/dist/api/src/routes/baseline-config.js +91 -0
  62. package/dist/api/src/routes/chat.js +114 -0
  63. package/dist/api/src/routes/health.js +52 -0
  64. package/dist/api/src/routes/index.js +31 -0
  65. package/dist/api/src/routes/ingestions.js +207 -0
  66. package/dist/api/src/routes/migrate.js +91 -0
  67. package/dist/api/src/routes/policies.js +86 -0
  68. package/dist/api/src/routes/processing.js +75 -0
  69. package/dist/api/src/routes/rules.js +8 -0
  70. package/dist/api/src/routes/sdk.js +80 -0
  71. package/dist/api/src/routes/settings.js +68 -0
  72. package/dist/api/src/routes/setup.js +315 -0
  73. package/dist/api/src/routes/stats.js +62 -0
  74. package/dist/api/src/routes/tts.js +178 -0
  75. package/dist/api/src/services/BaselineConfigService.js +168 -0
  76. package/dist/api/src/services/ChatService.js +166 -0
  77. package/dist/api/src/services/GoogleDriveService.js +280 -0
  78. package/dist/api/src/services/GoogleSheetsService.js +795 -0
  79. package/dist/api/src/services/IngestionService.js +990 -0
  80. package/dist/api/src/services/ModelCapabilityService.js +179 -0
  81. package/dist/api/src/services/PolicyEngine.js +1353 -0
  82. package/dist/api/src/services/PolicyLearningService.js +397 -0
  83. package/dist/api/src/services/PolicyLoader.js +159 -0
  84. package/dist/api/src/services/RAGService.js +295 -0
  85. package/dist/api/src/services/SDKService.js +212 -0
  86. package/dist/api/src/services/supabase.js +72 -0
  87. package/dist/api/src/utils/Actuator.js +225 -0
  88. package/dist/api/src/utils/actions/ActionHandler.js +1 -0
  89. package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
  90. package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
  91. package/dist/api/src/utils/actions/CopyAction.js +112 -0
  92. package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
  93. package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
  94. package/dist/api/src/utils/actions/NotifyAction.js +32 -0
  95. package/dist/api/src/utils/actions/RenameAction.js +51 -0
  96. package/dist/api/src/utils/actions/WebhookAction.js +51 -0
  97. package/dist/api/src/utils/actions/utils.js +237 -0
  98. package/dist/api/src/utils/llmResponse.js +63 -0
  99. package/dist/api/src/utils/logger.js +51 -0
  100. package/dist/assets/index-DzN8-j-e.css +1 -0
  101. package/dist/assets/index-Uy-ai3Dh.js +113 -0
  102. package/dist/favicon.svg +31 -0
  103. package/dist/folio-logo.svg +46 -0
  104. package/dist/index.html +14 -0
  105. package/docs-dev/FPE-spec.md +196 -0
  106. package/docs-dev/folio-prd.md +47 -0
  107. package/docs-dev/foundation-checklist.md +30 -0
  108. package/docs-dev/hybrid-routing-architecture.md +205 -0
  109. package/docs-dev/ingestion-engine.md +69 -0
  110. package/docs-dev/port-from-email-automator.md +32 -0
  111. package/docs-dev/tech-spec.md +98 -0
  112. package/index.html +13 -0
  113. package/package.json +101 -0
  114. package/public/favicon.svg +31 -0
  115. package/public/folio-logo.svg +46 -0
  116. package/scripts/dev-task.mjs +51 -0
  117. package/scripts/get-latest-migration-timestamp.mjs +34 -0
  118. package/scripts/migrate.sh +91 -0
  119. package/supabase/.temp/cli-latest +1 -0
  120. package/supabase/.temp/gotrue-version +1 -0
  121. package/supabase/.temp/pooler-url +1 -0
  122. package/supabase/.temp/postgres-version +1 -0
  123. package/supabase/.temp/project-ref +1 -0
  124. package/supabase/.temp/rest-version +1 -0
  125. package/supabase/.temp/storage-migration +1 -0
  126. package/supabase/.temp/storage-version +1 -0
  127. package/supabase/config.toml +64 -0
  128. package/supabase/functions/_shared/auth.ts +35 -0
  129. package/supabase/functions/_shared/cors.ts +12 -0
  130. package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
  131. package/supabase/functions/api-v1-settings/index.ts +66 -0
  132. package/supabase/functions/setup/index.ts +91 -0
  133. package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
  134. package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
  135. package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
  136. package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
  137. package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
  138. package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
  139. package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
  140. package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
  141. package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
  142. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
  143. package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
  144. package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
  145. package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
  146. package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
  147. package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
  148. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
  149. package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
  150. package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
  151. package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
  152. package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
  153. package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
  154. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
  155. package/supabase/migrations/29991231235959_test_migration.sql +0 -0
  156. package/supabase/templates/confirmation.html +76 -0
  157. package/supabase/templates/email-change.html +76 -0
  158. package/supabase/templates/invite.html +72 -0
  159. package/supabase/templates/magic-link.html +68 -0
  160. package/supabase/templates/recovery.html +82 -0
  161. package/tsconfig.api.json +16 -0
  162. package/tsconfig.json +25 -0
  163. 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,4 @@
1
+ export * from "./auth.js";
2
+ export * from "./errorHandler.js";
3
+ export * from "./rateLimit.js";
4
+ export * from "./validation.js";
@@ -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;