@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
package/.env.example
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Frontend runtime
|
|
2
|
+
VITE_SUPABASE_URL=
|
|
3
|
+
VITE_SUPABASE_ANON_KEY=
|
|
4
|
+
VITE_API_URL=http://localhost:3006
|
|
5
|
+
|
|
6
|
+
# Local API runtime
|
|
7
|
+
PORT=3006
|
|
8
|
+
NODE_ENV=development
|
|
9
|
+
DISABLE_AUTH=true
|
|
10
|
+
|
|
11
|
+
# Optional .env fallback for local API (BYOK via setup UI is preferred)
|
|
12
|
+
SUPABASE_URL=
|
|
13
|
+
SUPABASE_ANON_KEY=
|
|
14
|
+
SUPABASE_SERVICE_ROLE_KEY=
|
|
15
|
+
|
|
16
|
+
# Security
|
|
17
|
+
JWT_SECRET=dev-secret-change-in-production
|
|
18
|
+
TOKEN_ENCRYPTION_KEY=
|
|
19
|
+
|
|
20
|
+
# RealTimeX SDK settings are managed by RealTimeX Desktop
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Folio
|
|
2
|
+
|
|
3
|
+
Folio is an intelligent document processing and automation platform that acts as your personal AI-powered filing cabinet. Powered by advanced AI and Vision-Language Models, Folio ingests your documents, understands their content, and automatically organizes them according to your custom rules.
|
|
4
|
+
|
|
5
|
+
If you deal with invoices, receipts, reports, or any unstructured documents, Folio handles the heavy lifting of reading, extracting, and routing your data where it belongs.
|
|
6
|
+
|
|
7
|
+
## ✨ Key Features
|
|
8
|
+
|
|
9
|
+
- **Intelligent Ingestion**: Simply drop in PDFs, images, or raw text. Folio automatically reads and extracts the context, using high-speed Vision-Language Models (VLMs) for images.
|
|
10
|
+
- **Folio Policies**: Create powerful "If X, then Y" automation rules. Teach Folio to recognize specific types of documents and run actions automatically.
|
|
11
|
+
- **Smart Auto-Renaming**: Say goodbye to messy file names like `scan_001.pdf`. Folio can intelligently suggest and automatically rename files based on their actual content (e.g., `2026-02-28_Amazon_Invoice.pdf`).
|
|
12
|
+
- **Seamless Cloud Integrations**:
|
|
13
|
+
- **Google Drive**: Automatically route and copy specific files into designated cloud folders.
|
|
14
|
+
- **Google Sheets**: Accurately extract tabular data (like expenses or ledger entries) and automatically append new rows directly to your spreadsheets.
|
|
15
|
+
- **Semantic Search & AI Chat**: Stop hunting for files. Folio creates a dynamic semantic index (RAG) of your documents, allowing you to search by concept or chat directly with your entire document library.
|
|
16
|
+
- **Transparency & Control**: Watch exactly what the AI is doing in real-time with the **LiveTerminal** trace logs.
|
|
17
|
+
|
|
18
|
+
## 🚀 Getting Started
|
|
19
|
+
|
|
20
|
+
Folio is designed to be run locally on your machine while securely syncing data to your own designated cloud backend.
|
|
21
|
+
|
|
22
|
+
### Prerequisites
|
|
23
|
+
- Node.js (v20+)
|
|
24
|
+
- A [Supabase](https://supabase.com) account (for your dedicated database)
|
|
25
|
+
|
|
26
|
+
### Installation
|
|
27
|
+
|
|
28
|
+
1. **Launch the Setup Wizard:**
|
|
29
|
+
```bash
|
|
30
|
+
npx @realtimex/folio@latest --port 5176
|
|
31
|
+
```
|
|
32
|
+
2. **Configure your Database:**
|
|
33
|
+
Follow the Setup Wizard in your browser. You can use **Zero-Config Cloud Provisioning** to automatically set up a secure Supabase project, or manually provide an existing Supabase URL and Key.
|
|
34
|
+
3. **Connect your Integrations:**
|
|
35
|
+
Head to the **Configuration** tab in the Folio dashboard to connect your local LLM providers and authorize Google Drive/Sheets.
|
|
36
|
+
|
|
37
|
+
## 🛠️ For Developers
|
|
38
|
+
|
|
39
|
+
Folio is highly extensible and built on a robust, modern stack:
|
|
40
|
+
- **Frontend**: React + Vite + Tailwind CSS
|
|
41
|
+
- **Backend / API**: Local Express Runtime + RealTimeX SDK
|
|
42
|
+
- **Database**: Remote Supabase (PostgreSQL + pgvector for RAG)
|
|
43
|
+
- **Extensibility**: The Action Engine is modular, allowing easy creation of new plugin handlers.
|
|
44
|
+
|
|
45
|
+
### Local Development
|
|
46
|
+
|
|
47
|
+
To run the full stack locally for contribution:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Install dependencies
|
|
51
|
+
npm install
|
|
52
|
+
|
|
53
|
+
# Start the local API backend
|
|
54
|
+
npm run dev:api
|
|
55
|
+
|
|
56
|
+
# Start the frontend dev server
|
|
57
|
+
npm run dev
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If you modify the database schema, apply migrations using:
|
|
61
|
+
```bash
|
|
62
|
+
npm run migrate
|
|
63
|
+
```
|
package/api/server.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
|
|
7
|
+
import { config, validateConfig } from "./src/config/index.js";
|
|
8
|
+
import { errorHandler } from "./src/middleware/errorHandler.js";
|
|
9
|
+
import { apiRateLimit } from "./src/middleware/rateLimit.js";
|
|
10
|
+
import routes from "./src/routes/index.js";
|
|
11
|
+
import { SDKService } from "./src/services/SDKService.js";
|
|
12
|
+
import { createLogger } from "./src/utils/logger.js";
|
|
13
|
+
|
|
14
|
+
const logger = createLogger("Server");
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
|
|
18
|
+
const configValidation = validateConfig();
|
|
19
|
+
if (!configValidation.valid) {
|
|
20
|
+
logger.warn("Configuration warnings", { errors: configValidation.errors });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SDKService.initialize();
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
|
|
27
|
+
app.use((req, res, next) => {
|
|
28
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
29
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
30
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
31
|
+
if (config.isProduction) {
|
|
32
|
+
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
33
|
+
}
|
|
34
|
+
next();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.use(
|
|
38
|
+
cors({
|
|
39
|
+
origin: config.isProduction ? config.security.corsOrigins : true,
|
|
40
|
+
credentials: true,
|
|
41
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
42
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Supabase-Url", "X-Supabase-Anon-Key"]
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
app.use(express.json({ limit: "10mb" }));
|
|
47
|
+
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
|
48
|
+
|
|
49
|
+
app.use((req, res, next) => {
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
res.on("finish", () => {
|
|
52
|
+
const duration = Date.now() - start;
|
|
53
|
+
logger.debug(`${req.method} ${req.path}`, { status: res.statusCode, durationMs: duration });
|
|
54
|
+
});
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
app.use("/api", apiRateLimit);
|
|
59
|
+
app.use("/api", routes);
|
|
60
|
+
|
|
61
|
+
function getDistPath() {
|
|
62
|
+
if (
|
|
63
|
+
process.env.ELECTRON_STATIC_PATH &&
|
|
64
|
+
existsSync(path.join(process.env.ELECTRON_STATIC_PATH, "index.html"))
|
|
65
|
+
) {
|
|
66
|
+
return process.env.ELECTRON_STATIC_PATH;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fromRoot = path.join(config.rootDir || process.cwd(), "dist");
|
|
70
|
+
if (existsSync(path.join(fromRoot, "index.html"))) {
|
|
71
|
+
return fromRoot;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let current = __dirname;
|
|
75
|
+
for (let i = 0; i < 4; i += 1) {
|
|
76
|
+
const candidate = path.join(current, "dist");
|
|
77
|
+
if (existsSync(path.join(candidate, "index.html"))) {
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
current = path.dirname(current);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return fromRoot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const distUiPath = getDistPath();
|
|
87
|
+
if (existsSync(path.join(distUiPath, "index.html"))) {
|
|
88
|
+
logger.info("Serving static UI", { distUiPath });
|
|
89
|
+
app.use(express.static(distUiPath));
|
|
90
|
+
|
|
91
|
+
app.get(/.*/, (req, res, next) => {
|
|
92
|
+
if (req.path.startsWith("/api")) {
|
|
93
|
+
return next();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
res.sendFile(path.join(distUiPath, "index.html"), (error) => {
|
|
97
|
+
if (error) {
|
|
98
|
+
res.status(404).json({
|
|
99
|
+
success: false,
|
|
100
|
+
error: {
|
|
101
|
+
code: "NOT_FOUND",
|
|
102
|
+
message: "Frontend not built or route not found"
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
logger.warn("No dist/index.html found. API will run without bundled UI.", { distUiPath });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
app.use(errorHandler);
|
|
113
|
+
|
|
114
|
+
const server = app.listen(config.port, () => {
|
|
115
|
+
logger.info("Folio API started", {
|
|
116
|
+
port: config.port,
|
|
117
|
+
environment: config.nodeEnv,
|
|
118
|
+
packageRoot: config.packageRoot
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function shutdown(signal: string) {
|
|
123
|
+
logger.info(`Shutting down (${signal})`);
|
|
124
|
+
server.close(() => {
|
|
125
|
+
process.exit(0);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
130
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
|
|
9
|
+
function findPackageRoot(startDir: string): string {
|
|
10
|
+
let current = startDir;
|
|
11
|
+
while (current !== path.parse(current).root) {
|
|
12
|
+
if (existsSync(join(current, "package.json")) && existsSync(join(current, "bin"))) {
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
current = dirname(current);
|
|
16
|
+
}
|
|
17
|
+
return process.cwd();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const packageRoot = findPackageRoot(__dirname);
|
|
21
|
+
|
|
22
|
+
function loadEnvironment() {
|
|
23
|
+
const cwdEnv = join(process.cwd(), ".env");
|
|
24
|
+
const rootEnv = join(packageRoot, ".env");
|
|
25
|
+
|
|
26
|
+
if (existsSync(cwdEnv)) {
|
|
27
|
+
dotenv.config({ path: cwdEnv, override: true });
|
|
28
|
+
} else if (existsSync(rootEnv)) {
|
|
29
|
+
dotenv.config({ path: rootEnv, override: true });
|
|
30
|
+
} else {
|
|
31
|
+
dotenv.config();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
loadEnvironment();
|
|
36
|
+
|
|
37
|
+
function parseArgs(args: string[]): { port: number | null; noUi: boolean } {
|
|
38
|
+
const portIndex = args.indexOf("--port");
|
|
39
|
+
let port: number | null = null;
|
|
40
|
+
|
|
41
|
+
if (portIndex !== -1 && args[portIndex + 1]) {
|
|
42
|
+
const candidate = Number.parseInt(args[portIndex + 1], 10);
|
|
43
|
+
if (!Number.isNaN(candidate) && candidate > 0 && candidate < 65536) {
|
|
44
|
+
port = candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
port,
|
|
50
|
+
noUi: args.includes("--no-ui")
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cliArgs = parseArgs(process.argv.slice(2));
|
|
55
|
+
|
|
56
|
+
export const config = {
|
|
57
|
+
packageRoot,
|
|
58
|
+
port: cliArgs.port || (process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3006),
|
|
59
|
+
noUi: cliArgs.noUi,
|
|
60
|
+
nodeEnv: process.env.NODE_ENV || "development",
|
|
61
|
+
isProduction: process.env.NODE_ENV === "production",
|
|
62
|
+
rootDir: packageRoot,
|
|
63
|
+
scriptsDir: join(packageRoot, "scripts"),
|
|
64
|
+
|
|
65
|
+
supabase: {
|
|
66
|
+
url: process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || "",
|
|
67
|
+
anonKey: process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || "",
|
|
68
|
+
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || ""
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
security: {
|
|
72
|
+
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production",
|
|
73
|
+
encryptionKey: process.env.TOKEN_ENCRYPTION_KEY || "",
|
|
74
|
+
corsOrigins: process.env.CORS_ORIGINS?.split(",") || ["http://localhost:5173", "http://localhost:3006"],
|
|
75
|
+
rateLimitWindowMs: 60 * 1000,
|
|
76
|
+
rateLimitMax: 60,
|
|
77
|
+
disableAuth: process.env.DISABLE_AUTH === "true"
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function validateConfig(): { valid: boolean; errors: string[] } {
|
|
82
|
+
const errors: string[] = [];
|
|
83
|
+
|
|
84
|
+
if (config.isProduction && config.security.jwtSecret === "dev-secret-change-in-production") {
|
|
85
|
+
errors.push("JWT_SECRET must be set in production");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (config.isProduction && !config.security.encryptionKey) {
|
|
89
|
+
errors.push("TOKEN_ENCRYPTION_KEY must be set in production");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
valid: errors.length === 0,
|
|
94
|
+
errors
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createClient, SupabaseClient, User } from "@supabase/supabase-js";
|
|
2
|
+
import { NextFunction, Request, Response } from "express";
|
|
3
|
+
|
|
4
|
+
import { config } from "../config/index.js";
|
|
5
|
+
import { getServerSupabase, getSupabaseConfigFromHeaders } from "../services/supabase.js";
|
|
6
|
+
import { Logger, createLogger } from "../utils/logger.js";
|
|
7
|
+
import { AuthenticationError, AuthorizationError } from "./errorHandler.js";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger("AuthMiddleware");
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
namespace Express {
|
|
13
|
+
interface Request {
|
|
14
|
+
user?: User;
|
|
15
|
+
supabase?: SupabaseClient;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveSupabaseConfig(req: Request): { url: string; anonKey: string } | null {
|
|
21
|
+
const headerConfig = getSupabaseConfigFromHeaders(req.headers as Record<string, unknown>);
|
|
22
|
+
|
|
23
|
+
const envUrl = config.supabase.url;
|
|
24
|
+
const envKey = config.supabase.anonKey;
|
|
25
|
+
|
|
26
|
+
const envIsValid = envUrl.startsWith("http://") || envUrl.startsWith("https://");
|
|
27
|
+
if (envIsValid && envKey) {
|
|
28
|
+
return { url: envUrl, anonKey: envKey };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return headerConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function authMiddleware(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
const supabaseConfig = resolveSupabaseConfig(req);
|
|
37
|
+
|
|
38
|
+
if (!supabaseConfig) {
|
|
39
|
+
throw new AuthenticationError("Supabase not configured");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (config.security.disableAuth && !config.isProduction) {
|
|
43
|
+
const user = {
|
|
44
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
45
|
+
email: "dev@folio.local",
|
|
46
|
+
user_metadata: {},
|
|
47
|
+
app_metadata: {},
|
|
48
|
+
aud: "authenticated",
|
|
49
|
+
created_at: new Date().toISOString()
|
|
50
|
+
} as User;
|
|
51
|
+
|
|
52
|
+
const supabase =
|
|
53
|
+
getServerSupabase() ||
|
|
54
|
+
createClient(supabaseConfig.url, supabaseConfig.anonKey, {
|
|
55
|
+
auth: {
|
|
56
|
+
autoRefreshToken: false,
|
|
57
|
+
persistSession: false
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
req.user = user;
|
|
62
|
+
req.supabase = supabase;
|
|
63
|
+
Logger.setPersistence(supabase, user.id);
|
|
64
|
+
return next();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const authHeader = req.headers.authorization;
|
|
68
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
69
|
+
throw new AuthenticationError("Missing bearer token");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const token = authHeader.slice(7);
|
|
73
|
+
|
|
74
|
+
const supabase = createClient(supabaseConfig.url, supabaseConfig.anonKey, {
|
|
75
|
+
global: {
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${token}`
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const {
|
|
83
|
+
data: { user },
|
|
84
|
+
error
|
|
85
|
+
} = await supabase.auth.getUser(token);
|
|
86
|
+
|
|
87
|
+
if (error || !user) {
|
|
88
|
+
throw new AuthenticationError("Invalid or expired token");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
req.user = user;
|
|
92
|
+
req.supabase = supabase;
|
|
93
|
+
Logger.setPersistence(supabase, user.id);
|
|
94
|
+
next();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.error("Auth middleware error", {
|
|
97
|
+
error: error instanceof Error ? error.message : String(error)
|
|
98
|
+
});
|
|
99
|
+
next(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function optionalAuth(req: Request, res: Response, next: NextFunction): void {
|
|
104
|
+
const authHeader = req.headers.authorization;
|
|
105
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
106
|
+
next();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
void authMiddleware(req, res, next);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function requireRole(roles: string[]) {
|
|
114
|
+
return (req: Request, _res: Response, next: NextFunction) => {
|
|
115
|
+
if (!req.user) {
|
|
116
|
+
next(new AuthenticationError());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const role = req.user.user_metadata?.role || "user";
|
|
121
|
+
if (!roles.includes(role)) {
|
|
122
|
+
next(new AuthorizationError(`Requires one of: ${roles.join(", ")}`));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
next();
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextFunction, Request, Response } from "express";
|
|
2
|
+
|
|
3
|
+
import { config } from "../config/index.js";
|
|
4
|
+
import { createLogger } from "../utils/logger.js";
|
|
5
|
+
|
|
6
|
+
const logger = createLogger("ErrorHandler");
|
|
7
|
+
|
|
8
|
+
export class AppError extends Error {
|
|
9
|
+
statusCode: number;
|
|
10
|
+
isOperational: boolean;
|
|
11
|
+
code?: string;
|
|
12
|
+
|
|
13
|
+
constructor(message: string, statusCode = 500, code?: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.statusCode = statusCode;
|
|
16
|
+
this.isOperational = true;
|
|
17
|
+
this.code = code;
|
|
18
|
+
Error.captureStackTrace(this, this.constructor);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ValidationError extends AppError {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message, 400, "VALIDATION_ERROR");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AuthenticationError extends AppError {
|
|
29
|
+
constructor(message = "Authentication required") {
|
|
30
|
+
super(message, 401, "AUTHENTICATION_ERROR");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class AuthorizationError extends AppError {
|
|
35
|
+
constructor(message = "Insufficient permissions") {
|
|
36
|
+
super(message, 403, "AUTHORIZATION_ERROR");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class NotFoundError extends AppError {
|
|
41
|
+
constructor(resource = "Resource") {
|
|
42
|
+
super(`${resource} not found`, 404, "NOT_FOUND");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class RateLimitError extends AppError {
|
|
47
|
+
constructor() {
|
|
48
|
+
super("Too many requests", 429, "RATE_LIMIT_EXCEEDED");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function errorHandler(err: Error | AppError, req: Request, res: Response, _next: NextFunction): void {
|
|
53
|
+
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
|
54
|
+
const code = err instanceof AppError ? err.code : "INTERNAL_ERROR";
|
|
55
|
+
|
|
56
|
+
if (statusCode >= 500) {
|
|
57
|
+
logger.error("Server error", {
|
|
58
|
+
method: req.method,
|
|
59
|
+
path: req.path,
|
|
60
|
+
statusCode,
|
|
61
|
+
message: err.message
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
logger.warn("Client error", {
|
|
65
|
+
method: req.method,
|
|
66
|
+
path: req.path,
|
|
67
|
+
statusCode,
|
|
68
|
+
message: err.message
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
res.status(statusCode).json({
|
|
73
|
+
success: false,
|
|
74
|
+
error: {
|
|
75
|
+
code,
|
|
76
|
+
message: !config.isProduction ? err.message : statusCode >= 500 ? "Unexpected error" : err.message,
|
|
77
|
+
...(config.isProduction ? {} : { stack: err.stack })
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function asyncHandler(
|
|
83
|
+
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
|
|
84
|
+
) {
|
|
85
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
86
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NextFunction, Request, Response } from "express";
|
|
2
|
+
|
|
3
|
+
import { config } from "../config/index.js";
|
|
4
|
+
import { RateLimitError } from "./errorHandler.js";
|
|
5
|
+
|
|
6
|
+
interface RateLimitEntry {
|
|
7
|
+
count: number;
|
|
8
|
+
resetAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
12
|
+
|
|
13
|
+
setInterval(() => {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
16
|
+
if (entry.resetAt < now) {
|
|
17
|
+
rateLimitStore.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}, 60_000);
|
|
21
|
+
|
|
22
|
+
export interface RateLimitOptions {
|
|
23
|
+
windowMs?: number;
|
|
24
|
+
max?: number;
|
|
25
|
+
keyGenerator?: (req: Request) => string;
|
|
26
|
+
skip?: (req: Request) => boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function rateLimit(options: RateLimitOptions = {}) {
|
|
30
|
+
const {
|
|
31
|
+
windowMs = config.security.rateLimitWindowMs,
|
|
32
|
+
max = config.security.rateLimitMax,
|
|
33
|
+
keyGenerator = (req) => req.ip || String(req.headers["x-forwarded-for"] || "unknown"),
|
|
34
|
+
skip = () => false
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
38
|
+
if (skip(req)) {
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const key = keyGenerator(req);
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
let entry = rateLimitStore.get(key);
|
|
45
|
+
|
|
46
|
+
if (!entry || entry.resetAt < now) {
|
|
47
|
+
entry = {
|
|
48
|
+
count: 1,
|
|
49
|
+
resetAt: now + windowMs
|
|
50
|
+
};
|
|
51
|
+
rateLimitStore.set(key, entry);
|
|
52
|
+
} else {
|
|
53
|
+
entry.count += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
res.setHeader("X-RateLimit-Limit", max);
|
|
57
|
+
res.setHeader("X-RateLimit-Remaining", Math.max(0, max - entry.count));
|
|
58
|
+
res.setHeader("X-RateLimit-Reset", Math.ceil(entry.resetAt / 1000));
|
|
59
|
+
|
|
60
|
+
if (entry.count > max) {
|
|
61
|
+
return next(new RateLimitError());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const apiRateLimit = rateLimit({
|
|
69
|
+
windowMs: 60_000,
|
|
70
|
+
max: 60
|
|
71
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { z, ZodError, ZodSchema } from "zod";
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "./errorHandler.js";
|
|
5
|
+
|
|
6
|
+
export function validateBody<T>(schema: ZodSchema<T>) {
|
|
7
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
8
|
+
try {
|
|
9
|
+
req.body = schema.parse(req.body);
|
|
10
|
+
next();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error instanceof ZodError) {
|
|
13
|
+
const message = error.errors.map((item) => `${item.path.join(".")}: ${item.message}`).join(", ");
|
|
14
|
+
next(new ValidationError(message));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
next(error);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateQuery<T>(schema: ZodSchema<T>) {
|
|
23
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
24
|
+
try {
|
|
25
|
+
req.query = schema.parse(req.query) as Request["query"];
|
|
26
|
+
next();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof ZodError) {
|
|
29
|
+
const message = error.errors.map((item) => `${item.path.join(".")}: ${item.message}`).join(", ");
|
|
30
|
+
next(new ValidationError(message));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
next(error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const schemas = {
|
|
39
|
+
testSupabase: z.object({
|
|
40
|
+
url: z.string().url(),
|
|
41
|
+
anonKey: z.string().min(10)
|
|
42
|
+
}),
|
|
43
|
+
autoProvision: z.object({
|
|
44
|
+
orgId: z.string().min(1),
|
|
45
|
+
projectName: z.string().min(1).max(64).optional(),
|
|
46
|
+
region: z.string().min(1).max(64).optional()
|
|
47
|
+
}),
|
|
48
|
+
migrate: z.object({
|
|
49
|
+
projectRef: z.string().min(1),
|
|
50
|
+
accessToken: z.string().min(1),
|
|
51
|
+
anonKey: z.string().min(1).optional()
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
dispatchProcessing: z.object({
|
|
55
|
+
source_type: z.string().min(1),
|
|
56
|
+
payload: z.record(z.unknown())
|
|
57
|
+
})
|
|
58
|
+
};
|