@realtimex/folio 0.1.16 → 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 (47) 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 +45 -5
  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/dist/api/src/middleware/auth.js +59 -0
  15. package/dist/api/src/routes/chat.js +1 -1
  16. package/dist/api/src/routes/index.js +2 -0
  17. package/dist/api/src/routes/ingestions.js +45 -8
  18. package/dist/api/src/routes/policies.js +49 -7
  19. package/dist/api/src/routes/stats.js +9 -5
  20. package/dist/api/src/routes/workspaces.js +220 -0
  21. package/dist/api/src/services/ChatService.js +7 -2
  22. package/dist/api/src/services/IngestionService.js +35 -30
  23. package/dist/api/src/services/PolicyEngine.js +2 -1
  24. package/dist/api/src/services/PolicyLearningService.js +28 -6
  25. package/dist/api/src/services/PolicyLoader.js +29 -25
  26. package/dist/api/src/services/RAGService.js +43 -11
  27. package/dist/assets/index-CTn5FcC4.js +113 -0
  28. package/dist/assets/index-Dq9sxoZK.css +1 -0
  29. package/dist/index.html +2 -2
  30. package/package.json +1 -1
  31. package/supabase/functions/workspace-invite/index.ts +110 -0
  32. package/supabase/migrations/20260223000000_initial_foundation.sql +5 -0
  33. package/supabase/migrations/20260224000004_add_avatars_storage.sql +4 -0
  34. package/supabase/migrations/20260224000006_add_policies_table.sql +5 -0
  35. package/supabase/migrations/20260224000008_add_ingestions_table.sql +2 -0
  36. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +17 -4
  37. package/supabase/migrations/20260225000003_add_baseline_configs.sql +4 -3
  38. package/supabase/migrations/20260226000000_add_processing_events.sql +1 -0
  39. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +1 -0
  40. package/supabase/migrations/20260226000005_add_chat_tables.sql +3 -0
  41. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +4 -0
  42. package/supabase/migrations/20260302064608_add_ingestion_llm_settings_compat.sql +15 -0
  43. package/supabase/migrations/20260303000000_add_workspaces_phase1.sql +459 -0
  44. package/supabase/migrations/20260303010000_add_workspace_management_rpc.sql +310 -0
  45. package/supabase/migrations/20260303020000_workspace_scope_document_chunks.sql +139 -0
  46. package/dist/assets/index-DzN8-j-e.css +0 -1
  47. package/dist/assets/index-dnBz6SWG.js +0 -113
@@ -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;
@@ -22,10 +22,14 @@ router.get(
22
22
  res.status(401).json({ success: false, error: "Authentication required" });
23
23
  return;
24
24
  }
25
+ if (!req.workspaceId) {
26
+ res.status(403).json({ success: false, error: "Workspace membership required" });
27
+ return;
28
+ }
25
29
  const page = Math.max(1, parseInt(req.query["page"] as string) || 1);
26
30
  const pageSize = Math.min(100, Math.max(1, parseInt(req.query["pageSize"] as string) || 20));
27
31
  const query = (req.query["q"] as string | undefined)?.trim() || undefined;
28
- 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 });
29
33
  res.json({ success: true, ingestions, total, page, pageSize });
30
34
  })
31
35
  );
@@ -38,7 +42,11 @@ router.get(
38
42
  res.status(401).json({ success: false, error: "Authentication required" });
39
43
  return;
40
44
  }
