@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
@@ -276,11 +276,19 @@ export class PolicyLearningService {
276
276
  static async recordManualMatch(opts: {
277
277
  supabase: SupabaseClient;
278
278
  userId: string;
279
+ workspaceId?: string;
279
280
  ingestion: IngestionLike;
280
281
  policyId: string;
281
282
  policyName?: string;
282
283
  }): Promise<void> {
283
- const { supabase, userId, ingestion, policyId, policyName } = opts;
284
+ const { supabase, userId, workspaceId, ingestion, policyId, policyName } = opts;
285
+ if (!workspaceId) {
286
+ logger.warn("Skipping policy learning feedback: missing workspace context", {
287
+ ingestionId: ingestion.id,
288
+ policyId,
289
+ });
290
+ return;
291
+ }
284
292
  const features = buildFromIngestionRow(ingestion);
285
293
 
286
294
  if (features.tokens.length === 0) {
@@ -292,6 +300,7 @@ export class PolicyLearningService {
292
300
  }
293
301
 
294
302
  const row = {
303
+ workspace_id: workspaceId,
295
304
  user_id: userId,
296
305
  ingestion_id: ingestion.id,
297
306
  policy_id: policyId,
@@ -302,7 +311,7 @@ export class PolicyLearningService {
302
311
 
303
312
  const { error } = await supabase
304
313
  .from("policy_match_feedback")
305
- .upsert(row, { onConflict: "user_id,ingestion_id,policy_id" });
314
+ .upsert(row, { onConflict: "workspace_id,ingestion_id,policy_id" });
306
315
 
307
316
  if (error) {
308
317
  logger.error("Failed to save policy match feedback", {
@@ -323,15 +332,19 @@ export class PolicyLearningService {
323
332
  static async getPolicyLearningStats(opts: {
324
333
  supabase: SupabaseClient;
325
334
  userId: string;
335
+ workspaceId?: string;
326
336
  policyIds?: string[];
327
337
  }): Promise<PolicyLearningStats> {
328
- const { supabase, userId } = opts;
338
+ const { supabase, userId, workspaceId } = opts;
339
+ if (!workspaceId) {
340
+ return {};
341
+ }
329
342
  const normalizedPolicyIds = (opts.policyIds ?? []).map((id) => id.trim()).filter(Boolean);
330
343
 
331
344
  let query = supabase
332
345
  .from("policy_match_feedback")
333
346
  .select("policy_id,created_at")
334
- .eq("user_id", userId)
347
+ .eq("workspace_id", workspaceId)
335
348
  .order("created_at", { ascending: false })
336
349
  .limit(5000);
337
350
 
@@ -366,12 +379,13 @@ export class PolicyLearningService {
366
379
  static async resolveLearnedCandidate(opts: {
367
380
  supabase: SupabaseClient;
368
381
  userId: string;
382
+ workspaceId?: string;
369
383
  policyIds: string[];
370
384
  filePath: string;
371
385
  baselineEntities: Record<string, unknown>;
372
386
  documentText?: string;
373
387
  }): Promise<LearnedCandidateResolution> {
374
- const { supabase, userId, policyIds, filePath, baselineEntities, documentText } = opts;
388
+ const { supabase, userId, workspaceId, policyIds, filePath, baselineEntities, documentText } = opts;
375
389
  if (policyIds.length === 0) {
376
390
  return {
377
391
  candidate: null,
@@ -383,6 +397,17 @@ export class PolicyLearningService {
383
397
  },
384
398
  };
385
399
  }
400
+ if (!workspaceId) {
401
+ return {
402
+ candidate: null,
403
+ diagnostics: {
404
+ reason: "no_feedback_samples",
405
+ evaluatedPolicies: policyIds.length,
406
+ evaluatedSamples: 0,
407
+ topCandidates: [],
408
+ },
409
+ };
410
+ }
386
411
 
387
412
  const docFeatures = buildFromDocInput({ filePath, baselineEntities, documentText });
388
413
  if (docFeatures.tokens.length === 0) {
@@ -400,7 +425,7 @@ export class PolicyLearningService {
400
425
  const { data, error } = await supabase
401
426
  .from("policy_match_feedback")
402
427
  .select("policy_id,policy_name,features")
403
- .eq("user_id", userId)
428
+ .eq("workspace_id", workspaceId)
404
429
  .in("policy_id", policyIds)
405
430
  .order("created_at", { ascending: false })
406
431
  .limit(400);
@@ -63,7 +63,7 @@ export interface FolioPolicy {
63
63
  }
64
64
 
65
65
  // ─── Cache ───────────────────────────────────────────────────────────────────
66
- // Keyed by user_id so one user's policies never bleed into another's.
66
+ // Keyed by workspace_id so one workspace's policies never bleed into another's.
67
67
 
68
68
  const _cache = new Map<string, { policies: FolioPolicy[]; loadedAt: number }>();
69
69
  const CACHE_TTL_MS = 30_000;
@@ -90,21 +90,27 @@ function rowToPolicy(row: any): FolioPolicy {
90
90
  export class PolicyLoader {
91
91
 
92
92
  /**
93
- * Load all policies for the authenticated user from Supabase.
93
+ * Load all policies for the active workspace from Supabase.
94
94
  * Returns [] if no Supabase client is provided (unauthenticated state).
95
95
  */
96
- static async load(forceRefresh = false, supabase?: SupabaseClient | null): Promise<FolioPolicy[]> {
96
+ static async load(
97
+ forceRefresh = false,
98
+ supabase?: SupabaseClient | null,
99
+ workspaceId?: string
100
+ ): Promise<FolioPolicy[]> {
97
101
  if (!supabase) {
98
102
  logger.info("No Supabase client — policies require authentication");
99
103
  return [];
100
104
  }
101
105
 
102
- // Resolve the user ID to scope the cache correctly
103
- const { data: { user } } = await supabase.auth.getUser();
104
- const userId = user?.id ?? "anonymous";
106
+ const resolvedWorkspaceId = (workspaceId ?? "").trim();
107
+ if (!resolvedWorkspaceId) {
108
+ logger.warn("No workspace context returning empty policy set");
109
+ return [];
110
+ }
105
111
 
106
112
  const now = Date.now();
107
- const cached = _cache.get(userId);
113
+ const cached = _cache.get(resolvedWorkspaceId);
108
114
  if (!forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
109
115
  return cached.policies;
110
116
  }
@@ -113,14 +119,15 @@ export class PolicyLoader {
113
119
  const { data, error } = await supabase
114
120
  .from("policies")
115
121
  .select("*")
122
+ .eq("workspace_id", resolvedWorkspaceId)
116
123
  .eq("enabled", true)
117
124
  .order("priority", { ascending: false });
118
125
 
119
126
  if (error) throw error;
120
127
 
121
128
  const policies = (data ?? []).map(rowToPolicy);
122
- _cache.set(userId, { policies, loadedAt: Date.now() });
123
- logger.info(`Loaded ${policies.length} policies from DB for user ${userId}`);
129
+ _cache.set(resolvedWorkspaceId, { policies, loadedAt: Date.now() });
130
+ logger.info(`Loaded ${policies.length} policies from DB for workspace ${resolvedWorkspaceId}`);
124
131
  return policies;
125
132
  } catch (err) {
126
133
  logger.error("Failed to load policies from DB", { err });
@@ -128,9 +135,9 @@ export class PolicyLoader {
128
135
  }
129
136
  }
130
137
 
131
- static invalidateCache(userId?: string) {
132
- if (userId) {
133
- _cache.delete(userId);
138
+ static invalidateCache(workspaceId?: string) {
139
+ if (workspaceId) {
140
+ _cache.delete(workspaceId);
134
141
  } else {
135
142
  _cache.clear();
136
143
  }
@@ -152,12 +159,18 @@ export class PolicyLoader {
152
159
  * Save (upsert) a policy to Supabase.
153
160
  * Throws if no Supabase client is available.
154
161
  */
155
- static async save(policy: FolioPolicy, supabase?: SupabaseClient | null, userId?: string): Promise<string> {
156
- if (!supabase || !userId) {
162
+ static async save(
163
+ policy: FolioPolicy,
164
+ supabase?: SupabaseClient | null,
165
+ userId?: string,
166
+ workspaceId?: string
167
+ ): Promise<string> {
168
+ if (!supabase || !userId || !workspaceId) {
157
169
  throw new Error("Authentication required to save policies");
158
170
  }
159
171
 
160
172
  const row = {
173
+ workspace_id: workspaceId,
161
174
  user_id: userId,
162
175
  policy_id: policy.metadata.id,
163
176
  api_version: policy.apiVersion,
@@ -170,11 +183,11 @@ export class PolicyLoader {
170
183
 
171
184
  const { error } = await supabase
172
185
  .from("policies")
173
- .upsert(row, { onConflict: "user_id,policy_id" });
186
+ .upsert(row, { onConflict: "workspace_id,policy_id" });
174
187
 
175
188
  if (error) throw new Error(`Failed to save policy: ${error.message}`);
176
189
 
177
- this.invalidateCache();
190
+ this.invalidateCache(workspaceId);
178
191
  logger.info(`Saved policy to DB: ${policy.metadata.id}`);
179
192
  return `db:policies/${policy.metadata.id}`;
180
193
  }
@@ -186,9 +199,10 @@ export class PolicyLoader {
186
199
  policyId: string,
187
200
  patch: { enabled?: boolean; name?: string; description?: string; tags?: string[]; priority?: number },
188
201
  supabase?: SupabaseClient | null,
189
- userId?: string
202
+ userId?: string,
203
+ workspaceId?: string
190
204
  ): Promise<boolean> {
191
- if (!supabase || !userId) {
205
+ if (!supabase || !userId || !workspaceId) {
192
206
  throw new Error("Authentication required to update policies");
193
207
  }
194
208
 
@@ -196,7 +210,7 @@ export class PolicyLoader {
196
210
  .from("policies")
197
211
  .select("metadata, priority, enabled")
198
212
  .eq("policy_id", policyId)
199
- .eq("user_id", userId)
213
+ .eq("workspace_id", workspaceId)
200
214
  .single();
201
215
 
202
216
  if (fetchErr || !existing) throw new Error("Policy not found");
@@ -217,11 +231,11 @@ export class PolicyLoader {
217
231
  priority: patch.priority ?? existing.priority,
218
232
  })
219
233
  .eq("policy_id", policyId)
220
- .eq("user_id", userId);
234
+ .eq("workspace_id", workspaceId);
221
235
 
222
236
  if (error) throw new Error(`Failed to patch policy: ${error.message}`);
223
237
 
224
- this.invalidateCache();
238
+ this.invalidateCache(workspaceId);
225
239
  logger.info(`Patched policy: ${policyId}`);
226
240
  return true;
227
241
  }
@@ -230,8 +244,13 @@ export class PolicyLoader {
230
244
  * Delete a policy by ID from Supabase.
231
245
  * Throws if no Supabase client is available.
232
246
  */
233
- static async delete(policyId: string, supabase?: SupabaseClient | null, userId?: string): Promise<boolean> {
234
- if (!supabase || !userId) {
247
+ static async delete(
248
+ policyId: string,
249
+ supabase?: SupabaseClient | null,
250
+ userId?: string,
251
+ workspaceId?: string
252
+ ): Promise<boolean> {
253
+ if (!supabase || !userId || !workspaceId) {
235
254
  throw new Error("Authentication required to delete policies");
236
255
  }
237
256
 
@@ -239,11 +258,11 @@ export class PolicyLoader {
239
258
  .from("policies")
240
259
  .delete({ count: "exact" })
241
260
  .eq("policy_id", policyId)
242
- .eq("user_id", userId);
261
+ .eq("workspace_id", workspaceId);
243
262
 
244
263
  if (error) throw new Error(`Failed to delete policy: ${error.message}`);
245
264
 
246
- this.invalidateCache();
265
+ this.invalidateCache(workspaceId);
247
266
  return (count ?? 0) > 0;
248
267
  }
249
268
  }
@@ -133,7 +133,8 @@ export class RAGService {
133
133
  userId: string,
134
134
  rawText: string,
135
135
  supabase: SupabaseClient,
136
- settings?: EmbeddingSettings
136
+ settings?: EmbeddingSettings,
137
+ workspaceId?: string
137
138
  ): Promise<void> {
138
139
  if (/^\[VLM_(IMAGE|PDF)_DATA:/.test(rawText)) {
139
140
  logger.info(`Skipping chunking and embedding for VLM base64 multimodal data (Ingestion: ${ingestionId})`);
@@ -147,6 +148,22 @@ export class RAGService {
147
148
  }
148
149
 
149
150
  const resolvedModel = await this.resolveEmbeddingModel(settings || {});
151
+ let resolvedWorkspaceId = (workspaceId ?? "").trim();
152
+ if (!resolvedWorkspaceId) {
153
+ const { data: ingestionRow, error: ingestionLookupError } = await supabase
154
+ .from("ingestions")
155
+ .select("workspace_id")
156
+ .eq("id", ingestionId)
157
+ .maybeSingle();
158
+ if (ingestionLookupError) {
159
+ throw new Error(`Failed to resolve workspace for ingestion ${ingestionId}: ${ingestionLookupError.message}`);
160
+ }
161
+ resolvedWorkspaceId = String(ingestionRow?.workspace_id ?? "").trim();
162
+ if (!resolvedWorkspaceId) {
163
+ throw new Error(`Workspace context is required to index chunks for ingestion ${ingestionId}`);
164
+ }
165
+ }
166
+
150
167
  logger.info(
151
168
  `Extracted ${chunks.length} chunks for ingestion ${ingestionId}. Embedding with ${resolvedModel.provider}/${resolvedModel.model}...`
152
169
  );
@@ -164,6 +181,7 @@ export class RAGService {
164
181
  const { data: existing } = await supabase
165
182
  .from("document_chunks")
166
183
  .select("id")
184
+ .eq("workspace_id", resolvedWorkspaceId)
167
185
  .eq("ingestion_id", ingestionId)
168
186
  .eq("content_hash", hash)
169
187
  .eq("embedding_provider", resolvedModel.provider)
@@ -181,6 +199,7 @@ export class RAGService {
181
199
  const vector_dim = embedding.length;
182
200
 
183
201
  const { error } = await supabase.from("document_chunks").insert({
202
+ workspace_id: resolvedWorkspaceId,
184
203
  user_id: userId,
185
204
  ingestion_id: ingestionId,
186
205
  content,
@@ -212,6 +231,7 @@ export class RAGService {
212
231
  */
213
232
  private static async runSearchForModel(args: {
214
233
  userId: string;
234
+ workspaceId?: string;
215
235
  supabase: SupabaseClient;
216
236
  modelScope: ModelScope;
217
237
  queryEmbedding: number[];
@@ -219,17 +239,26 @@ export class RAGService {
219
239
  similarityThreshold: number;
220
240
  topK: number;
221
241
  }): Promise<RetrievedChunk[]> {
222
- const { userId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
242
+ const { userId, workspaceId, supabase, modelScope, queryEmbedding, queryDim, similarityThreshold, topK } = args;
223
243
 
224
- const { data, error } = await supabase.rpc("search_documents", {
225
- p_user_id: userId,
244
+ const basePayload = {
226
245
  p_embedding_provider: modelScope.provider,
227
246
  p_embedding_model: modelScope.model,
228
247
  query_embedding: queryEmbedding,
229
248
  match_threshold: similarityThreshold,
230
249
  match_count: topK,
231
250
  query_dim: queryDim
232
- });
251
+ };
252
+
253
+ const { data, error } = workspaceId
254
+ ? await supabase.rpc("search_workspace_documents", {
255
+ p_workspace_id: workspaceId,
256
+ ...basePayload
257
+ })
258
+ : await supabase.rpc("search_documents", {
259
+ p_user_id: userId,
260
+ ...basePayload
261
+ });
233
262
 
234
263
  if (error) {
235
264
  throw new Error(`Knowledge base search failed for ${modelScope.provider}/${modelScope.model}: ${error.message}`);
@@ -238,19 +267,27 @@ export class RAGService {
238
267
  return (data || []) as RetrievedChunk[];
239
268
  }
240
269
 
241
- private static async listUserModelScopes(
270
+ private static async listModelScopes(
242
271
  userId: string,
243
- supabase: SupabaseClient
272
+ supabase: SupabaseClient,
273
+ workspaceId?: string
244
274
  ): Promise<ModelScope[]> {
245
- const { data, error } = await supabase
275
+ let query = supabase
246
276
  .from("document_chunks")
247
277
  .select("embedding_provider, embedding_model, vector_dim, created_at")
248
- .eq("user_id", userId)
249
278
  .order("created_at", { ascending: false })
250
279
  .limit(2000);
251
280
 
281
+ if (workspaceId) {
282
+ query = query.eq("workspace_id", workspaceId);
283
+ } else {
284
+ query = query.eq("user_id", userId);
285
+ }
286
+
287
+ const { data, error } = await query;
288
+
252
289
  if (error) {
253
- logger.warn("Failed to list user embedding scopes for RAG fallback", { error });
290
+ logger.warn("Failed to list embedding scopes for RAG fallback", { userId, workspaceId, error });
254
291
  return [];
255
292
  }
256
293
 
@@ -284,12 +321,14 @@ export class RAGService {
284
321
  topK?: number;
285
322
  similarityThreshold?: number;
286
323
  settings?: EmbeddingSettings;
324
+ workspaceId?: string;
287
325
  } = {}
288
326
  ): Promise<RetrievedChunk[]> {
289
327
  const {
290
328
  topK = 5,
291
329
  similarityThreshold = 0.7,
292
- settings
330
+ settings,
331
+ workspaceId
293
332
  } = options;
294
333
 
295
334
  const minThreshold = Math.max(0.1, Math.min(similarityThreshold, 0.4));
@@ -325,6 +364,7 @@ export class RAGService {
325
364
 
326
365
  const hits = await this.runSearchForModel({
327
366
  userId,
367
+ workspaceId,
328
368
  supabase,
329
369
  modelScope: scope,
330
370
  queryEmbedding,
@@ -363,7 +403,7 @@ export class RAGService {
363
403
  }
364
404
 
365
405
  if (collected.size === 0) {
366
- const scopes = await this.listUserModelScopes(userId, supabase);
406
+ const scopes = await this.listModelScopes(userId, supabase, workspaceId);
367
407
  const fallbackScopes = scopes.filter(
368
408
  (scope) => !(scope.provider === preferredScope.provider && scope.model === preferredScope.model)
369
409
  );
@@ -14,6 +14,55 @@ function resolveSupabaseConfig(req) {
14
14
  }
15
15
  return headerConfig;
16
16
  }
17
+ function resolvePreferredWorkspaceId(req) {
18
+ const raw = req.headers["x-workspace-id"];
19
+ if (typeof raw === "string") {
20
+ const trimmed = raw.trim();
21
+ return trimmed.length > 0 ? trimmed : null;
22
+ }
23
+ if (Array.isArray(raw) && typeof raw[0] === "string") {
24
+ const trimmed = raw[0].trim();
25
+ return trimmed.length > 0 ? trimmed : null;
26
+ }
27
+ return null;
28
+ }
29
+ async function resolveWorkspaceContext(req, supabase, user) {
30
+ const preferredWorkspaceId = resolvePreferredWorkspaceId(req);
31
+ const { data, error } = await supabase
32
+ .from("workspace_members")
33
+ .select("workspace_id,role,status,created_at")
34
+ .eq("user_id", user.id)
35
+ .eq("status", "active")
36
+ .order("created_at", { ascending: true });
37
+ if (error) {
38
+ const errorCode = error.code;
39
+ // Backward compatibility: allow projects that haven't migrated yet.
40
+ if (errorCode === "42P01") {
41
+ return null;
42
+ }
43
+ throw new AuthorizationError(`Failed to resolve workspace membership: ${error.message}`);
44
+ }
45
+ const memberships = (data ?? []);
46
+ if (memberships.length === 0) {
47
+ return null;
48
+ }
49
+ if (preferredWorkspaceId) {
50
+ const preferred = memberships.find((membership) => membership.workspace_id === preferredWorkspaceId);
51
+ if (preferred) {
52
+ return {
53
+ workspaceId: preferred.workspace_id,
54
+ workspaceRole: preferred.role,
55
+ };
56
+ }
57
+ }
58
+ const active = memberships[0];
59
+ if (!active)
60
+ return null;
61
+ return {
62
+ workspaceId: active.workspace_id,
63
+ workspaceRole: active.role,
64
+ };
65
+ }
17
66
  export async function authMiddleware(req, _res, next) {
18
67
  try {
19
68
  const supabaseConfig = resolveSupabaseConfig(req);
@@ -38,6 +87,11 @@ export async function authMiddleware(req, _res, next) {
38
87
  });
39
88
  req.user = user;
40
89
  req.supabase = supabase;
90
+ const workspace = await resolveWorkspaceContext(req, supabase, user);
91
+ if (workspace) {
92
+ req.workspaceId = workspace.workspaceId;
93
+ req.workspaceRole = workspace.workspaceRole;
94
+ }
41
95
  Logger.setPersistence(supabase, user.id);
42
96
  return next();
43
97
  }
@@ -59,6 +113,11 @@ export async function authMiddleware(req, _res, next) {
59
113
  }
60
114
  req.user = user;
61
115
  req.supabase = supabase;
116
+ const workspace = await resolveWorkspaceContext(req, supabase, user);
117
+ if (workspace) {
118
+ req.workspaceId = workspace.workspaceId;
119
+ req.workspaceRole = workspace.workspaceRole;
120
+ }
62
121
  Logger.setPersistence(supabase, user.id);
63
122
  next();
64
123
  }
@@ -103,7 +103,7 @@ router.post("/message", asyncHandler(async (req, res) => {
103
103
  await req.supabase.from("chat_sessions").update({ title }).eq("id", normalizedSessionId);
104
104
  }
105
105
  try {
106
- const aiMessage = await ChatService.handleMessage(normalizedSessionId, req.user.id, trimmedContent, req.supabase);
106
+ const aiMessage = await ChatService.handleMessage(normalizedSessionId, req.user.id, trimmedContent, req.supabase, req.workspaceId);
107
107
  res.json({ success: true, message: aiMessage });
108
108
  }
109
109
  catch (error) {
@@ -13,6 +13,7 @@ import settingsRoutes from "./settings.js";
13
13
  import rulesRoutes from "./rules.js";
14
14
  import chatRoutes from "./chat.js";
15
15
  import statsRoutes from "./stats.js";
16
+ import workspaceRoutes from "./workspaces.js";
16
17
  const router = Router();
17
18
  router.use("/health", healthRoutes);
18
19
  router.use("/migrate", migrateRoutes);
@@ -28,4 +29,5 @@ router.use("/settings", settingsRoutes);
28
29
  router.use("/rules", rulesRoutes);
29
30
  router.use("/chat", chatRoutes);
30
31
  router.use("/stats", statsRoutes);
32
+ router.use("/workspaces", workspaceRoutes);
31
33
  export default router;
@@ -17,10 +17,14 @@ router.get("/", asyncHandler(async (req, res) => {
17
17
  res.status(401).json({ success: false, error: "Authentication required" });
18
18
  return;
19
19
  }
20
+ if (!req.workspaceId) {
21
+ res.status(403).json({ success: false, error: "Workspace membership required" });
22
+ return;
23
+ }
20
24
  const page = Math.max(1, parseInt(req.query["page"]) || 1);
21
25
  const pageSize = Math.min(100, Math.max(1, parseInt(req.query["pageSize"]) || 20));
22
26
  const query = req.query["q"]?.trim() || undefined;
23
- const { ingestions, total } = await IngestionService.list(req.supabase, req.user.id, { page, pageSize, query });
27
+ const { ingestions, total } = await IngestionService.list(req.supabase, req.workspaceId, { page, pageSize, query });
24
28
  res.json({ success: true, ingestions, total, page, pageSize });
25
29
  }));
26
30
  // GET /api/ingestions/:id — get single ingestion
@@ -29,7 +33,11 @@ router.get("/:id", asyncHandler(async (req, res) => {
29
33
  res.status(401).json({ success: false, error: "Authentication required" });
30
34
  return;
31
35
  }
32
- const ingestion = await IngestionService.get(req.params["id"], req.supabase, req.user.id);
36
+ if (!req.workspaceId) {
37
+ res.status(403).json({ success: false, error: "Workspace membership required" });
38
+ return;
39
+ }
40
+ const ingestion = await IngestionService.get(req.params["id"], req.supabase, req.workspaceId);
33
41
  if (!ingestion) {
34
42
  res.status(404).json({ success: false, error: "Not found" });
35
43
  return;
@@ -42,6 +50,10 @@ router.post("/upload", upload.single("file"), asyncHandler(async (req, res) => {
42
50
  res.status(401).json({ success: false, error: "Authentication required" });
43
51
  return;
44
52
  }
53
+ if (!req.workspaceId) {
54
+ res.status(403).json({ success: false, error: "Workspace membership required" });
55
+ return;
56
+ }
45
57
  const file = req.file;
46
58
  if (!file) {
47
59
  res.status(400).json({ success: false, error: "No file uploaded" });
@@ -70,6 +82,7 @@ router.post("/upload", upload.single("file"), asyncHandler(async (req, res) => {
70
82
  const ingestion = await IngestionService.ingest({
71
83
  supabase: req.supabase,
72
84
  userId: req.user.id,
85
+ workspaceId: req.workspaceId,
73
86
  filename: file.originalname,
74
87
  mimeType: file.mimetype,
75
88
  fileSize: file.size,
@@ -86,7 +99,11 @@ router.post("/:id/rerun", asyncHandler(async (req, res) => {
86
99
  res.status(401).json({ success: false, error: "Authentication required" });
87
100
  return;
88
101
  }
89
- const matched = await IngestionService.rerun(req.params["id"], req.supabase, req.user.id);
102
+ if (!req.workspaceId) {
103
+ res.status(403).json({ success: false, error: "Workspace membership required" });
104
+ return;
105
+ }
106
+ const matched = await IngestionService.rerun(req.params["id"], req.supabase, req.user.id, req.workspaceId);
90
107
  res.json({ success: true, matched });
91
108
  }));
92
109
  // POST /api/ingestions/:id/match — manually assign a policy and optionally learn from it
@@ -95,6 +112,10 @@ router.post("/:id/match", asyncHandler(async (req, res) => {
95
112
  res.status(401).json({ success: false, error: "Authentication required" });
96
113
  return;
97
114
  }
115
+ if (!req.workspaceId) {
116
+ res.status(403).json({ success: false, error: "Workspace membership required" });
117
+ return;
118
+ }
98
119
  const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
99
120
  if (!policyId) {
100
121
  res.status(400).json({ success: false, error: "policy_id is required" });
@@ -104,7 +125,7 @@ router.post("/:id/match", asyncHandler(async (req, res) => {
104
125
  const rerun = req.body?.rerun !== false;
105
126
  const allowSideEffects = req.body?.allow_side_effects === true;
106
127
  try {
107
- const ingestion = await IngestionService.matchToPolicy(req.params["id"], policyId, req.supabase, req.user.id, {
128
+ const ingestion = await IngestionService.matchToPolicy(req.params["id"], policyId, req.supabase, req.user.id, req.workspaceId, {
108
129
  learn,
109
130
  rerun,
110
131
  allowSideEffects,
@@ -130,13 +151,17 @@ router.post("/:id/refine-policy", asyncHandler(async (req, res) => {
130
151
  res.status(401).json({ success: false, error: "Authentication required" });
131
152
  return;
132
153
  }
154
+ if (!req.workspaceId) {
155
+ res.status(403).json({ success: false, error: "Workspace membership required" });
156
+ return;
157
+ }
133
158
  const policyId = typeof req.body?.policy_id === "string" ? req.body.policy_id.trim() : "";
134
159
  if (!policyId) {
135
160
  res.status(400).json({ success: false, error: "policy_id is required" });
136
161
  return;
137
162
  }
138
163
  try {
139
- const suggestion = await IngestionService.suggestPolicyRefinement(req.params["id"], policyId, req.supabase, req.user.id, {
164
+ const suggestion = await IngestionService.suggestPolicyRefinement(req.params["id"], policyId, req.supabase, req.user.id, req.workspaceId, {
140
165
  provider: typeof req.body?.provider === "string" ? req.body.provider : undefined,
141
166
  model: typeof req.body?.model === "string" ? req.body.model : undefined,
142
167
  });
@@ -161,13 +186,17 @@ router.post("/:id/summarize", asyncHandler(async (req, res) => {
161
186
  res.status(401).json({ success: false, error: "Authentication required" });
162
187
  return;
163
188
  }
189
+ if (!req.workspaceId) {
190
+ res.status(403).json({ success: false, error: "Workspace membership required" });
191
+ return;
192
+ }
164
193
  const { data: settingsRow } = await req.supabase
165
194
  .from("user_settings")
166
195
  .select("llm_provider, llm_model, ingestion_llm_provider, ingestion_llm_model")
167
196
  .eq("user_id", req.user.id)
168
197
  .maybeSingle();
169
198
  const llmSettings = IngestionService.resolveIngestionLlmSettings(settingsRow);
170
- const summary = await IngestionService.summarize(req.params["id"], req.supabase, req.user.id, llmSettings);
199
+ const summary = await IngestionService.summarize(req.params["id"], req.supabase, req.user.id, req.workspaceId, llmSettings);
171
200
  res.json({ success: true, summary });
172
201
  }));
173
202
  // PATCH /api/ingestions/:id/tags — replace tags array (human edits)
@@ -176,6 +205,10 @@ router.patch("/:id/tags", asyncHandler(async (req, res) => {
176
205
  res.status(401).json({ success: false, error: "Authentication required" });
177
206
  return;
178
207
  }
208
+ if (!req.workspaceId) {
209
+ res.status(403).json({ success: false, error: "Workspace membership required" });
210
+ return;
211
+ }
179
212
  const tags = req.body?.tags;
180
213
  if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
181
214
  res.status(400).json({ success: false, error: "tags must be an array of strings" });
@@ -186,7 +219,7 @@ router.patch("/:id/tags", asyncHandler(async (req, res) => {
186
219
  .from("ingestions")
187
220
  .update({ tags: normalized })
188
221
  .eq("id", req.params["id"])
189
- .eq("user_id", req.user.id);
222
+ .eq("workspace_id", req.workspaceId);
190
223
  if (error) {
191
224
  res.status(500).json({ success: false, error: error.message });
192
225
  return;
@@ -199,7 +232,11 @@ router.delete("/:id", asyncHandler(async (req, res) => {
199
232
  res.status(401).json({ success: false, error: "Authentication required" });
200
233
  return;
201
234
  }
202
- const deleted = await IngestionService.delete(req.params["id"], req.supabase, req.user.id);
235
+ if (!req.workspaceId) {
236
+ res.status(403).json({ success: false, error: "Workspace membership required" });
237
+ return;
238
+ }
239
+ const deleted = await IngestionService.delete(req.params["id"], req.supabase, req.workspaceId);
203
240
  if (!deleted) {
204
241
  res.status(404).json({ success: false, error: "Not found" });
205
242
  return;