@aphexcms/cms-core 0.1.3 → 0.1.5

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 (280) hide show
  1. package/dist/api/assets.d.ts +48 -0
  2. package/dist/api/assets.d.ts.map +1 -0
  3. package/dist/api/assets.js +52 -0
  4. package/dist/api/client.d.ts +37 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +125 -0
  7. package/dist/api/documents.d.ts +56 -0
  8. package/dist/api/documents.d.ts.map +1 -0
  9. package/dist/api/documents.js +77 -0
  10. package/dist/api/index.d.ts +7 -0
  11. package/dist/api/index.d.ts.map +1 -0
  12. package/dist/api/index.js +5 -0
  13. package/dist/api/organizations.d.ts +101 -0
  14. package/dist/api/organizations.d.ts.map +1 -0
  15. package/dist/api/organizations.js +92 -0
  16. package/dist/api/types.d.ts +23 -0
  17. package/dist/api/types.d.ts.map +1 -0
  18. package/dist/api/types.js +1 -0
  19. package/dist/app.d.ts +19 -0
  20. package/dist/auth/MULTI_TENANCY_PLAN.md +1183 -0
  21. package/dist/auth/auth-errors.d.ts +7 -0
  22. package/dist/auth/auth-errors.d.ts.map +1 -0
  23. package/dist/auth/auth-errors.js +13 -0
  24. package/dist/auth/auth-hooks.d.ts +6 -0
  25. package/dist/auth/auth-hooks.d.ts.map +1 -0
  26. package/dist/auth/auth-hooks.js +108 -0
  27. package/dist/auth/provider.d.ts +17 -0
  28. package/dist/auth/provider.d.ts.map +1 -0
  29. package/dist/auth/provider.js +1 -0
  30. package/dist/client/index.d.ts +24 -0
  31. package/dist/client/index.d.ts.map +1 -0
  32. package/dist/client/index.js +31 -0
  33. package/dist/components/AdminApp.svelte +1077 -0
  34. package/dist/components/AdminApp.svelte.d.ts +24 -0
  35. package/dist/components/AdminApp.svelte.d.ts.map +1 -0
  36. package/dist/components/admin/AdminLayout.svelte +115 -0
  37. package/dist/components/admin/AdminLayout.svelte.d.ts +15 -0
  38. package/dist/components/admin/AdminLayout.svelte.d.ts.map +1 -0
  39. package/dist/components/admin/DocumentEditor.svelte +795 -0
  40. package/dist/components/admin/DocumentEditor.svelte.d.ts +18 -0
  41. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -0
  42. package/dist/components/admin/DocumentTypesList.svelte +97 -0
  43. package/dist/components/admin/DocumentTypesList.svelte.d.ts +14 -0
  44. package/dist/components/admin/DocumentTypesList.svelte.d.ts.map +1 -0
  45. package/dist/components/admin/ObjectModal.svelte +135 -0
  46. package/dist/components/admin/ObjectModal.svelte.d.ts +15 -0
  47. package/dist/components/admin/ObjectModal.svelte.d.ts.map +1 -0
  48. package/dist/components/admin/SchemaField.svelte +171 -0
  49. package/dist/components/admin/SchemaField.svelte.d.ts +19 -0
  50. package/dist/components/admin/SchemaField.svelte.d.ts.map +1 -0
  51. package/dist/components/admin/fields/ArrayField.svelte +266 -0
  52. package/dist/components/admin/fields/ArrayField.svelte.d.ts +12 -0
  53. package/dist/components/admin/fields/ArrayField.svelte.d.ts.map +1 -0
  54. package/dist/components/admin/fields/BooleanField.svelte +35 -0
  55. package/dist/components/admin/fields/BooleanField.svelte.d.ts +13 -0
  56. package/dist/components/admin/fields/BooleanField.svelte.d.ts.map +1 -0
  57. package/dist/components/admin/fields/ImageField.svelte +284 -0
  58. package/dist/components/admin/fields/ImageField.svelte.d.ts +15 -0
  59. package/dist/components/admin/fields/ImageField.svelte.d.ts.map +1 -0
  60. package/dist/components/admin/fields/NumberField.svelte +82 -0
  61. package/dist/components/admin/fields/NumberField.svelte.d.ts +14 -0
  62. package/dist/components/admin/fields/NumberField.svelte.d.ts.map +1 -0
  63. package/dist/components/admin/fields/ReferenceField.svelte +260 -0
  64. package/dist/components/admin/fields/ReferenceField.svelte.d.ts +12 -0
  65. package/dist/components/admin/fields/ReferenceField.svelte.d.ts.map +1 -0
  66. package/dist/components/admin/fields/SlugField.svelte +74 -0
  67. package/dist/components/admin/fields/SlugField.svelte.d.ts +15 -0
  68. package/dist/components/admin/fields/SlugField.svelte.d.ts.map +1 -0
  69. package/dist/components/admin/fields/StringField.svelte +40 -0
  70. package/dist/components/admin/fields/StringField.svelte.d.ts +14 -0
  71. package/dist/components/admin/fields/StringField.svelte.d.ts.map +1 -0
  72. package/dist/components/admin/fields/TextareaField.svelte +40 -0
  73. package/dist/components/admin/fields/TextareaField.svelte.d.ts +14 -0
  74. package/dist/components/admin/fields/TextareaField.svelte.d.ts.map +1 -0
  75. package/dist/components/fields/index.d.ts +9 -0
  76. package/dist/components/fields/index.d.ts.map +1 -0
  77. package/dist/components/fields/index.js +9 -0
  78. package/dist/components/index.d.ts +7 -0
  79. package/dist/components/index.d.ts.map +1 -0
  80. package/dist/components/index.js +12 -0
  81. package/dist/components/layout/OrganizationSwitcher.svelte +218 -0
  82. package/dist/components/layout/OrganizationSwitcher.svelte.d.ts +11 -0
  83. package/dist/components/layout/OrganizationSwitcher.svelte.d.ts.map +1 -0
  84. package/dist/components/layout/Sidebar.svelte +88 -0
  85. package/dist/components/layout/Sidebar.svelte.d.ts +14 -0
  86. package/dist/components/layout/Sidebar.svelte.d.ts.map +1 -0
  87. package/dist/components/layout/sidebar/AppSidebar.svelte +63 -0
  88. package/dist/components/layout/sidebar/AppSidebar.svelte.d.ts +11 -0
  89. package/dist/components/layout/sidebar/AppSidebar.svelte.d.ts.map +1 -0
  90. package/dist/components/layout/sidebar/NavMain.svelte +95 -0
  91. package/dist/components/layout/sidebar/NavMain.svelte.d.ts +19 -0
  92. package/dist/components/layout/sidebar/NavMain.svelte.d.ts.map +1 -0
  93. package/dist/components/layout/sidebar/NavSecondary.svelte +69 -0
  94. package/dist/components/layout/sidebar/NavSecondary.svelte.d.ts +9 -0
  95. package/dist/components/layout/sidebar/NavSecondary.svelte.d.ts.map +1 -0
  96. package/dist/components/layout/sidebar/NavUser.svelte +85 -0
  97. package/dist/components/layout/sidebar/NavUser.svelte.d.ts +9 -0
  98. package/dist/components/layout/sidebar/NavUser.svelte.d.ts.map +1 -0
  99. package/dist/config.d.ts +3 -0
  100. package/dist/config.d.ts.map +1 -0
  101. package/dist/config.js +15 -0
  102. package/dist/db/adapters/index.d.ts +1 -0
  103. package/dist/db/adapters/index.d.ts.map +1 -0
  104. package/dist/db/adapters/index.js +4 -0
  105. package/dist/db/index.d.ts +2 -0
  106. package/dist/db/index.d.ts.map +1 -0
  107. package/dist/db/index.js +4 -0
  108. package/dist/db/interfaces/asset.d.ts +51 -0
  109. package/dist/db/interfaces/asset.d.ts.map +1 -0
  110. package/dist/db/interfaces/asset.js +1 -0
  111. package/dist/db/interfaces/document.d.ts +36 -0
  112. package/dist/db/interfaces/document.d.ts.map +1 -0
  113. package/dist/db/interfaces/document.js +1 -0
  114. package/dist/db/interfaces/index.d.ts +73 -0
  115. package/dist/db/interfaces/index.d.ts.map +1 -0
  116. package/dist/db/interfaces/index.js +1 -0
  117. package/dist/db/interfaces/organization.d.ts +27 -0
  118. package/dist/db/interfaces/organization.d.ts.map +1 -0
  119. package/dist/db/interfaces/organization.js +1 -0
  120. package/dist/db/interfaces/schema.d.ts +21 -0
  121. package/dist/db/interfaces/schema.d.ts.map +1 -0
  122. package/dist/db/interfaces/schema.js +1 -0
  123. package/dist/db/interfaces/user.d.ts +15 -0
  124. package/dist/db/interfaces/user.d.ts.map +1 -0
  125. package/dist/db/interfaces/user.js +1 -0
  126. package/dist/db/utils/reference-resolver.d.ts +18 -0
  127. package/dist/db/utils/reference-resolver.d.ts.map +1 -0
  128. package/dist/db/utils/reference-resolver.js +80 -0
  129. package/dist/define.d.ts +3 -0
  130. package/dist/define.d.ts.map +1 -0
  131. package/dist/define.js +4 -0
  132. package/dist/email/index.d.ts +2 -0
  133. package/dist/email/index.d.ts.map +1 -0
  134. package/dist/email/index.js +4 -0
  135. package/dist/email/interfaces/email.d.ts +42 -0
  136. package/dist/email/interfaces/email.d.ts.map +1 -0
  137. package/dist/email/interfaces/email.js +1 -0
  138. package/dist/engine.d.ts +26 -0
  139. package/dist/engine.d.ts.map +1 -0
  140. package/dist/engine.js +66 -0
  141. package/dist/field-validation/rule.d.ts +51 -0
  142. package/dist/field-validation/rule.d.ts.map +1 -0
  143. package/dist/field-validation/rule.js +221 -0
  144. package/dist/field-validation/utils.d.ts +21 -0
  145. package/dist/field-validation/utils.d.ts.map +1 -0
  146. package/dist/field-validation/utils.js +66 -0
  147. package/dist/hooks.d.ts +23 -0
  148. package/dist/hooks.d.ts.map +1 -0
  149. package/dist/hooks.js +96 -0
  150. package/dist/index.d.ts +2 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +4 -0
  153. package/dist/plugins/README.md +154 -0
  154. package/dist/routes/assets-by-id.d.ts +5 -0
  155. package/dist/routes/assets-by-id.d.ts.map +1 -0
  156. package/dist/routes/assets-by-id.js +138 -0
  157. package/dist/routes/assets-cdn.d.ts +3 -0
  158. package/dist/routes/assets-cdn.d.ts.map +1 -0
  159. package/dist/routes/assets-cdn.js +155 -0
  160. package/dist/routes/assets.d.ts +4 -0
  161. package/dist/routes/assets.d.ts.map +1 -0
  162. package/dist/routes/assets.js +94 -0
  163. package/dist/routes/documents-by-id.d.ts +5 -0
  164. package/dist/routes/documents-by-id.d.ts.map +1 -0
  165. package/dist/routes/documents-by-id.js +142 -0
  166. package/dist/routes/documents-publish.d.ts +4 -0
  167. package/dist/routes/documents-publish.d.ts.map +1 -0
  168. package/dist/routes/documents-publish.js +151 -0
  169. package/dist/routes/documents.d.ts +4 -0
  170. package/dist/routes/documents.d.ts.map +1 -0
  171. package/dist/routes/documents.js +131 -0
  172. package/dist/routes/index.d.ts +6 -0
  173. package/dist/routes/index.d.ts.map +1 -0
  174. package/dist/routes/index.js +10 -0
  175. package/dist/routes/organizations-by-id.d.ts +5 -0
  176. package/dist/routes/organizations-by-id.d.ts.map +1 -0
  177. package/dist/routes/organizations-by-id.js +187 -0
  178. package/dist/routes/organizations-invitations.d.ts +4 -0
  179. package/dist/routes/organizations-invitations.d.ts.map +1 -0
  180. package/dist/routes/organizations-invitations.js +125 -0
  181. package/dist/routes/organizations-members.d.ts +5 -0
  182. package/dist/routes/organizations-members.d.ts.map +1 -0
  183. package/dist/routes/organizations-members.js +206 -0
  184. package/dist/routes/organizations-switch.d.ts +3 -0
  185. package/dist/routes/organizations-switch.d.ts.map +1 -0
  186. package/dist/routes/organizations-switch.js +53 -0
  187. package/dist/routes/organizations.d.ts +4 -0
  188. package/dist/routes/organizations.d.ts.map +1 -0
  189. package/dist/routes/organizations.js +108 -0
  190. package/dist/routes/schemas-by-type.d.ts +3 -0
  191. package/dist/routes/schemas-by-type.d.ts.map +1 -0
  192. package/dist/routes/schemas-by-type.js +25 -0
  193. package/dist/routes/schemas.d.ts +3 -0
  194. package/dist/routes/schemas.d.ts.map +1 -0
  195. package/dist/routes/schemas.js +11 -0
  196. package/dist/routes-exports.d.ts +14 -0
  197. package/dist/routes-exports.d.ts.map +1 -0
  198. package/dist/routes-exports.js +19 -0
  199. package/dist/schema-context.svelte.d.ts +10 -0
  200. package/dist/schema-context.svelte.d.ts.map +1 -0
  201. package/dist/schema-context.svelte.js +18 -0
  202. package/dist/schema-utils/cleanup.d.ts +21 -0
  203. package/dist/schema-utils/cleanup.d.ts.map +1 -0
  204. package/dist/schema-utils/cleanup.js +80 -0
  205. package/dist/schema-utils/index.d.ts +4 -0
  206. package/dist/schema-utils/index.d.ts.map +1 -0
  207. package/dist/schema-utils/index.js +4 -0
  208. package/dist/schema-utils/utils.d.ts +30 -0
  209. package/dist/schema-utils/utils.d.ts.map +1 -0
  210. package/dist/schema-utils/utils.js +37 -0
  211. package/dist/schema-utils/validator.d.ts +6 -0
  212. package/dist/schema-utils/validator.d.ts.map +1 -0
  213. package/dist/schema-utils/validator.js +45 -0
  214. package/dist/server/index.d.ts +16 -0
  215. package/dist/server/index.d.ts.map +1 -0
  216. package/dist/server/index.js +28 -0
  217. package/dist/services/asset-service.d.ts +86 -0
  218. package/dist/services/asset-service.d.ts.map +1 -0
  219. package/dist/services/asset-service.js +187 -0
  220. package/dist/services/index.d.ts +3 -0
  221. package/dist/services/index.d.ts.map +1 -0
  222. package/dist/services/index.js +4 -0
  223. package/dist/storage/adapters/index.d.ts +2 -0
  224. package/dist/storage/adapters/index.d.ts.map +1 -0
  225. package/dist/storage/adapters/index.js +2 -0
  226. package/dist/storage/adapters/local-storage-adapter.d.ts +54 -0
  227. package/dist/storage/adapters/local-storage-adapter.d.ts.map +1 -0
  228. package/dist/storage/adapters/local-storage-adapter.js +187 -0
  229. package/dist/storage/index.d.ts +3 -0
  230. package/dist/storage/index.d.ts.map +1 -0
  231. package/dist/storage/index.js +6 -0
  232. package/dist/storage/interfaces/index.d.ts +2 -0
  233. package/dist/storage/interfaces/index.d.ts.map +1 -0
  234. package/dist/storage/interfaces/index.js +2 -0
  235. package/dist/storage/interfaces/storage.d.ts +91 -0
  236. package/dist/storage/interfaces/storage.d.ts.map +1 -0
  237. package/dist/storage/interfaces/storage.js +1 -0
  238. package/dist/storage/providers/storage.d.ts +43 -0
  239. package/dist/storage/providers/storage.d.ts.map +1 -0
  240. package/dist/storage/providers/storage.js +64 -0
  241. package/dist/types/asset.d.ts +73 -0
  242. package/dist/types/asset.d.ts.map +1 -0
  243. package/dist/types/asset.js +2 -0
  244. package/dist/types/auth.d.ts +50 -0
  245. package/dist/types/auth.d.ts.map +1 -0
  246. package/dist/types/auth.js +41 -0
  247. package/dist/types/config.d.ts +47 -0
  248. package/dist/types/config.d.ts.map +1 -0
  249. package/dist/types/config.js +1 -0
  250. package/dist/types/document.d.ts +34 -0
  251. package/dist/types/document.d.ts.map +1 -0
  252. package/dist/types/document.js +1 -0
  253. package/dist/types/index.d.ts +9 -0
  254. package/dist/types/index.d.ts.map +1 -0
  255. package/dist/types/index.js +8 -0
  256. package/dist/types/organization.d.ts +105 -0
  257. package/dist/types/organization.d.ts.map +1 -0
  258. package/dist/types/organization.js +3 -0
  259. package/dist/types/schemas.d.ts +114 -0
  260. package/dist/types/schemas.d.ts.map +1 -0
  261. package/dist/types/schemas.js +1 -0
  262. package/dist/types/sidebar.d.ts +33 -0
  263. package/dist/types/sidebar.d.ts.map +1 -0
  264. package/dist/types/sidebar.js +1 -0
  265. package/dist/types/user.d.ts +14 -0
  266. package/dist/types/user.d.ts.map +1 -0
  267. package/dist/types/user.js +1 -0
  268. package/dist/utils/content-hash.d.ts +22 -0
  269. package/dist/utils/content-hash.d.ts.map +1 -0
  270. package/dist/utils/content-hash.js +67 -0
  271. package/dist/utils/image-url.d.ts +88 -0
  272. package/dist/utils/image-url.d.ts.map +1 -0
  273. package/dist/utils/image-url.js +165 -0
  274. package/dist/utils/index.d.ts +6 -0
  275. package/dist/utils/index.d.ts.map +1 -0
  276. package/dist/utils/index.js +9 -0
  277. package/dist/utils/slug.d.ts +13 -0
  278. package/dist/utils/slug.d.ts.map +1 -0
  279. package/dist/utils/slug.js +30 -0
  280. package/package.json +11 -41