41
- 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);
42
50
  if (!ingestion) {
43
51
  res.status(404).json({ success: false, error: "Not found" });
44
52
  return;
@@ -56,6 +64,10 @@ router.post(
56
64
  res.status(401).json({ success: false, error: "Authentication required" });
57
65
  return;
58
66
  }
67
+ if (!req.workspaceId) {
68
+ res.status(403).json({ success: false, error: "Workspace membership required" });
69
+ return;
70
+ }
59
71
  const file = req.file;
60
72
  if (!file) {
61
73
  res.status(400).json({ success: false, error: "No file uploaded" });
@@ -90,6 +102,7 @@ router.post(
90
102
  const ingestion = await IngestionService.ingest({
91
103
  supabase: req.supabase,
92
104
  userId: req.user.id,
105
+ workspaceId: req.workspaceId,
93
106
  filename: file.originalname,
94
107
  mimeType: file.mimetype,
95
108
  fileSize: file.size,
@@ -111,7 +124,11 @@ router.post(
111
124
  res.status(401).json({ success: false, error: "Authentication required" });
112
125
  return;
113
126
  }
114
- 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);
115
132
  res.json({ success: true, matched });
116
133
  })
117
134
  );
@@ -124,6 +141,10 @@ router.post(
124
141
  res.status(401).json({ success: false, error: "Authentication required" });
125
142
  return;
126
143
  }
144
+ if (!req.workspaceId) {
145
+ res.status(403).json({ success: false, error: "Workspace membership required" });
146
+ return;
147
+ }
127
148
 
128
149
  const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
129
150
  if (!policyId) {
@@ -140,6 +161,7 @@ router.post(
140
161
  policyId,
141
162
  req.supabase,
142
163
  req.user.id,
164
+ req.workspaceId,
143
165
  {
144
166
  learn,
145
167
  rerun,
@@ -170,6 +192,10 @@ router.post(
170
192
  res.status(401).json({ success: false, error: "Authentication required" });
171
193
  return;
172
194
  }
195
+ if (!req.workspaceId) {
196
+ res.status(403).json({ success: false, error: "Workspace membership required" });
197
+ return;
198
+ }
173
199
 
174
200
  const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
175
201
  if (!policyId) {
@@ -183,6 +209,7 @@ router.post(
183
209
  policyId,
184
210
  req.supabase,
185
211
  req.user.id,
212
+ req.workspaceId,
186
213
  {
187
214
  provider: typeof req.body?.provider === "string" ? req.body.provider : undefined,
188
215
  model: typeof req.body?.model === "string" ? req.body.model : undefined,
@@ -212,6 +239,10 @@ router.post(
212
239
  res.status(401).json({ success: false, error: "Authentication required" });
213
240
  return;
214
241
  }
242
+ if (!req.workspaceId) {
243
+ res.status(403).json({ success: false, error: "Workspace membership required" });
244
+ return;
245
+ }
215
246
  const { data: settingsRow } = await req.supabase
216
247
  .from("user_settings")
217
248
  .select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model")
@@ -224,6 +255,7 @@ router.post(
224
255
  req.params["id"] as string,
225
256
  req.supabase,
226
257
  req.user.id,
258
+ req.workspaceId,
227
259
  llmSettings
228
260
  );
229
261
  res.json({ success: true, summary });
@@ -238,6 +270,10 @@ router.patch(
238
270
  res.status(401).json({ success: false, error: "Authentication required" });
239
271
  return;
240
272
  }
273
+ if (!req.workspaceId) {
274
+ res.status(403).json({ success: false, error: "Workspace membership required" });
275
+ return;
276
+ }
241
277
  const tags: unknown = req.body?.tags;
242
278
  if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
243
279
  res.status(400).json({ success: false, error: "tags must be an array of strings" });
@@ -248,7 +284,7 @@ router.patch(
248
284
  .from("ingestions")
249
285
  .update({ tags: normalized })
250
286
  .eq("id", req.params["id"] as string)
251
- .eq("user_id", req.user.id);
287
+ .eq("workspace_id", req.workspaceId);
252
288
  if (error) {
253
289
  res.status(500).json({ success: false, error: error.message });
254
290
  return;
@@ -265,7 +301,11 @@ router.delete(
265
301
  res.status(401).json({ success: false, error: "Authentication required" });
266
302
  return;
267
303
  }
268
- 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);
269
309
  if (!deleted) {
270
310
  res.status(404).json({ success: false, error: "Not found" });
271
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;