@realtimex/folio 0.1.15 → 0.1.17

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 (50) hide show
  1. package/api/src/middleware/auth.ts +77 -0
  2. package/api/src/routes/chat.ts +7 -1
  3. package/api/src/routes/index.ts +2 -0
  4. package/api/src/routes/ingestions.ts +51 -6
  5. package/api/src/routes/policies.ts +50 -7
  6. package/api/src/routes/stats.ts +9 -5
  7. package/api/src/routes/workspaces.ts +290 -0
  8. package/api/src/services/ChatService.ts +8 -2
  9. package/api/src/services/IngestionService.ts +38 -26
  10. package/api/src/services/PolicyEngine.ts +4 -1
  11. package/api/src/services/PolicyLearningService.ts +31 -6
  12. package/api/src/services/PolicyLoader.ts +44 -25
  13. package/api/src/services/RAGService.ts +52 -12
  14. package/api/src/services/SDKService.ts +48 -2
  15. package/dist/api/src/middleware/auth.js +59 -0
  16. package/dist/api/src/routes/chat.js +1 -1
  17. package/dist/api/src/routes/index.js +2 -0
  18. package/dist/api/src/routes/ingestions.js +51 -9
  19. package/dist/api/src/routes/policies.js +49 -7
  20. package/dist/api/src/routes/stats.js +9 -5
  21. package/dist/api/src/routes/workspaces.js +220 -0
  22. package/dist/api/src/services/ChatService.js +7 -2
  23. package/dist/api/src/services/IngestionService.js +35 -30
  24. package/dist/api/src/services/PolicyEngine.js +2 -1
  25. package/dist/api/src/services/PolicyLearningService.js +28 -6
  26. package/dist/api/src/services/PolicyLoader.js +29 -25
  27. package/dist/api/src/services/RAGService.js +43 -11
  28. package/dist/api/src/services/SDKService.js +37 -2
  29. package/dist/assets/index-CTn5FcC4.js +113 -0
  30. package/dist/assets/index-Dq9sxoZK.css +1 -0
  31. package/dist/index.html +2 -2
  32. package/docs-dev/ingestion-engine.md +3 -3
  33. package/package.json +1 -1
  34. package/supabase/functions/workspace-invite/index.ts +110 -0
  35. package/supabase/migrations/20260223000000_initial_foundation.sql +5 -0
  36. package/supabase/migrations/20260224000004_add_avatars_storage.sql +4 -0
  37. package/supabase/migrations/20260224000006_add_policies_table.sql +5 -0
  38. package/supabase/migrations/20260224000008_add_ingestions_table.sql +2 -0
  39. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +17 -4
  40. package/supabase/migrations/20260225000003_add_baseline_configs.sql +4 -3
  41. package/supabase/migrations/20260226000000_add_processing_events.sql +1 -0
  42. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +1 -0
  43. package/supabase/migrations/20260226000005_add_chat_tables.sql +3 -0
  44. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +4 -0
  45. package/supabase/migrations/20260302064608_add_ingestion_llm_settings_compat.sql +15 -0
  46. package/supabase/migrations/20260303000000_add_workspaces_phase1.sql +459 -0
  47. package/supabase/migrations/20260303010000_add_workspace_management_rpc.sql +310 -0
  48. package/supabase/migrations/20260303020000_workspace_scope_document_chunks.sql +139 -0
  49. package/dist/assets/index-Cj989Mcp.js +0 -113
  50. package/dist/assets/index-DzN8-j-e.css +0 -1
@@ -13,10 +13,19 @@ declare global {
13
13
  interface Request {
14
14
  user?: User;
15
15
  supabase?: SupabaseClient;
16
+ workspaceId?: string;
17
+ workspaceRole?: string;
16
18
  }
17
19
  }
18
20
  }
19
21
 
