@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
@@ -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
  );
@@ -1,4 +1,6 @@
1
1
  import { RealtimeXSDK, ProvidersResponse } from "@realtimex/sdk";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
 
3
5
  import { createLogger } from "../utils/logger.js";
4
6
 
@@ -48,7 +50,6 @@ export class SDKService {
48
50
 
49
51
  logger.info("RealTimeX SDK initialized successfully");
50
52
 
51
- // @ts-ignore ping available in desktop bridge
52
53
  this.instance.ping?.().catch(() => {
53
54
  logger.warn("Desktop ping failed during startup");
54
55
  });
@@ -77,7 +78,6 @@ export class SDKService {
77
78
 
78
79
  // Try to ping first (faster)
79
80
  try {
80
- // @ts-ignore ping available in desktop bridge
81
81
  await sdk.ping();
82
82
  return true;
83
83
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -111,6 +111,51 @@ export class SDKService {
111
111
  }
112
112
  }
113
113
 
114
+ private static appDataDir: string | null = null;
115
+
116
+ static async getAppDataDir(): Promise<string | null> {
117
+ if (this.appDataDir) {
118
+ return this.appDataDir;
119
+ }
120
+
121
+ const sdk = this.getSDK();
122
+ if (!sdk) {
123
+ return null;
124
+ }
125
+
126
+ try {
127
+ const dataDir = await this.withTimeout<string>(
128
+ sdk.getAppDataDir(),
129
+ 10000,
130
+ "App data directory fetch timed out"
131
+ );
132
+
133
+ if (!dataDir || !dataDir.trim()) {
134
+ return null;
135
+ }
136
+
137
+ this.appDataDir = dataDir;
138
+ return dataDir;
139
+ } catch (error) {
140
+ logger.warn("Failed to get app data directory from SDK", {
141
+ error: error instanceof Error ? error.message : String(error),
142
+ });
143
+ return null;
144
+ }
145
+ }
146
+
147
+ static async getDefaultDropzoneDir(): Promise<string> {
148
+ const sdkDataDir = await this.getAppDataDir();
149
+ if (sdkDataDir) {
150
+ return path.join(sdkDataDir, "dropzone");
151
+ }
152
+
153
+ const sdkAppId = this.getSDK()?.appId?.trim();
154
+ const envAppId = process.env.RTX_APP_ID?.trim();
155
+ const fallbackAppId = sdkAppId || envAppId || "folio";
156
+ return path.join(os.homedir(), ".realtimex.ai", "Resources", "local-apps", fallbackAppId, "dropzone");
157
+ }
158
+
114
159
  // Cache for default providers (avoid repeated SDK calls)
115
160
  private static defaultChatProvider: ProviderResult | null = null;
116
161
  private static defaultEmbedProvider: ProviderResult | null = null;
@@ -243,6 +288,7 @@ export class SDKService {
243
288
  }
244
289
 
245
290
  static clearProviderCache(): void {
291
+ this.appDataDir = null;
246
292
  this.defaultChatProvider = null;
247
293
  this.defaultEmbedProvider = null;
248
294
  }
@@ -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;