gufi-cli 0.1.49 → 0.1.51

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 (36) hide show
  1. package/dist/commands/docs.js +1 -5
  2. package/dist/lib/docs-resolver.d.ts +8 -0
  3. package/dist/lib/docs-resolver.js +27 -0
  4. package/dist/lib/security.js +5 -0
  5. package/dist/mcp.js +56 -43
  6. package/docs/dev-guide/1-01-architecture.md +358 -0
  7. package/docs/dev-guide/1-02-multi-tenant.md +415 -0
  8. package/docs/dev-guide/1-03-column-types.md +594 -0
  9. package/docs/dev-guide/1-04-json-config.md +442 -0
  10. package/docs/dev-guide/1-05-authentication.md +427 -0
  11. package/docs/dev-guide/2-01-api-reference.md +564 -0
  12. package/docs/dev-guide/2-02-automations.md +508 -0
  13. package/docs/dev-guide/2-03-gufi-cli.md +568 -0
  14. package/docs/dev-guide/2-04-realtime.md +401 -0
  15. package/docs/dev-guide/2-05-permissions.md +497 -0
  16. package/docs/dev-guide/2-06-integrations-overview.md +104 -0
  17. package/docs/dev-guide/2-07-stripe.md +173 -0
  18. package/docs/dev-guide/2-08-nayax.md +297 -0
  19. package/docs/dev-guide/2-09-ourvend.md +226 -0
  20. package/docs/dev-guide/2-10-tns.md +177 -0
  21. package/docs/dev-guide/2-11-custom-http.md +268 -0
  22. package/docs/dev-guide/3-01-custom-views.md +555 -0
  23. package/docs/dev-guide/3-02-webhooks-api.md +446 -0
  24. package/docs/mcp/00-overview.md +329 -0
  25. package/docs/mcp/01-architecture.md +226 -0
  26. package/docs/mcp/02-modules.md +285 -0
  27. package/docs/mcp/03-fields.md +357 -0
  28. package/docs/mcp/04-views.md +613 -0
  29. package/docs/mcp/05-automations.md +461 -0
  30. package/docs/mcp/06-api.md +531 -0
  31. package/docs/mcp/07-packages.md +246 -0
  32. package/docs/mcp/08-common-errors.md +284 -0
  33. package/docs/mcp/09-examples.md +453 -0
  34. package/docs/mcp/README.md +71 -0
  35. package/docs/mcp/tool-descriptions.json +64 -0
  36. package/package.json +3 -2