22
+ type WorkspaceMembershipRow = {
23
+ workspace_id: string;
24
+ role: "owner" | "admin" | "member";
25
+ status: "active" | "invited" | "disabled";
26
+ created_at: string;
27
+ };
28
+
20
29
  function resolveSupabaseConfig(req: Request): { url: string; anonKey: string } | null {
21
30
  const headerConfig = getSupabaseConfigFromHeaders(req.headers as Record<string, unknown>);
22
31
 
@@ -31,6 +40,64 @@ function resolveSupabaseConfig(req: Request): { url: string; anonKey: string } |
31
40
  return headerConfig;
32
41
  }
33
42
 
43
+ function resolvePreferredWorkspaceId(req: Request): string | null {
44
+ const raw = req.headers["x-workspace-id"];
45
+ if (typeof raw === "string") {
46
+ const trimmed = raw.trim();
47
+ return trimmed.length > 0 ? trimmed : null;
48
+ }
49
+ if (Array.isArray(raw) && typeof raw[0] === "string") {
50
+ const trimmed = raw[0].trim();
51
+ return trimmed.length > 0 ? trimmed : null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ async function resolveWorkspaceContext(
57
+ req: Request,
58
+ supabase: SupabaseClient,
59
+ user: User
60
+ ): Promise<{ workspaceId: string; workspaceRole: "owner" | "admin" | "member" } | null> {
61
+ const preferredWorkspaceId = resolvePreferredWorkspaceId(req);
62
+ const { data, error } = await supabase
63
+ .from("workspace_members")
64
+ .select("workspace_id,role,status,created_at")
65
+ .eq("user_id", user.id)
66
+ .eq("status", "active")
67
+ .order("created_at", { ascending: true });
68
+
69
+ if (error) {
70
+ const errorCode = (error as { code?: string }).code;
71
+ // Backward compatibility: allow projects that haven't migrated yet.
72
+ if (errorCode === "42P01") {
73
+ return null;
74
+ }
75
+ throw new AuthorizationError(`Failed to resolve workspace membership: ${error.message}`);
76
+ }
77
+
78
+ const memberships = (data ?? []) as WorkspaceMembershipRow[];
79
+ if (memberships.length === 0) {
80
+ return null;
81
+ }
82
+
83
+ if (preferredWorkspaceId) {
84
+ const preferred = memberships.find((membership) => membership.workspace_id === preferredWorkspaceId);
85
+ if (preferred) {
86
+ return {
87
+ workspaceId: preferred.workspace_id,
88
+ workspaceRole: preferred.role,
89
+ };
90
+ }
91
+ }
92
+
93
+ const active = memberships[0];
94
+ if (!active) return null;
95
+ return {
96
+ workspaceId: active.workspace_id,
97
+ workspaceRole: active.role,
98
+ };
99
+ }
100
+
34
101
  export async function authMiddleware(req: Request, _res: Response, next: NextFunction): Promise<void> {
35
102
  try {
36
103
  const supabaseConfig = resolveSupabaseConfig(req);
@@ -60,6 +127,11 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun
60
127
 
61
128
  req.user = user;
62
129
  req.supabase = supabase;
130
+ const workspace = await resolveWorkspaceContext(req, supabase, user);
131
+ if (workspace) {
132
+ req.workspaceId = workspace.workspaceId;
133
+ req.workspaceRole = workspace.workspaceRole;
134
+ }
63
135
  Logger.setPersistence(supabase, user.id);
64
136
  return next();
65
137
  }
@@ -90,6 +162,11 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun
90
162
 
91
163
  req.user = user;
92
164
  req.supabase = supabase;
165
+ const workspace = await resolveWorkspaceContext(req, supabase, user);
166
+ if (workspace) {
167
+ req.workspaceId = workspace.workspaceId;
168
+ req.workspaceRole = workspace.workspaceRole;
169
+ }
93
170
  Logger.setPersistence(supabase, user.id);
94
171
  next();
95
172
  } catch (error) {
@@ -142,7 +142,13 @@ router.post(
142
142
  }
143
143
 
144
144
  try {
145
- const aiMessage = await ChatService.handleMessage(normalizedSessionId, req.user.id, trimmedContent, req.supabase);
145
+ const aiMessage = await ChatService.handleMessage(
146
+ normalizedSessionId,
147
+ req.user.id,
148
+ trimmedContent,
149
+ req.supabase,
150
+ req.workspaceId
151
+ );
146
152
  res.json({ success: true, message: aiMessage });
147
153
  } catch (error) {
148
154
  const message = error instanceof Error ? error.message : "Failed to process message";
@@ -14,6 +14,7 @@ import settingsRoutes from "./settings.js";
14
14
  import rulesRoutes from "./rules.js";
15
15
  import chatRoutes from "./chat.js";
16
16
  import statsRoutes from "./stats.js";
17
+ import workspaceRoutes from "./workspaces.js";
17
18
 
18
19
  const router = Router();
19
20
 
@@ -31,5 +32,6 @@ router.use("/settings", settingsRoutes);
31
32
  router.use("/rules", rulesRoutes);
32
33
  router.use("/chat", chatRoutes);
33
34
  router.use("/stats", statsRoutes);
35
+ router.use("/workspaces", workspaceRoutes);
34
36
 
35
37
  export default router;
@@ -7,6 +7,7 @@ import crypto from "crypto";
7
7
  import { asyncHandler } from "../middleware/errorHandler.js";
8
8
  import { optionalAuth } from "../middleware/auth.js";
9
9
  import { IngestionService } from "../services/IngestionService.js";
10
+ import { SDKService } from "../services/SDKService.js";
10
11
 
11
12
  const router = Router();
12
13
  const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
@@ -21,10 +22,14 @@ router.get(
21
22
  res.status(401).json({ success: false, error: "Authentication required" });
22
23
  return;
23
24
  }
25
+ if (!req.workspaceId) {
26
+ res.status(403).json({ success: false, error: "Workspace membership required" });
27
+ return;
28
+ }
24
29
  const page = Math.max(1, parseInt(req.query["page"] as string) || 1);
25
30
  const pageSize = Math.min(100, Math.max(1, parseInt(req.query["pageSize"] as string) || 20));
26
31
  const query = (req.query["q"] as string | undefined)?.trim() || undefined;
27
- const { ingestions, total } = await IngestionService.list(req.supabase, req.user.id, { page, pageSize, query });
32
+ const { ingestions, total } = await IngestionService.list(req.supabase, req.workspaceId, { page, pageSize, query });
28
33
  res.json({ success: true, ingestions, total, page, pageSize });
29
34
  })
30
35
  );
@@ -37,7 +42,11 @@ router.get(
37
42
  res.status(401).json({ success: false, error: "Authentication required" });
38
43
  return;
39
44
  }
40
- const ingestion = await IngestionService.get(req.params["id"] as string, req.supabase, req.user.id);
45
+ if (!req.workspaceId) {
46
+ res.status(403).json({ success: false, error: "Workspace membership required" });
47
+ return;
48
+ }
49
+ const ingestion = await IngestionService.get(req.params["id"] as string, req.supabase, req.workspaceId);
41
50
  if (!ingestion) {
42
51
  res.status(404).json({ success: false, error: "Not found" });
43
52
  return;
@@ -55,6 +64,10 @@ router.post(
55
64
  res.status(401).json({ success: false, error: "Authentication required" });
56
65
  return;
57
66
  }
67
+ if (!req.workspaceId) {
68
+ res.status(403).json({ success: false, error: "Workspace membership required" });
69
+ return;
70
+ }
58
71
  const file = req.file;
59
72
  if (!file) {
60
73
  res.status(400).json({ success: false, error: "No file uploaded" });
@@ -68,7 +81,11 @@ router.post(
68
81
  .eq("user_id", req.user.id)
69
82
  .maybeSingle();
70
83
 
71
- const dropzoneDir = settings?.storage_path || path.join(os.homedir(), ".realtimex", "folio", "dropzone");
84
+ const configuredStoragePath = typeof settings?.storage_path === "string" ? settings.storage_path.trim() : "";
85
+ const legacyDefaultDropzoneDir = path.join(os.homedir(), ".realtimex", "folio", "dropzone");
86
+ const dropzoneDir = !configuredStoragePath || configuredStoragePath === legacyDefaultDropzoneDir
87
+ ? await SDKService.getDefaultDropzoneDir()
88
+ : configuredStoragePath;
72
89
  await fs.mkdir(dropzoneDir, { recursive: true });
73
90
 
74
91
  // Compute SHA-256 hash before writing — used for deduplication
@@ -85,6 +102,7 @@ router.post(
85
102
  const ingestion = await IngestionService.ingest({
86
103
  supabase: req.supabase,
87
104
  userId: req.user.id,
105
+ workspaceId: req.workspaceId,
88
106
  filename: file.originalname,
89
107
  mimeType: file.mimetype,
90
108
  fileSize: file.size,
@@ -106,7 +124,11 @@ router.post(
106
124
  res.status(401).json({ success: false, error: "Authentication required" });
107
125
  return;
108
126
  }
109
- const matched = await IngestionService.rerun(req.params["id"] as string, req.supabase, req.user.id);
127
+ if (!req.workspaceId) {
128
+ res.status(403).json({ success: false, error: "Workspace membership required" });
129
+ return;
130
+ }
131
+ const matched = await IngestionService.rerun(req.params["id"] as string, req.supabase, req.user.id, req.workspaceId);
110
132
  res.json({ success: true, matched });
111
133
  })
112
134
  );
@@ -119,6 +141,10 @@ router.post(
119
141
  res.status(401).json({ success: false, error: "Authentication required" });
120
142
  return;
121
143
  }
144
+ if (!req.workspaceId) {
145
+ res.status(403).json({ success: false, error: "Workspace membership required" });
146
+ return;
147
+ }
122
148
 
123
149
  const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
124
150
  if (!policyId) {
@@ -135,6 +161,7 @@ router.post(
135
161
  policyId,
136
162
  req.supabase,
137
163
  req.user.id,
164
+ req.workspaceId,
138
165
  {
139
166
  learn,
140
167
  rerun,
@@ -165,6 +192,10 @@ router.post(
165
192
  res.status(401).json({ success: false, error: "Authentication required" });
166
193
  return;
167
194
  }
195
+ if (!req.workspaceId) {
196
+ res.status(403).json({ success: false, error: "Workspace membership required" });
197
+ return;
198
+ }
168
199
 
169
200
  const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
170
201
  if (!policyId) {
@@ -178,6 +209,7 @@ router.post(
178
209
  policyId,
179
210
  req.supabase,
180
211
  req.user.id,
212
+ req.workspaceId,
181
213
  {
182
214
  provider: typeof req.body?.provider === "string" ? req.body.provider : undefined,
183
215
  model: typeof req.body?.model === "string" ? req.body.model : undefined,
@@ -207,6 +239,10 @@ router.post(
207
239
  res.status(401).json({ success: false, error: "Authentication required" });
208
240
  return;
209
241
  }
242
+ if (!req.workspaceId) {
243
+ res.status(403).json({ success: false, error: "Workspace membership required" });
244
+ return;
245
+ }
210
246
  const { data: settingsRow } = await req.supabase
211
247
  .from("user_settings")
212
248
  .select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model")
@@ -219,6 +255,7 @@ router.post(
219
255
  req.params["id"] as string,
220
256
  req.supabase,
221
257
  req.user.id,
258
+ req.workspaceId,
222
259
  llmSettings
223
260
  );
224
261
  res.json({ success: true, summary });
@@ -233,6 +270,10 @@ router.patch(
233
270
  res.status(401).json({ success: false, error: "Authentication required" });
234
271
  return;
235
272
  }
273
+ if (!req.workspaceId) {
274
+ res.status(403).json({ success: false, error: "Workspace membership required" });
275
+ return;
276
+ }
236
277
  const tags: unknown = req.body?.tags;
237
278
  if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
238
279
  res.status(400).json({ success: false, error: "tags must be an array of strings" });
@@ -243,7 +284,7 @@ router.patch(
243
284
  .from("ingestions")
244
285
  .update({ tags: normalized })
245
286
  .eq("id", req.params["id"] as string)
246
- .eq("user_id", req.user.id);
287
+ .eq("workspace_id", req.workspaceId);
247
288
  if (error) {
248
289
  res.status(500).json({ success: false, error: error.message });
249
290
  return;
@@ -260,7 +301,11 @@ router.delete(
260
301
  res.status(401).json({ success: false, error: "Authentication required" });
261
302
  return;
262
303
  }
263
- const deleted = await IngestionService.delete(req.params["id"] as string, req.supabase, req.user.id);
304
+ if (!req.workspaceId) {
305
+ res.status(403).json({ success: false, error: "Workspace membership required" });
306
+ return;
307
+ }
308
+ const deleted = await IngestionService.delete(req.params["id"] as string, req.supabase, req.workspaceId);
264
309
  if (!deleted) {
265
310
  res.status(404).json({ success: false, error: "Not found" });
266
311
  return;
@@ -15,8 +15,17 @@ router.use(optionalAuth);
15
15
  router.get(
16
16
  "/",
17
17
  asyncHandler(async (req, res) => {
18
- const policies = await PolicyLoader.load(false, req.supabase);
19
- if (!req.supabase || !req.user || policies.length === 0) {
18
+ if (!req.supabase || !req.user) {
19
+ const policies = await PolicyLoader.load(false, req.supabase, req.workspaceId);
20
+ res.json({ success: true, policies });
21
+ return;
22
+ }
23
+ if (!req.workspaceId) {
24
+ res.status(403).json({ success: false, error: "Workspace membership required" });
25
+ return;
26
+ }
27
+ const policies = await PolicyLoader.load(false, req.supabase, req.workspaceId);
28
+ if (policies.length === 0) {
20
29
  res.json({ success: true, policies });
21
30
  return;
22
31
  }
@@ -24,6 +33,7 @@ router.get(
24
33
  const stats = await PolicyLearningService.getPolicyLearningStats({
25
34
  supabase: req.supabase,
26
35
  userId: req.user.id,
36
+ workspaceId: req.workspaceId,
27
37
  policyIds: policies.map((policy) => policy.metadata.id),
28
38
  });
29
39
 
@@ -47,12 +57,20 @@ router.get(
47
57
  router.post(
48
58
  "/",
49
59
  asyncHandler(async (req, res) => {
60
+ if (!req.supabase || !req.user) {
61
+ res.status(401).json({ success: false, error: "Authentication required" });
62
+ return;
63
+ }
64
+ if (!req.workspaceId) {
65
+ res.status(403).json({ success: false, error: "Workspace membership required" });
66
+ return;
67
+ }
50
68
  const policy = req.body;
51
69
  if (!PolicyLoader.validate(policy)) {
52
70
  res.status(400).json({ success: false, error: "Invalid policy schema" });
53
71
  return;
54
72
  }
55
- const filePath = await PolicyLoader.save(policy, req.supabase, req.user?.id);
73
+ const filePath = await PolicyLoader.save(policy, req.supabase, req.user.id, req.workspaceId);
56
74
  res.status(201).json({ success: true, filePath });
57
75
  })
58
76
  );
@@ -61,7 +79,15 @@ router.post(
61
79
  router.delete(
62
80
  "/:id",
63
81
  asyncHandler(async (req, res) => {
64
- const deleted = await PolicyLoader.delete(req.params["id"] as string, req.supabase, req.user?.id);
82
+ if (!req.supabase || !req.user) {
83
+ res.status(401).json({ success: false, error: "Authentication required" });
84
+ return;
85
+ }
86
+ if (!req.workspaceId) {
87
+ res.status(403).json({ success: false, error: "Workspace membership required" });
88
+ return;
89
+ }
90
+ const deleted = await PolicyLoader.delete(req.params["id"] as string, req.supabase, req.user.id, req.workspaceId);
65
91
  if (!deleted) {
66
92
  res.status(404).json({ success: false, error: "Policy not found" });
67
93
  return;
@@ -74,12 +100,21 @@ router.delete(
74
100
  router.patch(
75
101
  "/:id",
76
102
  asyncHandler(async (req, res) => {
103
+ if (!req.supabase || !req.user) {
104
+ res.status(401).json({ success: false, error: "Authentication required" });
105
+ return;
106
+ }
107
+ if (!req.workspaceId) {
108
+ res.status(403).json({ success: false, error: "Workspace membership required" });
109
+ return;
110
+ }
77
111
  const { enabled, name, description, tags, priority } = req.body;
78
112
  await PolicyLoader.patch(
79
113
  req.params["id"] as string,
80
114
  { enabled, name, description, tags, priority },
81
115
  req.supabase,
82
- req.user?.id
116
+ req.user.id,
117
+ req.workspaceId
83
118
  );
84
119
  res.json({ success: true });
85
120
  })
@@ -89,8 +124,16 @@ router.patch(
89
124
  router.post(
90
125
  "/reload",
91
126
  asyncHandler(async (req, res) => {
92
- PolicyLoader.invalidateCache();
93
- const policies = await PolicyLoader.load(true, req.supabase);
127
+ if (!req.supabase || !req.user) {
128
+ res.status(401).json({ success: false, error: "Authentication required" });
129
+ return;
130
+ }
131
+ if (!req.workspaceId) {
132
+ res.status(403).json({ success: false, error: "Workspace membership required" });
133
+ return;
134
+ }
135
+ PolicyLoader.invalidateCache(req.workspaceId);
136
+ const policies = await PolicyLoader.load(true, req.supabase, req.workspaceId);
94
137
  res.json({ success: true, count: policies.length });
95
138
  })
96
139
  );
@@ -20,33 +20,37 @@ router.get("/", async (req, res) => {
20
20
  }
21
21
 
22
22
  try {
23
- const userId = req.user.id;
23
+ const workspaceId = req.workspaceId;
24
+ if (!workspaceId) {
25
+ res.status(403).json({ success: false, error: "Workspace membership required" });
26
+ return;
27
+ }
24
28
  const s = req.supabase; // the scoped service client
25
29
 
26
30
  // 1. Total Documents Ingested
27
31
  const { count: totalDocumentsCount, error: err1 } = await s
28
32
  .from("ingestions")
29
33
  .select("*", { count: 'exact', head: true })
30
- .eq("user_id", userId);
34
+ .eq("workspace_id", workspaceId);
31
35
 
32
36
  // 2. Active Policies
33
37
  const { count: activePoliciesCount, error: err2 } = await s
34
38
  .from("policies")
35
39
  .select("*", { count: 'exact', head: true })
36
- .eq("user_id", userId)
40
+ .eq("workspace_id", workspaceId)
37
41
  .eq("enabled", true);
38
42
 
39
43
  // 3. RAG Knowledge Base (Chunks)
40
44
  const { count: ragChunksCount, error: err3 } = await s
41
45
  .from("document_chunks")
42
46
  .select("*", { count: 'exact', head: true })
43
- .eq("user_id", userId);
47
+ .eq("workspace_id", workspaceId);
44
48
 
45
49
  // 4. Automation Runs (Sum of actions taken across ingestions)
46
50
  const { data: ingestionsWithActions, error: err4 } = await s
47
51
  .from("ingestions")
48
52
  .select("actions_taken")
49
- .eq("user_id", userId)
53
+ .eq("workspace_id", workspaceId)
50
54
  .not("actions_taken", "is", null);
51
55
 
52
56
  let automationRunsCount = 0;