@@ -0,0 +1,1183 @@
1
+ # Multi-Tenancy Implementation Plan
2
+
3
+ **Goal**: Transform Aphex CMS from single-tenant to multi-tenant, enabling agencies to manage multiple client organizations with complete data isolation.
4
+
5
+ ---
6
+
7
+ ## ⚠️ Important: Soft Multi-Tenancy
8
+
9
+ **This implementation uses "soft multi-tenancy" (shared database with row-level filtering), NOT "true multi-tenancy" (database-per-tenant).**
10
+
11
+ ### What This Means:
12
+
13
+ - ✅ All organizations share the same database and compute resources
14
+ - ✅ Data isolation is enforced at the **application level** via `organizationId` filtering
15
+ - ✅ Suitable for 90% of use cases (agencies, freelancers, small-to-medium businesses)
16
+ - ❌ NOT suitable for enterprises requiring database-level isolation or dedicated infrastructure
17
+ - ❌ Noisy neighbor effects possible (one org's heavy queries can slow others)
18
+ - ❌ Cannot guarantee data residency (all data in same database/region)
19
+
20
+ ### When to Upgrade to True Multi-Tenancy:
21
+
22
+ - Enterprise clients with compliance requirements (GDPR, HIPAA)
23
+ - Organizations requiring guaranteed SLAs and dedicated resources
24
+ - High-security environments requiring database-level isolation
25
+ - Clients willing to pay 10-40x more for dedicated infrastructure
26
+
27
+ See [Enterprise Multi-Tenancy Considerations](#enterprise-multi-tenancy-considerations) for future evolution paths.
28
+
29
+ ---
30
+
31
+ ## Overview
32
+
33
+ ### Key Principles
34
+
35
+ - ✅ **Separate Organizations** - Each client gets their own isolated workspace
36
+ - ✅ **Super Admin Pattern** - First/designated users can create organizations
37
+ - ✅ **Many-to-Many** - Users can belong to multiple organizations with different roles
38
+ - ✅ **Active Organization** - Users work in one organization at a time (switchable)
39
+ - ✅ **Role-Based Access** - Owner > Admin > Editor > Viewer per organization
40
+ - ✅ **Complete Isolation** - Documents/assets are scoped by organization
41
+ - ✅ **Don't Touch Better Auth** - All extensions go in CMS tables
42
+
43
+ ### Architecture
44
+
45
+ ```
46
+ Better Auth (App Layer) CMS Core (Package Layer)
47
+ ├── user (authentication) ├── cms_organizations
48
+ ├── session ├── cms_organization_members
49
+ └── apikey ├── cms_invitations
50
+ ├── cms_user_sessions
51
+ ├── cms_documents (+ organizationId)
52
+ └── cms_assets (+ organizationId)
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Phase 1: Database Schema
58
+
59
+ ### New Tables
60
+
61
+ #### 1. Organizations
62
+
63
+ ```typescript
64
+ cms_organizations {
65
+ id: uuid PRIMARY KEY;
66
+ name: varchar(200) NOT NULL; // "Client A", "Agency Internal"
67
+ slug: varchar(100) UNIQUE NOT NULL; // "client-a", "agency-internal"
68
+ metadata: jsonb; // { logo, theme, website, settings }
69
+ createdBy: text NOT NULL; // User ID (super admin who created it)
70
+ createdAt: timestamp;
71
+ updatedAt: timestamp;
72
+ }
73
+ ```
74
+
75
+ **Purpose**: Store organization (client/project) data with branding/settings.
76
+
77
+ #### 2. Organization Members (Many-to-Many)
78
+
79
+ ```typescript
80
+ cms_organization_members {
81
+ id: uuid PRIMARY KEY;
82
+ organizationId: uuid NOT NULL → cms_organizations(id) CASCADE;
83
+ userId: text NOT NULL; // References Better Auth user
84
+ role: enum('owner', 'admin', 'editor', 'viewer') NOT NULL;
85
+ preferences: jsonb; // Org-specific user preferences
86
+ invitedBy: text; // User ID who invited this member
87
+ createdAt: timestamp;
88
+ updatedAt: timestamp;
89
+
90
+ UNIQUE(organizationId, userId); // One role per user per org
91
+ }
92
+ ```
93
+
94
+ **Purpose**: Junction table linking users to organizations with roles.
95
+
96
+ #### 3. Invitations
97
+
98
+ ```typescript
99
+ cms_invitations {
100
+ id: uuid PRIMARY KEY;
101
+ organizationId: uuid NOT NULL → cms_organizations(id) CASCADE;
102
+ email: varchar(255) NOT NULL;
103
+ role: enum('owner', 'admin', 'editor', 'viewer') NOT NULL;
104
+ token: text UNIQUE NOT NULL; // Crypto-random token (32 bytes)
105
+ invitedBy: text NOT NULL; // User ID of inviter
106
+ expiresAt: timestamp NOT NULL; // Default: now() + 7 days
107
+ acceptedAt: timestamp; // Null until accepted
108
+ createdAt: timestamp;
109
+
110
+ UNIQUE(organizationId, email); // Can't invite same email twice
111
+ }
112
+ ```
113
+
114
+ **Purpose**: Pending invitations with secure tokens.
115
+
116
+ #### 4. User Sessions (Active Organization Tracking)
117
+
118
+ ```typescript
119
+ cms_user_sessions {
120
+ userId: text PRIMARY KEY; // References Better Auth user
121
+ activeOrganizationId: uuid → cms_organizations(id);
122
+ updatedAt: timestamp;
123
+ }
124
+ ```
125
+
126
+ **Purpose**: Track which organization user is currently working in.
127
+
128
+ ### Modified Tables
129
+
130
+ #### 5. Documents (Add Organization Scoping)
131
+
132
+ ```typescript
133
+ cms_documents {
134
+ // ... existing fields
135
+ organizationId: uuid NOT NULL → cms_organizations(id) CASCADE; // NEW
136
+ }
137
+ ```
138
+
139
+ #### 6. Assets (Add Organization Scoping)
140
+
141
+ ```typescript
142
+ cms_assets {
143
+ // ... existing fields
144
+ organizationId: uuid NOT NULL → cms_organizations(id) CASCADE; // NEW
145
+ }
146
+ ```
147
+
148
+ ### Keep Existing Tables
149
+
150
+ #### 7. User Profiles (Keep for Global Preferences)
151
+
152
+ ```typescript
153
+ cms_user_profiles {
154
+ userId: text PRIMARY KEY;
155
+ preferences: jsonb; // Global preferences (theme, language)
156
+ createdAt: timestamp;
157
+ updatedAt: timestamp;
158
+ }
159
+ ```
160
+
161
+ **Note**: This stores global user preferences. Organization-specific preferences go in `cms_organization_members`.
162
+
163
+ ---
164
+
165
+ ## Phase 2: Core Types & Interfaces
166
+
167
+ ### New Types
168
+
169
+ **Location**: `packages/cms-core/src/types/`
170
+
171
+ ```typescript
172
+ // types/organization.ts
173
+ export interface Organization {
174
+ id: string;
175
+ name: string;
176
+ slug: string;
177
+ metadata?: {
178
+ logo?: string;
179
+ theme?: { primaryColor: string; fontFamily: string; logoUrl: string };
180
+ website?: string;
181
+ settings?: Record<string, any>;
182
+ };
183
+ createdBy: string;
184
+ createdAt: Date;
185
+ updatedAt: Date;
186
+ }
187
+
188
+ export interface OrganizationMember {
189
+ id: string;
190
+ organizationId: string;
191
+ userId: string;
192
+ role: 'owner' | 'admin' | 'editor' | 'viewer';
193
+ preferences?: Record<string, any>;
194
+ invitedBy?: string;
195
+ createdAt: Date;
196
+ updatedAt: Date;
197
+ }
198
+
199
+ export interface OrganizationMembership {
200
+ organization: Organization; // Full org data
201
+ member: OrganizationMember; // Membership record
202
+ }
203
+
204
+ export interface Invitation {
205
+ id: string;
206
+ organizationId: string;
207
+ email: string;
208
+ role: 'owner' | 'admin' | 'editor' | 'viewer';
209
+ token: string;
210
+ invitedBy: string;
211
+ expiresAt: Date;
212
+ acceptedAt?: Date;
213
+ createdAt: Date;
214
+ }
215
+ ```
216
+
217
+ ### Updated Types
218
+
219
+ ```typescript
220
+ // types/user.ts (UPDATED)
221
+ export interface CMSUser extends AuthUser {
222
+ // Super admin flag
223
+ isSuperAdmin: boolean;
224
+
225
+ // Current active organization (what they're working in now)
226
+ activeOrganization?: {
227
+ id: string;
228
+ name: string;
229
+ role: 'owner' | 'admin' | 'editor' | 'viewer';
230
+ };
231
+
232
+ // All organizations user belongs to (for switcher)
233
+ organizations?: OrganizationMembership[];
234
+
235
+ // Global preferences
236
+ preferences?: Record<string, any>;
237
+ }
238
+
239
+ // types/auth.ts (UPDATED)
240
+ export interface SessionAuth {
241
+ type: 'session';
242
+ user: CMSUser; // Now includes org context
243
+ session: {
244
+ id: string;
245
+ expiresAt: Date;
246
+ };
247
+ }
248
+
249
+ export interface ApiKeyAuth {
250
+ type: 'api_key';
251
+ keyId: string;
252
+ name: string;
253
+ permissions: ('read' | 'write')[];
254
+ organizationId: string; // NEW: All API keys are org-scoped
255
+ createdBy?: string;
256
+ lastUsedAt?: Date;
257
+ }
258
+ ```
259
+
260
+ ### New Adapter Interface
261
+
262
+ ```typescript
263
+ // db/interfaces/organization.ts
264
+ export interface OrganizationAdapter {
265
+ // Organization CRUD
266
+ createOrganization(data: CreateOrganizationData): Promise<Organization>;
267
+ findOrganizationById(id: string): Promise<Organization | null>;
268
+ findOrganizationBySlug(slug: string): Promise<Organization | null>;
269
+ updateOrganization(id: string, data: UpdateOrganizationData): Promise<Organization>;
270
+ deleteOrganization(id: string): Promise<boolean>;
271
+
272
+ // Member management
273
+ addMember(data: AddMemberData): Promise<OrganizationMember>;
274
+ removeMember(organizationId: string, userId: string): Promise<boolean>;
275
+ updateMemberRole(
276
+ organizationId: string,
277
+ userId: string,
278
+ role: string
279
+ ): Promise<OrganizationMember>;
280
+ findUserMembership(userId: string, organizationId: string): Promise<OrganizationMember | null>;
281
+ findUserOrganizations(userId: string): Promise<OrganizationMembership[]>;
282
+ findOrganizationMembers(organizationId: string): Promise<OrganizationMember[]>;
283
+
284
+ // Invitation management
285
+ createInvitation(data: CreateInvitationData): Promise<Invitation>;
286
+ findInvitationByToken(token: string): Promise<Invitation | null>;
287
+ findOrganizationInvitations(organizationId: string): Promise<Invitation[]>;
288
+ acceptInvitation(token: string, userId: string): Promise<OrganizationMember>;
289
+ deleteInvitation(id: string): Promise<boolean>;
290
+ cleanupExpiredInvitations(): Promise<number>;
291
+
292
+ // User session management
293
+ updateUserSession(userId: string, organizationId: string): Promise<void>;
294
+ findUserSession(userId: string): Promise<{ activeOrganizationId: string } | null>;
295
+ }
296
+ ```
297
+
298
+ ### Updated Adapter Interfaces
299
+
300
+ ```typescript
301
+ // DocumentAdapter - Add organizationId parameter
302
+ interface DocumentAdapter {
303
+ list(organizationId: string, filters?: DocumentFilters): Promise<Document[]>;
304
+ create(organizationId: string, data: CreateDocumentData): Promise<Document>;
305
+ update(organizationId: string, id: string, data: UpdateDocumentData): Promise<Document>;
306
+ delete(organizationId: string, id: string): Promise<boolean>;
307
+ // ... all methods need organizationId
308
+ }
309
+
310
+ // AssetAdapter - Add organizationId parameter
311
+ interface AssetAdapter {
312
+ list(organizationId: string, filters?: AssetFilters): Promise<Asset[]>;
313
+ create(organizationId: string, data: CreateAssetData): Promise<Asset>;
314
+ delete(organizationId: string, id: string): Promise<boolean>;
315
+ // ... all methods need organizationId
316
+ }
317
+ ```
318
+
319
+ ---
320
+
321
+ ## Phase 3: Auth Service Updates
322
+
323
+ ### Super Admin Detection
324
+
325
+ **Don't modify Better Auth `user` table**. Use one of these approaches:
326
+
327
+ **Option 1: Environment Variable (Recommended)**
328
+
329
+ ```typescript
330
+ // apps/studio/src/lib/server/auth/service.ts
331
+ const SUPER_ADMIN_EMAILS = process.env.SUPER_ADMIN_EMAILS?.split(',') || [];
332
+
333
+ async function isSuperAdmin(email: string): boolean {
334
+ return SUPER_ADMIN_EMAILS.includes(email);
335
+ }
336
+ ```
337
+
338
+ **Option 2: Separate CMS Table**
339
+
340
+ ```typescript
341
+ // Create cms_super_admins table
342
+ cms_super_admins {
343
+ userId: text PRIMARY KEY;
344
+ createdAt: timestamp;
345
+ }
346
+ ```
347
+
348
+ ### Enrich authService.getSession()
349
+
350
+ **Location**: `apps/studio/src/lib/server/auth/service.ts`
351
+
352
+ ```typescript
353
+ async getSession(request: Request, db: DatabaseAdapter): Promise<SessionAuth | null> {
354
+ // 1. Get Better Auth session (authentication)
355
+ const session = await auth.api.getSession({ headers: request.headers });
356
+ if (!session) return null;
357
+
358
+ // 2. Check super admin status
359
+ const isSuperAdmin = SUPER_ADMIN_EMAILS.includes(session.user.email);
360
+
361
+ // 3. Get active organization from cms_user_sessions
362
+ const userSession = await db.findUserSession(session.user.id);
363
+ const activeOrgId = userSession?.activeOrganizationId;
364
+
365
+ // 4. If activeOrgId exists, get membership details
366
+ let activeOrganization;
367
+ if (activeOrgId) {
368
+ const membership = await db.findUserMembership(session.user.id, activeOrgId);
369
+ if (membership) {
370
+ activeOrganization = {
371
+ id: membership.organization.id,
372
+ name: membership.organization.name,
373
+ role: membership.member.role
374
+ };
375
+ }
376
+ }
377
+
378
+ // 5. Get all user organizations (for switcher)
379
+ const organizations = await db.findUserOrganizations(session.user.id);
380
+
381
+ // 6. Get global preferences (from cms_user_profiles)
382
+ const userProfile = await db.findUserProfileById(session.user.id);
383
+
384
+ // 7. Build enriched CMSUser
385
+ const cmsUser: CMSUser = {
386
+ id: session.user.id,
387
+ email: session.user.email,
388
+ name: session.user.name,
389
+ image: session.user.image,
390
+ isSuperAdmin,
391
+ activeOrganization,
392
+ organizations,
393
+ preferences: userProfile?.preferences || {}
394
+ };
395
+
396
+ return {
397
+ type: 'session',
398
+ user: cmsUser,
399
+ session: {
400
+ id: session.session.id,
401
+ expiresAt: session.session.expiresAt
402
+ }
403
+ };
404
+ }
405
+ ```
406
+
407
+ ---
408
+
409
+ ## Phase 4: API Endpoints
410
+
411
+ ### Organization Management
412
+
413
+ ```typescript
414
+ POST /api/organizations // Create org (super admin only)
415
+ GET /api/organizations // List user's organizations
416
+ GET /api/organizations/:id // Get org details
417
+ PATCH /api/organizations/:id // Update org (owner only)
418
+ DELETE /api/organizations/:id // Delete org (owner only)
419
+ POST /api/auth/switch-organization // Switch active org
420
+
421
+ // Super admin only
422
+ GET /api/admin/organizations // List all orgs
423
+ GET /api/admin/organizations/:id/stats // Org stats
424
+ ```
425
+
426
+ ### Member Management
427
+
428
+ ```typescript
429
+ GET /api/organizations/:id/members // List members
430
+ PATCH /api/organizations/:id/members/:userId // Update member role
431
+ DELETE /api/organizations/:id/members/:userId // Remove member
432
+ ```
433
+
434
+ ### Invitation System
435
+
436
+ ```typescript
437
+ // Create invitation (owner/admin only)
438
+ POST /api/organizations/:id/invitations
439
+ {
440
+ email: "user@example.com",
441
+ role: "editor",
442
+ expiresInDays: 7 // Optional, default 7
443
+ }
444
+ // Returns: { id, token, inviteLink: "/invite/token" }
445
+
446
+ // List pending invitations (owner/admin only)
447
+ GET /api/organizations/:id/invitations
448
+
449
+ // Cancel invitation (owner/admin only)
450
+ DELETE /api/invitations/:id
451
+
452
+ // Smart redirect (no UI, just logic)
453
+ GET /invite/:token
454
+ // If authenticated → auto-accept and redirect
455
+ // If not authenticated → redirect to login/signup with ?invite=token
456
+ ```
457
+
458
+ ### Updated Endpoints
459
+
460
+ ```typescript
461
+ // All now automatically scoped by activeOrganizationId
462
+ GET /api/documents
463
+ POST /api/documents
464
+ GET /api/assets
465
+ POST /api/assets
466
+
467
+ // API keys now require organizationId
468
+ POST /api/auth/api-keys
469
+ {
470
+ name: "Production Key",
471
+ permissions: ["read", "write"],
472
+ organizationId: "org-123" // REQUIRED
473
+ }
474
+ ```
475
+
476
+ ---
477
+
478
+ ## Phase 5: Invitation Flow
479
+
480
+ ### Complete Flow (Corrected)
481
+
482
+ ```
483
+ 1. Owner/Admin creates invitation
484
+
485
+ 2. Email sent to user with /invite/token link
486
+
487
+ 3. User clicks link → GET /invite/:token
488
+
489
+ 4. Route checks authentication:
490
+
491
+ If authenticated:
492
+ → Auto-accept invitation
493
+ → Redirect to /admin (in new org)
494
+
495
+ If NOT authenticated:
496
+ → Check if user exists (by email)
497
+
498
+ If user exists:
499
+ → Redirect to /login?invite=token
500
+
501
+ If user doesn't exist:
502
+ → Redirect to /signup?invite=token
503
+
504
+ 5. After login/signup:
505
+ → Check for ?invite= param
506
+ → Auto-accept invitation
507
+ → Redirect to /admin (in new org)
508
+ ```
509
+
510
+ ### Implementation
511
+
512
+ ```typescript
513
+ // apps/studio/src/routes/invite/[token]/+page.server.ts
514
+ export const load: PageServerLoad = async ({ params, locals }) => {
515
+ const { token } = params;
516
+
517
+ // Get invitation
518
+ const invitation = await db.findInvitationByToken(token);
519
+ if (!invitation) throw error(404, 'Invitation not found');
520
+ if (invitation.expiresAt < new Date()) throw error(410, 'Invitation expired');
521
+ if (invitation.acceptedAt) throw error(410, 'Invitation already accepted');
522
+
523
+ // Check if user is authenticated
524
+ const auth = locals.auth;
525
+
526
+ if (auth && auth.type === 'session') {
527
+ // User is logged in → auto-accept
528
+ await db.acceptInvitation(token, auth.user.id);
529
+
530
+ // Set as active organization
531
+ await db.updateUserSession(auth.user.id, invitation.organizationId);
532
+
533
+ throw redirect(302, '/admin');
534
+ } else {
535
+ // User not logged in → redirect to login/signup
536
+ // Check if user exists
537
+ const userExists = await checkUserExists(invitation.email);
538
+
539
+ if (userExists) {
540
+ throw redirect(302, `/login?invite=${token}`);
541
+ } else {
542
+ throw redirect(302, `/signup?invite=${token}`);
543
+ }
544
+ }
545
+ };
546
+ ```
547
+
548
+ ```typescript
549
+ // Update login/signup to handle invite param
550
+ // apps/studio/src/routes/login/+page.server.ts (or form action)
551
+ export const actions = {
552
+ default: async ({ request, locals, url }) => {
553
+ // ... perform login
554
+
555
+ // Check for invite token
556
+ const inviteToken = url.searchParams.get('invite');
557
+ if (inviteToken) {
558
+ await db.acceptInvitation(inviteToken, userId);
559
+ await db.updateUserSession(userId, invitation.organizationId);
560
+ }
561
+
562
+ throw redirect(302, '/admin');
563
+ }
564
+ };
565
+ ```
566
+
567
+ ### Email Integration
568
+
569
+ ```typescript
570
+ // Send invitation email (use Resend, SendGrid, etc.)
571
+ const invitation = await db.createInvitation({
572
+ organizationId: org.id,
573
+ email: 'user@example.com',
574
+ role: 'editor',
575
+ invitedBy: currentUser.id,
576
+ expiresInDays: 7
577
+ });
578
+
579
+ await emailService.send({
580
+ to: invitation.email,
581
+ subject: `You've been invited to ${org.name}`,
582
+ template: 'invitation',
583
+ data: {
584
+ orgName: org.name,
585
+ inviterName: currentUser.name,
586
+ role: invitation.role,
587
+ inviteLink: `https://yourdomain.com/invite/${invitation.token}`,
588
+ expiresAt: invitation.expiresAt
589
+ }
590
+ });
591
+ ```
592
+
593
+ ---
594
+
595
+ ## Phase 6: Frontend Components
596
+
597
+ ### Organization Switcher
598
+
599
+ ```svelte
600
+ <!-- apps/studio/src/lib/components/OrganizationSwitcher.svelte -->
601
+ <script lang="ts">
602
+ import { page } from '$app/stores';
603
+
604
+ let user = $derived($page.data.auth?.user);
605
+ let activeOrg = $derived(user?.activeOrganization);
606
+ let organizations = $derived(user?.organizations || []);
607
+ let isSuperAdmin = $derived(user?.isSuperAdmin || false);
608
+
609
+ async function switchOrganization(orgId: string) {
610
+ await fetch('/api/auth/switch-organization', {
611
+ method: 'POST',
612
+ headers: { 'Content-Type': 'application/json' },
613
+ body: JSON.stringify({ organizationId: orgId })
614
+ });
615
+ window.location.reload();
616
+ }
617
+ </script>
618
+
619
+ {#if isSuperAdmin}
620
+ <span class="badge">Super Admin</span>
621
+ {/if}
622
+
623
+ <select value={activeOrg?.id} onchange={(e) => switchOrganization(e.currentTarget.value)}>
624
+ {#if !activeOrg}
625
+ <option value="">Select Organization</option>
626
+ {/if}
627
+ {#each organizations as { organization, member }}
628
+ <option value={organization.id}>
629
+ {organization.name} ({member.role})
630
+ </option>
631
+ {/each}
632
+ </select>
633
+
634
+ {#if isSuperAdmin}
635
+ <a href="/admin/organizations/new">+ Create Organization</a>
636
+ {/if}
637
+ ```
638
+
639
+ ### Key UI Updates
640
+
641
+ - **Header/Sidebar**: Show active organization and switcher
642
+ - **Organization Settings**: Only visible to owners/admins
643
+ - **Invite Members Form**: Only visible to owners/admins
644
+ - **Pending Invitations List**: Show in members page
645
+ - **Login/Signup Pages**: Handle `?invite=` parameter
646
+ - **No Accept Invite Page**: `/invite/:token` is just a smart redirect
647
+
648
+ ---
649
+
650
+ ## Phase 7: Public Content & Landing Pages
651
+
652
+ ### Organization-Based Routing
653
+
654
+ Each organization can have landing pages with custom branding:
655
+
656
+ **Option A: Subdomain**
657
+
658
+ ```
659
+ https://client-a.yourdomain.com/product-launch
660
+ https://client-b.yourdomain.com/services
661
+ ```
662
+
663
+ **Option B: Path**
664
+
665
+ ```
666
+ https://yourdomain.com/client-a/product-launch
667
+ https://yourdomain.com/client-b/services
668
+ ```
669
+
670
+ ### Implementation
671
+
672
+ ```typescript
673
+ // Landing pages are just documents scoped by organizationId
674
+ await db.createDocument(clientAOrgId, {
675
+ schemaType: 'landingPage',
676
+ data: {
677
+ title: 'Product Launch',
678
+ slug: 'product-launch',
679
+ hero: { ... },
680
+ sections: [ ... ],
681
+ status: 'published'
682
+ }
683
+ });
684
+
685
+ // Route resolves org from subdomain/path
686
+ const org = await db.findOrganizationBySlug(subdomain);
687
+ const pages = await db.findDocuments(org.id, {
688
+ schemaType: 'landingPage',
689
+ filters: { 'data.slug': pageSlug, 'data.status': 'published' }
690
+ });
691
+
692
+ // Apply org theme from metadata
693
+ const theme = org.metadata?.theme || defaultTheme;
694
+ ```
695
+
696
+ ---
697
+
698
+ ## Phase 8: Migration Strategy
699
+
700
+ ### Step 1: Add Schema
701
+
702
+ ```sql
703
+ -- Add new tables
704
+ CREATE TABLE cms_organizations (...);
705
+ CREATE TABLE cms_organization_members (...);
706
+ CREATE TABLE cms_invitations (...);
707
+ CREATE TABLE cms_user_sessions (...);
708
+
709
+ -- Add organizationId to existing tables
710
+ ALTER TABLE cms_documents ADD COLUMN organization_id UUID;
711
+ ALTER TABLE cms_assets ADD COLUMN organization_id UUID;
712
+ ```
713
+
714
+ ### Step 2: Migrate Data
715
+
716
+ ```sql
717
+ -- Create default organization
718
+ INSERT INTO cms_organizations (id, name, slug, created_by)
719
+ VALUES (
720
+ gen_random_uuid(),
721
+ 'Default Organization',
722
+ 'default',
723
+ (SELECT id FROM user WHERE email = 'admin@example.com')
724
+ );
725
+
726
+ -- Migrate user profiles to organization members
727
+ INSERT INTO cms_organization_members (organization_id, user_id, role)
728
+ SELECT
729
+ (SELECT id FROM cms_organizations WHERE slug = 'default'),
730
+ user_id,
731
+ 'editor' -- Default role
732
+ FROM cms_user_profiles;
733
+
734
+ -- Assign all documents to default org
735
+ UPDATE cms_documents
736
+ SET organization_id = (SELECT id FROM cms_organizations WHERE slug = 'default');
737
+
738
+ -- Assign all assets to default org
739
+ UPDATE cms_assets
740
+ SET organization_id = (SELECT id FROM cms_organizations WHERE slug = 'default');
741
+ ```
742
+
743
+ ### Step 3: Deploy Code
744
+
745
+ - Update adapters
746
+ - Update auth service
747
+ - Update UI
748
+
749
+ ### Step 4: Make organizationId NOT NULL
750
+
751
+ ```sql
752
+ ALTER TABLE cms_documents ALTER COLUMN organization_id SET NOT NULL;
753
+ ALTER TABLE cms_assets ALTER COLUMN organization_id SET NOT NULL;
754
+ ```
755
+
756
+ ---
757
+
758
+ ## Key Architecture Decisions
759
+
760
+ ### ✅ What We're Doing
761
+
762
+ 1. **Don't Modify Better Auth Tables**
763
+ - Store super admin list in env var or separate table
764
+ - Store active org in `cms_user_sessions` (not `session` table)
765
+
766
+ 2. **Keep User Profiles Table**
767
+ - For global user preferences (theme, language)
768
+ - Organization-specific preferences go in `cms_organization_members`
769
+
770
+ 3. **Many-to-Many User-Organization**
771
+ - Users can belong to multiple organizations
772
+ - Different role in each organization
773
+ - One active organization at a time
774
+
775
+ 4. **Smart Invite Flow**
776
+ - No separate "accept invite" page
777
+ - `/invite/:token` auto-redirects to login/signup
778
+ - Auto-accept after authentication
779
+
780
+ 5. **Organization Scoping**
781
+ - All documents/assets require `organizationId`
782
+ - API keys are organization-scoped
783
+ - Complete data isolation
784
+
785
+ ### ❌ What We're NOT Doing
786
+
787
+ 1. **Not modifying Better Auth schema** - All extensions in CMS tables
788
+ 2. **Not adding fine-grained permissions yet** - Start with role-based access
789
+ 3. **Not implementing datasets yet** - Can add later (dev/staging/prod)
790
+ 4. **Not using Better Auth hooks for sync** - Using lazy sync in authService
791
+
792
+ ---
793
+
794
+ ## Summary Checklist
795
+
796
+ - [ ] Phase 1: Create database schema (4 new tables, 2 modified)
797
+ - [ ] Phase 2: Define types and interfaces
798
+ - [ ] Phase 3: Implement OrganizationAdapter (PostgreSQL)
799
+ - [ ] Phase 4: Update DocumentAdapter and AssetAdapter signatures
800
+ - [ ] Phase 5: Update authService.getSession() to enrich with org data
801
+ - [ ] Phase 6: Create organization management endpoints
802
+ - [ ] Phase 7: Implement invitation system (create, accept, email)
803
+ - [ ] Phase 8: Build organization switcher UI
804
+ - [ ] Phase 9: Update login/signup to handle invite params
805
+ - [ ] Phase 10: Add organization settings pages
806
+ - [ ] Phase 11: Test multi-tenancy isolation
807
+ - [ ] Phase 12: Run migration on existing data
808
+ - [ ] Phase 13: (Optional) Add landing page routing
809
+ - [ ] Phase 14: (Optional) Set up email service for invitations
810
+
811
+ ---
812
+
813
+ **Estimated Time**: 2-3 weeks for core multi-tenancy (Phases 1-12)
814
+
815
+ **Next Steps**: Start with Phase 1 (Database Schema) - create the migration file.
816
+
817
+ ---
818
+
819
+ ## Best Practices & Guardrails for Soft Multi-Tenancy
820
+
821
+ Since this implementation uses soft multi-tenancy (shared database), it's critical to have proper guardrails to prevent data leaks and noisy neighbor issues.
822
+
823
+ ### ✅ Current Safeguards in This Plan
824
+
825
+ #### 1. **Database-Level Constraints**
826
+
827
+ ```sql
828
+ -- Foreign key ensures organizationId is valid
829
+ ALTER TABLE cms_documents
830
+ ADD CONSTRAINT fk_documents_organization
831
+ FOREIGN KEY (organization_id)
832
+ REFERENCES cms_organizations(id)
833
+ ON DELETE CASCADE;
834
+
835
+ -- NOT NULL prevents missing organizationId
836
+ ALTER TABLE cms_documents
837
+ ALTER COLUMN organization_id SET NOT NULL;
838
+
839
+ -- Index for fast filtering (prevents slow queries)
840
+ CREATE INDEX idx_documents_organization ON cms_documents(organization_id);
841
+ CREATE INDEX idx_assets_organization ON cms_assets(organization_id);
842
+ ```
843
+
844
+ **Why**: These constraints ensure you CANNOT create documents without an organization, and queries filter efficiently.
845
+
846
+ #### 2. **Adapter-Level Enforcement**
847
+
848
+ ```typescript
849
+ // ALL adapter methods REQUIRE organizationId parameter
850
+ interface DocumentAdapter {
851
+ // ✅ Good: organizationId required
852
+ list(organizationId: string, filters?: Filters): Promise<Document[]>;
853
+
854
+ // ❌ Bad: organizationId optional or missing
855
+ list(filters?: Filters): Promise<Document[]>;
856
+ }
857
+
858
+ // Implementation ensures filtering
859
+ async list(organizationId: string, filters) {
860
+ return await db.select()
861
+ .from(documents)
862
+ .where(eq(documents.organizationId, organizationId)); // ALWAYS filtered
863
+ }
864
+ ```
865
+
866
+ **Why**: Makes it impossible to forget filtering by organizationId - it's a required parameter.
867
+
868
+ #### 3. **Auth Hook Enforcement**
869
+
870
+ ```typescript
871
+ // Auth hook sets organizationId in event.locals
872
+ if (auth.type === 'session') {
873
+ if (!auth.user.activeOrganization) {
874
+ // ✅ No org selected → block access
875
+ throw redirect(302, '/select-organization');
876
+ }
877
+ event.locals.organizationId = auth.user.activeOrganization.id;
878
+ }
879
+
880
+ // API routes use locals.organizationId
881
+ const orgId = locals.organizationId; // Set by auth hook
882
+ const docs = await db.findDocuments(orgId, filters);
883
+ ```
884
+
885
+ **Why**: Centralized enforcement - organizationId is set once in middleware, used everywhere.
886
+
887
+ #### 4. **Type Safety**
888
+
889
+ ```typescript
890
+ // TypeScript ensures organizationId is provided
891
+ const docs = await db.findDocuments(orgId, { schemaType: 'post' });
892
+ // ^^^^^ Required, won't compile without it
893
+ ```
894
+
895
+ **Why**: Compile-time safety prevents accidental cross-org queries.
896
+
897
+ ---
898
+
899
+ ### ⚠️ Recommended Additional Safeguards
900
+
901
+ #### 1. **Row-Level Security (RLS) - Database Level** (HIGHLY RECOMMENDED)
902
+
903
+ Add PostgreSQL Row-Level Security as a backup:
904
+
905
+ ```sql
906
+ -- Enable RLS on tables
907
+ ALTER TABLE cms_documents ENABLE ROW LEVEL SECURITY;
908
+ ALTER TABLE cms_assets ENABLE ROW LEVEL SECURITY;
909
+
910
+ -- Create policy: users can only see their org's data
911
+ CREATE POLICY documents_org_isolation ON cms_documents
912
+ USING (organization_id = current_setting('app.current_organization_id')::uuid);
913
+
914
+ CREATE POLICY assets_org_isolation ON cms_assets
915
+ USING (organization_id = current_setting('app.current_organization_id')::uuid);
916
+
917
+ -- Set org context per request
918
+ SET app.current_organization_id = 'org-123';
919
+ SELECT * FROM cms_documents; -- Only sees org-123's documents
920
+ ```
921
+
922
+ **Implementation**:
923
+
924
+ ```typescript
925
+ // In adapter, set session variable before querying
926
+ async list(organizationId: string, filters) {
927
+ // Set RLS context
928
+ await db.execute(sql`SET app.current_organization_id = ${organizationId}`);
929
+
930
+ // Even if we forget WHERE clause, RLS protects us
931
+ return await db.select().from(documents);
932
+ }
933
+ ```
934
+
935
+ **Benefit**: Even if application code has a bug, database-level RLS prevents cross-org access.
936
+
937
+ ---
938
+
939
+ #### 2. **Query Timeouts** (Prevent Noisy Neighbor)
940
+
941
+ ```typescript
942
+ // Set statement timeout per request
943
+ async executeQuery(orgId: string, query: SQL) {
944
+ await db.execute(sql`SET statement_timeout = '30s'`); // Kill after 30s
945
+
946
+ try {
947
+ return await db.execute(query);
948
+ } catch (error) {
949
+ if (error.code === '57014') { // Query timeout
950
+ throw new Error('Query took too long - please optimize your filters');
951
+ }
952
+ throw error;
953
+ }
954
+ }
955
+ ```
956
+
957
+ **Benefit**: Prevents one org's bad query from locking the database.
958
+
959
+ ---
960
+
961
+ #### 3. **Connection Pool Limits Per Organization**
962
+
963
+ ```typescript
964
+ // Track active connections per org
965
+ const orgConnectionCount = new Map<string, number>();
966
+
967
+ async function getConnection(orgId: string) {
968
+ const current = orgConnectionCount.get(orgId) || 0;
969
+
970
+ // Limit to 5 concurrent connections per org
971
+ if (current >= 5) {
972
+ throw new Error('Too many concurrent requests - please try again');
973
+ }
974
+
975
+ orgConnectionCount.set(orgId, current + 1);
976
+
977
+ const conn = await pool.connect();
978
+
979
+ conn.on('release', () => {
980
+ orgConnectionCount.set(orgId, (orgConnectionCount.get(orgId) || 1) - 1);
981
+ });
982
+
983
+ return conn;
984
+ }
985
+ ```
986
+
987
+ **Benefit**: Prevents one org from exhausting the connection pool.
988
+
989
+ ---
990
+
991
+ #### 4. **API Rate Limiting** ✅ Already Handled by Better Auth
992
+
993
+ **Current Implementation:**
994
+
995
+ ```typescript
996
+ // apps/studio/src/lib/server/auth/better-auth/instance.ts
997
+ plugins: [
998
+ apiKey({
999
+ apiKeyHeaders: ['x-api-key'],
1000
+ rateLimit: {
1001
+ enabled: true,
1002
+ timeWindow: 1000 * 60 * 60 * 24, // 24 hours (adjustable)
1003
+ maxRequests: 10000 // 10k requests/day (adjustable)
1004
+ },
1005
+ enableMetadata: true
1006
+ })
1007
+ ];
1008
+ ```
1009
+
1010
+ **Benefit**:
1011
+
1012
+ - ✅ API keys already rate-limited (10k requests/day by default)
1013
+ - ✅ Configurable per deployment (adjust timeWindow and maxRequests)
1014
+ - ✅ Handled by Better Auth (automatic enforcement)
1015
+ - ✅ Prevents API abuse without additional infrastructure
1016
+ - ✅ No Redis/Upstash needed for basic rate limiting
1017
+
1018
+ **Note**: This is per-key rate limiting. Each API key is limited independently. For organization-level aggregated limits (e.g., "Org A gets 100k requests/day across all keys"), you'd need custom rate limiting, but per-key limits are sufficient for most use cases.
1019
+
1020
+ ---
1021
+
1022
+ #### 5. **Monitoring & Alerting Per Organization**
1023
+
1024
+ ```typescript
1025
+ // Track query performance per org
1026
+ async function executeQuery(orgId: string, query: SQL) {
1027
+ const start = Date.now();
1028
+
1029
+ try {
1030
+ const result = await db.execute(query);
1031
+ const duration = Date.now() - start;
1032
+
1033
+ // Log slow queries
1034
+ if (duration > 1000) {
1035
+ console.warn(`[Org ${orgId}] Slow query (${duration}ms):`, query);
1036
+
1037
+ // Alert if consistently slow
1038
+ await metrics.increment('slow_queries', { organizationId: orgId });
1039
+ }
1040
+
1041
+ return result;
1042
+ } catch (error) {
1043
+ // Track errors per org
1044
+ await metrics.increment('query_errors', { organizationId: orgId });
1045
+ throw error;
1046
+ }
1047
+ }
1048
+ ```
1049
+
1050
+ **Benefit**: Identify problematic organizations before they affect others.
1051
+
1052
+ ---
1053
+
1054
+ #### 6. **Audit Logging**
1055
+
1056
+ ```typescript
1057
+ // Log all data access
1058
+ cms_audit_logs {
1059
+ id: uuid;
1060
+ organizationId: uuid;
1061
+ userId: text;
1062
+ action: enum('read', 'create', 'update', 'delete');
1063
+ resourceType: string; // 'document', 'asset'
1064
+ resourceId: uuid;
1065
+ timestamp: timestamp;
1066
+ }
1067
+
1068
+ // In adapter
1069
+ async create(orgId: string, data: CreateDocumentData) {
1070
+ const doc = await db.insert(documents).values({
1071
+ ...data,
1072
+ organizationId: orgId
1073
+ });
1074
+
1075
+ // Audit log
1076
+ await db.insert(auditLogs).values({
1077
+ organizationId: orgId,
1078
+ userId: currentUser.id,
1079
+ action: 'create',
1080
+ resourceType: 'document',
1081
+ resourceId: doc.id,
1082
+ timestamp: new Date()
1083
+ });
1084
+
1085
+ return doc;
1086
+ }
1087
+ ```
1088
+
1089
+ **Benefit**: Track and investigate any cross-org access attempts.
1090
+
1091
+ ---
1092
+
1093
+ #### 7. **Regular Security Audits**
1094
+
1095
+ ```typescript
1096
+ // Automated check: Find documents without organizationId (shouldn't exist)
1097
+ async function auditOrganizationIsolation() {
1098
+ const orphanedDocs = await db.select().from(documents).where(isNull(documents.organizationId));
1099
+
1100
+ if (orphanedDocs.length > 0) {
1101
+ console.error(`SECURITY ALERT: ${orphanedDocs.length} documents without organizationId!`);
1102
+ // Alert admin
1103
+ }
1104
+
1105
+ // Check for cross-org references
1106
+ const invalidRefs = await db.execute(sql`
1107
+ SELECT d.id, d.organization_id, a.organization_id as asset_org_id
1108
+ FROM cms_documents d
1109
+ JOIN cms_assets a ON d.data->>'imageId' = a.id::text
1110
+ WHERE d.organization_id != a.organization_id
1111
+ `);
1112
+
1113
+ if (invalidRefs.length > 0) {
1114
+ console.error(`SECURITY ALERT: ${invalidRefs.length} cross-org references!`);
1115
+ }
1116
+ }
1117
+
1118
+ // Run daily
1119
+ setInterval(auditOrganizationIsolation, 24 * 60 * 60 * 1000);
1120
+ ```
1121
+
1122
+ **Benefit**: Catch isolation bugs early before they become security issues.
1123
+
1124
+ ---
1125
+
1126
+ ### ✅ Summary: Is This Plan Following Best Practices?
1127
+
1128
+ **YES**, the current plan has good safeguards:
1129
+
1130
+ | Safeguard | Status | Notes |
1131
+ | ------------------------------------- | --------------- | ---------------------------------------- |
1132
+ | **Required organizationId parameter** | ✅ Built-in | Adapter interface enforces it |
1133
+ | **Database foreign keys** | ✅ Built-in | Prevents invalid organizationId |
1134
+ | **NOT NULL constraints** | ✅ Built-in | Prevents missing organizationId |
1135
+ | **API key rate limiting** | ✅ Built-in | Better Auth plugin (10k/day, adjustable) |
1136
+ | **Indexes on organizationId** | ⚠️ Add this | Ensure fast queries (performance) |
1137
+ | **Row-Level Security (RLS)** | ❌ Not included | HIGHLY RECOMMENDED to add |
1138
+ | **Query timeouts** | ❌ Not included | Recommended for production |
1139
+ | **Connection pool limits** | ❌ Not included | Recommended for scale |
1140
+ | **Audit logging** | ❌ Not included | Recommended for compliance |
1141
+
1142
+ ### Recommendations:
1143
+
1144
+ **Must Have (Before Production)**:
1145
+
1146
+ 1. ✅ Add database indexes on `organizationId` columns
1147
+ 2. ✅ Implement Row-Level Security (RLS) in PostgreSQL
1148
+ 3. ✅ Add query timeouts
1149
+
1150
+ **Should Have (For Scale)**: 4. ✅ Add connection pool limits per organization 5. ✅ Set up monitoring per organization 6. ✅ Consider session-based rate limiting (for UI users, not API keys)
1151
+
1152
+ **Nice to Have (For Enterprise)**: 7. ✅ Audit logging 8. ✅ Automated security audits 9. ✅ Backup/restore per organization 10. ✅ Organization-level aggregated rate limits (across all API keys)
1153
+
1154
+ ---
1155
+
1156
+ ## Enterprise Multi-Tenancy Considerations
1157
+
1158
+ For clients requiring true database isolation, consider these evolution paths:
1159
+
1160
+ ### **Tier 2: Schema-per-Tenant** (Intermediate)
1161
+
1162
+ - Each org gets a PostgreSQL schema (same database, isolated tables)
1163
+ - Better isolation than row-level filtering
1164
+ - Still shares compute resources
1165
+ - ~4x price increase
1166
+
1167
+ ### **Tier 3: Database-per-Tenant** (Advanced)
1168
+
1169
+ - Each org gets a separate database (different DBs, same server)
1170
+ - Complete database isolation
1171
+ - Independent backups/restores
1172
+ - Can support data residency requirements
1173
+ - ~10x price increase
1174
+
1175
+ ### **Tier 4: Fully Isolated** (Enterprise)
1176
+
1177
+ - Each org gets dedicated compute + database + storage
1178
+ - No noisy neighbor effects
1179
+ - Custom SLAs and scaling
1180
+ - Suitable for Fortune 500 clients
1181
+ - ~40x+ price increase
1182
+
1183
+ **Implementation Note**: Current architecture supports gradual migration - start with soft multi-tenancy, upgrade specific orgs to higher tiers as needed.