@@ -0,0 +1,415 @@
1
+ ---
2
+ id: multi-tenant
3
+ title: "Multi-Tenant System"
4
+ description: "How Gufi isolates company data"
5
+ icon: Building2
6
+ category: dev
7
+ part: 1
8
+ ---
9
+
10
+ # Multi-Tenant System
11
+
12
+ How Gufi isolates company data
13
+
14
+ ## Overview
15
+
16
+ Gufi uses **schema-based multi-tenancy** where each company has its own PostgreSQL schema. This provides complete data isolation with excellent performance.
17
+
18
+ ## Schema Architecture
19
+
20
+ ### Core Schema
21
+
22
+ The `core` schema contains platform-wide data:
23
+
24
+ ```sql
25
+ -- Users (can belong to multiple companies)
26
+ core.users
27
+ ├── id
28
+ ├── email
29
+ ├── name
30
+ ├── password_hash
31
+ ├── platform_role -- admin, consultant, client
32
+ └── created_at
33
+
34
+ -- Companies
35
+ core.companies
36
+ ├── id
37
+ ├── name
38
+ ├── settings (jsonb)
39
+ └── created_at
40
+
41
+ -- User-Company membership
42
+ core.company_users
43
+ ├── user_id
44
+ ├── company_id
45
+ ├── roles (jsonb array)
46
+ └── is_active
47
+
48
+ -- Module definitions (templates)
49
+ core.modules
50
+ ├── id
51
+ ├── company_id
52
+ ├── name
53
+ ├── config (jsonb)
54
+ └── created_at
55
+
56
+ -- Entity definitions
57
+ core.entities
58
+ ├── id
59
+ ├── module_id
60
+ ├── name
61
+ ├── automations (jsonb)
62
+ └── fields (jsonb)
63
+ ```
64
+
65
+ ### Company Schemas
66
+
67
+ Each company gets a dedicated schema:
68
+
69
+ ```sql
70
+ -- Company 146 has schema: company_146
71
+ company_146.m360_t4589 -- Products table
72
+ company_146.m360_t4590 -- Categories table
73
+ company_146.m361_t4600 -- Customers table
74
+ company_146.__audit_log__ -- Audit trail
75
+ ```
76
+
77
+ ### Naming Convention
78
+
79
+ Tables follow the pattern `m{moduleId}_t{entityId}`:
80
+
81
+ ```
82
+ m360_t4589
83
+ │ └── Entity ID 4589
84
+ └── Module ID 360
85
+ ```
86
+
87
+ This ensures:
88
+ - Unique table names across modules
89
+ - Easy mapping to entity definitions
90
+ - No name collisions between modules
91
+
92
+ ## Request Flow
93
+
94
+ ### JWT Contains Company
95
+
96
+ Every authenticated request includes company context:
97
+
98
+ ```json
99
+ {
100
+ "sub": "user_123",
101
+ "company_id": 146,
102
+ "roles": ["Admin"],
103
+ "exp": 1699999999
104
+ }
105
+ ```
106
+
107
+ ### Backend Processing
108
+
109
+ ```javascript
110
+ // Middleware extracts and sets context
111
+ async function setTenantContext(req, res, next) {
112
+ const companyId = req.user.company_id;
113
+
114
+ // Get connection from pool
115
+ const client = await pool.connect();
116
+
117
+ // Set schema search path
118
+ await client.query(
119
+ `SET search_path TO company_${companyId}, core, public`
120
+ );
121
+
122
+ req.db = client;
123
+ next();
124
+ }
125
+ ```
126
+
127
+ ### Query Execution
128
+
129
+ With search_path set, queries automatically scope to company:
130
+
131
+ ```sql
132
+ -- Developer writes:
133
+ SELECT * FROM m360_t4589 WHERE status = 'active';
134
+
135
+ -- PostgreSQL resolves to:
136
+ SELECT * FROM company_146.m360_t4589 WHERE status = 'active';
137
+ ```
138
+
139
+ ## Data Isolation Guarantees
140
+
141
+ ### Schema Separation
142
+
143
+ | Guarantee | Implementation |
144
+ |---|---|
145
+ | **No cross-company queries** | search_path prevents access |
146
+ | **No data leakage** | Separate schemas, separate tables |
147
+ | **Backup isolation** | Per-schema backups possible |
148
+ | **Performance isolation** | Indexes are per-schema |
149
+
150
+ ### Security Layers
151
+
152
+ ```
153
+ 1. JWT Validation
154
+ └── Company ID extracted from token
155
+
156
+ 2. Middleware Check
157
+ └── User belongs to company verified
158
+
159
+ 3. Schema Switch
160
+ └── search_path set to company schema
161
+
162
+ 4. Query Execution
163
+ └── Only company data visible
164
+
165
+ 5. Response
166
+ └── Data returned (already filtered)
167
+ ```
168
+
169
+ ### What This Prevents
170
+
171
+ ```javascript
172
+ // This is IMPOSSIBLE:
173
+ // User from Company A querying Company B data
174
+
175
+ // Even if an attacker modified the query to:
176
+ SELECT * FROM company_200.products;
177
+
178
+ // They would get:
179
+ ERROR: permission denied for schema company_200
180
+ ```
181
+
182
+ ## Company Creation
183
+
184
+ ### Schema Provisioning
185
+
186
+ When a new company is created:
187
+
188
+ ```javascript
189
+ async function createCompanySchema(companyId) {
190
+ const schemaName = `company_${companyId}`;
191
+
192
+ // Create schema
193
+ await pool.query(`CREATE SCHEMA ${schemaName}`);
194
+
195
+ // Create system tables
196
+ await pool.query(`
197
+ CREATE TABLE ${schemaName}.__audit_log__ (
198
+ id SERIAL PRIMARY KEY,
199
+ table_name TEXT,
200
+ record_id INTEGER,
201
+ action TEXT,
202
+ old_data JSONB,
203
+ new_data JSONB,
204
+ user_id INTEGER,
205
+ created_at TIMESTAMP DEFAULT NOW()
206
+ )
207
+ `);
208
+
209
+ // Set up triggers for audit
210
+ // ... (trigger creation)
211
+ }
212
+ ```
213
+
214
+ ### Module Installation
215
+
216
+ When a module is installed for a company:
217
+
218
+ ```javascript
219
+ async function installModule(companyId, moduleConfig) {
220
+ const schemaName = `company_${companyId}`;
221
+
222
+ for (const entity of moduleConfig.entities) {
223
+ const tableName = `m${moduleConfig.id}_t${entity.id}`;
224
+
225
+ // Create table with fields
226
+ const columns = entity.fields.map(f =>
227
+ `${f.name} ${mapToPostgresType(f.type)}`
228
+ ).join(', ');
229
+
230
+ await pool.query(`
231
+ CREATE TABLE ${schemaName}.${tableName} (
232
+ id SERIAL PRIMARY KEY,
233
+ ${columns},
234
+ created_at TIMESTAMP DEFAULT NOW(),
235
+ updated_at TIMESTAMP DEFAULT NOW(),
236
+ created_by INTEGER,
237
+ updated_by INTEGER
238
+ )
239
+ `);
240
+
241
+ // Create indexes, triggers, etc.
242
+ }
243
+ }
244
+ ```
245
+
246
+ ## Logical vs Physical Names
247
+
248
+ ### The Mapping
249
+
250
+ Users see logical names, database uses physical names:
251
+
252
+ | Logical Name | Physical Name | Stored In |
253
+ |---|---|---|
254
+ | products | m360_t4589 | core.entities |
255
+ | categories | m360_t4590 | core.entities |
256
+ | customers | m361_t4600 | core.entities |
257
+
258
+ ### Resolution Process
259
+
260
+ ```javascript
261
+ // Frontend requests using logical name
262
+ GET /api/tables/products
263
+
264
+ // Backend resolves to physical name
265
+ async function resolveTableName(companyId, logicalName) {
266
+ const result = await pool.query(`
267
+ SELECT
268
+ CONCAT('m', m.id, '_t', e.id) as physical_name
269
+ FROM core.entities e
270
+ JOIN core.modules m ON e.module_id = m.id
271
+ WHERE m.company_id = $1 AND e.name = $2
272
+ `, [companyId, logicalName]);
273
+
274
+ return result.rows[0]?.physical_name;
275
+ }
276
+
277
+ // Query executed against physical table
278
+ SELECT * FROM company_146.m360_t4589;
279
+ ```
280
+
281
+ ### Benefits
282
+
283
+ 1. **No Name Collisions**: Two modules can have "products" entity
284
+ 2. **Renaming**: Change display name without migrations
285
+ 3. **Module Portability**: Same module works across companies
286
+ 4. **Clear Ownership**: Table name shows which module owns it
287
+
288
+ ## Cross-Company Operations
289
+
290
+ ### Platform Admin Access
291
+
292
+ Platform admins can query across companies:
293
+
294
+ ```javascript
295
+ // Only for platform_role = 'admin'
296
+ async function getCrossCompanyReport() {
297
+ // Temporarily use all schemas
298
+ const companies = await pool.query(
299
+ 'SELECT id FROM core.companies'
300
+ );
301
+
302
+ const results = [];
303
+ for (const company of companies.rows) {
304
+ await pool.query(
305
+ `SET search_path TO company_${company.id}`
306
+ );
307
+
308
+ const data = await pool.query(
309
+ 'SELECT COUNT(*) FROM m360_t4589'
310
+ );
311
+ results.push({ companyId: company.id, count: data.rows[0].count });
312
+ }
313
+
314
+ return results;
315
+ }
316
+ ```
317
+
318
+ ### Data Migration
319
+
320
+ Moving data between companies (rare):
321
+
322
+ ```javascript
323
+ // Export from source
324
+ SET search_path TO company_100;
325
+ COPY products TO '/tmp/products.csv' CSV;
326
+
327
+ // Import to destination
328
+ SET search_path TO company_200;
329
+ COPY products FROM '/tmp/products.csv' CSV;
330
+ ```
331
+
332
+ ## Performance Considerations
333
+
334
+ ### Schema Benefits
335
+
336
+ - **Index isolation**: Each company's indexes are independent
337
+ - **Statistics per schema**: Query planner optimizes per-company
338
+ - **Vacuum per schema**: Maintenance can be targeted
339
+
340
+ ### Pooling Strategy
341
+
342
+ ```javascript
343
+ // Connection pool is shared
344
+ const pool = new Pool({
345
+ max: 100, // Total connections
346
+ // ...
347
+ });
348
+
349
+ // But each request sets its own search_path
350
+ // No connection affinity needed
351
+ ```
352
+
353
+ ### Caching
354
+
355
+ Schema is cached per company:
356
+
357
+ ```javascript
358
+ const schemaCache = new Map();
359
+
360
+ async function getCompanySchema(companyId) {
361
+ const cacheKey = `schema:${companyId}`;
362
+
363
+ let schema = schemaCache.get(cacheKey);
364
+ if (!schema) {
365
+ schema = await loadSchemaFromDb(companyId);
366
+ schemaCache.set(cacheKey, schema);
367
+ }
368
+
369
+ return schema;
370
+ }
371
+ ```
372
+
373
+ ## Best Practices
374
+
375
+ ### Always Use search_path
376
+
377
+ ```javascript
378
+ // Good
379
+ await client.query('SET search_path TO company_${id}');
380
+ await client.query('SELECT * FROM products');
381
+
382
+ // Bad - never hardcode schema in queries
383
+ await client.query('SELECT * FROM company_${id}.products');
384
+ ```
385
+
386
+ ### Clean Up Connections
387
+
388
+ ```javascript
389
+ async function handleRequest(req, res) {
390
+ const client = await pool.connect();
391
+ try {
392
+ await client.query(`SET search_path TO company_${req.companyId}`);
393
+ // ... do work ...
394
+ } finally {
395
+ await client.query('RESET search_path');
396
+ client.release();
397
+ }
398
+ }
399
+ ```
400
+
401
+ ### Validate Company Access
402
+
403
+ ```javascript
404
+ // Before any operation
405
+ async function validateAccess(userId, companyId) {
406
+ const membership = await pool.query(`
407
+ SELECT 1 FROM core.company_users
408
+ WHERE user_id = $1 AND company_id = $2 AND is_active = true
409
+ `, [userId, companyId]);
410
+
411
+ if (membership.rows.length === 0) {
412
+ throw new ForbiddenError('Access denied');
413
+ }
414
+ }
415
+ ```