@selvajs/local-provider 0.11.0

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 (295) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/auth/LocalAuthProvider.d.ts +28 -0
  4. package/dist/auth/LocalAuthProvider.d.ts.map +1 -0
  5. package/dist/auth/LocalAuthProvider.js +142 -0
  6. package/dist/auth/LocalAuthProvider.js.map +1 -0
  7. package/dist/auth/__tests__/conformance.test.d.ts +2 -0
  8. package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
  9. package/dist/auth/__tests__/conformance.test.js +36 -0
  10. package/dist/auth/__tests__/conformance.test.js.map +1 -0
  11. package/dist/auth/hmac.d.ts +18 -0
  12. package/dist/auth/hmac.d.ts.map +1 -0
  13. package/dist/auth/hmac.js +41 -0
  14. package/dist/auth/hmac.js.map +1 -0
  15. package/dist/auth/index.d.ts +6 -0
  16. package/dist/auth/index.d.ts.map +1 -0
  17. package/dist/auth/index.js +4 -0
  18. package/dist/auth/index.js.map +1 -0
  19. package/dist/auth/users.d.ts +38 -0
  20. package/dist/auth/users.d.ts.map +1 -0
  21. package/dist/auth/users.js +100 -0
  22. package/dist/auth/users.js.map +1 -0
  23. package/dist/compute/FilesystemComputeProvider.d.ts +16 -0
  24. package/dist/compute/FilesystemComputeProvider.d.ts.map +1 -0
  25. package/dist/compute/FilesystemComputeProvider.js +51 -0
  26. package/dist/compute/FilesystemComputeProvider.js.map +1 -0
  27. package/dist/compute/SingleComputeServerProvider.d.ts +15 -0
  28. package/dist/compute/SingleComputeServerProvider.d.ts.map +1 -0
  29. package/dist/compute/SingleComputeServerProvider.js +26 -0
  30. package/dist/compute/SingleComputeServerProvider.js.map +1 -0
  31. package/dist/compute/types.d.ts +30 -0
  32. package/dist/compute/types.d.ts.map +1 -0
  33. package/dist/compute/types.js +2 -0
  34. package/dist/compute/types.js.map +1 -0
  35. package/dist/computeServer/FilesystemComputeServerStore.d.ts +14 -0
  36. package/dist/computeServer/FilesystemComputeServerStore.d.ts.map +1 -0
  37. package/dist/computeServer/FilesystemComputeServerStore.js +35 -0
  38. package/dist/computeServer/FilesystemComputeServerStore.js.map +1 -0
  39. package/dist/computeServer/LocalComputeServerProvider.d.ts +18 -0
  40. package/dist/computeServer/LocalComputeServerProvider.d.ts.map +1 -0
  41. package/dist/computeServer/LocalComputeServerProvider.js +29 -0
  42. package/dist/computeServer/LocalComputeServerProvider.js.map +1 -0
  43. package/dist/computeServer/__tests__/conformance.test.d.ts +2 -0
  44. package/dist/computeServer/__tests__/conformance.test.d.ts.map +1 -0
  45. package/dist/computeServer/__tests__/conformance.test.js +20 -0
  46. package/dist/computeServer/__tests__/conformance.test.js.map +1 -0
  47. package/dist/computeServer/index.d.ts +2 -0
  48. package/dist/computeServer/index.d.ts.map +1 -0
  49. package/dist/computeServer/index.js +2 -0
  50. package/dist/computeServer/index.js.map +1 -0
  51. package/dist/data/LocalComputeServerStore.d.ts +72 -0
  52. package/dist/data/LocalComputeServerStore.d.ts.map +1 -0
  53. package/dist/data/LocalComputeServerStore.js +207 -0
  54. package/dist/data/LocalComputeServerStore.js.map +1 -0
  55. package/dist/data/LocalDataProvider.d.ts +47 -0
  56. package/dist/data/LocalDataProvider.d.ts.map +1 -0
  57. package/dist/data/LocalDataProvider.js +118 -0
  58. package/dist/data/LocalDataProvider.js.map +1 -0
  59. package/dist/data/LocalDefinitionMetaProvider.d.ts +22 -0
  60. package/dist/data/LocalDefinitionMetaProvider.d.ts.map +1 -0
  61. package/dist/data/LocalDefinitionMetaProvider.js +131 -0
  62. package/dist/data/LocalDefinitionMetaProvider.js.map +1 -0
  63. package/dist/data/LocalDefinitionStore.d.ts +33 -0
  64. package/dist/data/LocalDefinitionStore.d.ts.map +1 -0
  65. package/dist/data/LocalDefinitionStore.js +274 -0
  66. package/dist/data/LocalDefinitionStore.js.map +1 -0
  67. package/dist/data/LocalInviteStore.d.ts +23 -0
  68. package/dist/data/LocalInviteStore.d.ts.map +1 -0
  69. package/dist/data/LocalInviteStore.js +98 -0
  70. package/dist/data/LocalInviteStore.js.map +1 -0
  71. package/dist/data/LocalOrgStore.d.ts +67 -0
  72. package/dist/data/LocalOrgStore.d.ts.map +1 -0
  73. package/dist/data/LocalOrgStore.js +255 -0
  74. package/dist/data/LocalOrgStore.js.map +1 -0
  75. package/dist/data/LocalPlatformProjectGrantStore.d.ts +14 -0
  76. package/dist/data/LocalPlatformProjectGrantStore.d.ts.map +1 -0
  77. package/dist/data/LocalPlatformProjectGrantStore.js +62 -0
  78. package/dist/data/LocalPlatformProjectGrantStore.js.map +1 -0
  79. package/dist/data/LocalProjectStore.d.ts +30 -0
  80. package/dist/data/LocalProjectStore.d.ts.map +1 -0
  81. package/dist/data/LocalProjectStore.js +171 -0
  82. package/dist/data/LocalProjectStore.js.map +1 -0
  83. package/dist/data/LocalShareLinkStore.d.ts +39 -0
  84. package/dist/data/LocalShareLinkStore.d.ts.map +1 -0
  85. package/dist/data/LocalShareLinkStore.js +108 -0
  86. package/dist/data/LocalShareLinkStore.js.map +1 -0
  87. package/dist/data/__tests__/LocalDefinitionMetaProvider.test.d.ts +2 -0
  88. package/dist/data/__tests__/LocalDefinitionMetaProvider.test.d.ts.map +1 -0
  89. package/dist/data/__tests__/LocalDefinitionMetaProvider.test.js +21 -0
  90. package/dist/data/__tests__/LocalDefinitionMetaProvider.test.js.map +1 -0
  91. package/dist/data/__tests__/cascade.test.d.ts +2 -0
  92. package/dist/data/__tests__/cascade.test.d.ts.map +1 -0
  93. package/dist/data/__tests__/cascade.test.js +265 -0
  94. package/dist/data/__tests__/cascade.test.js.map +1 -0
  95. package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
  96. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
  97. package/dist/data/__tests__/compute-server-conformance.test.js +21 -0
  98. package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
  99. package/dist/data/__tests__/compute-server-encryption.test.d.ts +2 -0
  100. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
  101. package/dist/data/__tests__/compute-server-encryption.test.js +131 -0
  102. package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
  103. package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
  104. package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
  105. package/dist/data/__tests__/definition-conformance.test.js +20 -0
  106. package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
  107. package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
  108. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
  109. package/dist/data/__tests__/event-sink-conformance.test.js +24 -0
  110. package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
  111. package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
  112. package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
  113. package/dist/data/__tests__/invite-conformance.test.js +21 -0
  114. package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
  115. package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
  116. package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
  117. package/dist/data/__tests__/org-conformance.test.js +36 -0
  118. package/dist/data/__tests__/org-conformance.test.js.map +1 -0
  119. package/dist/data/__tests__/platform-project-grant-conformance.test.d.ts +2 -0
  120. package/dist/data/__tests__/platform-project-grant-conformance.test.d.ts.map +1 -0
  121. package/dist/data/__tests__/platform-project-grant-conformance.test.js +20 -0
  122. package/dist/data/__tests__/platform-project-grant-conformance.test.js.map +1 -0
  123. package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
  124. package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
  125. package/dist/data/__tests__/project-conformance.test.js +53 -0
  126. package/dist/data/__tests__/project-conformance.test.js.map +1 -0
  127. package/dist/data/__tests__/rules.test.d.ts +2 -0
  128. package/dist/data/__tests__/rules.test.d.ts.map +1 -0
  129. package/dist/data/__tests__/rules.test.js +484 -0
  130. package/dist/data/__tests__/rules.test.js.map +1 -0
  131. package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
  132. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
  133. package/dist/data/__tests__/share-link-conformance.test.js +20 -0
  134. package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
  135. package/dist/data/fsJson.d.ts +12 -0
  136. package/dist/data/fsJson.d.ts.map +1 -0
  137. package/dist/data/fsJson.js +29 -0
  138. package/dist/data/fsJson.js.map +1 -0
  139. package/dist/data/index.d.ts +13 -0
  140. package/dist/data/index.d.ts.map +1 -0
  141. package/dist/data/index.js +9 -0
  142. package/dist/data/index.js.map +1 -0
  143. package/dist/data/pagination.d.ts +15 -0
  144. package/dist/data/pagination.d.ts.map +1 -0
  145. package/dist/data/pagination.js +36 -0
  146. package/dist/data/pagination.js.map +1 -0
  147. package/dist/data/secretCrypto.d.ts +23 -0
  148. package/dist/data/secretCrypto.d.ts.map +1 -0
  149. package/dist/data/secretCrypto.js +64 -0
  150. package/dist/data/secretCrypto.js.map +1 -0
  151. package/dist/data/userData.d.ts +40 -0
  152. package/dist/data/userData.d.ts.map +1 -0
  153. package/dist/data/userData.js +84 -0
  154. package/dist/data/userData.js.map +1 -0
  155. package/dist/definitions/LocalDefinitionMetaProvider.d.ts +27 -0
  156. package/dist/definitions/LocalDefinitionMetaProvider.d.ts.map +1 -0
  157. package/dist/definitions/LocalDefinitionMetaProvider.js +188 -0
  158. package/dist/definitions/LocalDefinitionMetaProvider.js.map +1 -0
  159. package/dist/definitions/__tests__/conformance.test.d.ts +2 -0
  160. package/dist/definitions/__tests__/conformance.test.d.ts.map +1 -0
  161. package/dist/definitions/__tests__/conformance.test.js +20 -0
  162. package/dist/definitions/__tests__/conformance.test.js.map +1 -0
  163. package/dist/definitions/index.d.ts +2 -0
  164. package/dist/definitions/index.d.ts.map +1 -0
  165. package/dist/definitions/index.js +2 -0
  166. package/dist/definitions/index.js.map +1 -0
  167. package/dist/definitions/providers/filesystem-files.d.ts +24 -0
  168. package/dist/definitions/providers/filesystem-files.d.ts.map +1 -0
  169. package/dist/definitions/providers/filesystem-files.js +170 -0
  170. package/dist/definitions/providers/filesystem-files.js.map +1 -0
  171. package/dist/definitions/providers/filesystem-meta.d.ts +17 -0
  172. package/dist/definitions/providers/filesystem-meta.d.ts.map +1 -0
  173. package/dist/definitions/providers/filesystem-meta.js +216 -0
  174. package/dist/definitions/providers/filesystem-meta.js.map +1 -0
  175. package/dist/fsJson.d.ts +12 -0
  176. package/dist/fsJson.d.ts.map +1 -0
  177. package/dist/fsJson.js +29 -0
  178. package/dist/fsJson.js.map +1 -0
  179. package/dist/index.d.ts +13 -0
  180. package/dist/index.d.ts.map +1 -0
  181. package/dist/index.js +16 -0
  182. package/dist/index.js.map +1 -0
  183. package/dist/invites/LocalInviteProvider.d.ts +24 -0
  184. package/dist/invites/LocalInviteProvider.d.ts.map +1 -0
  185. package/dist/invites/LocalInviteProvider.js +89 -0
  186. package/dist/invites/LocalInviteProvider.js.map +1 -0
  187. package/dist/invites/__tests__/conformance.test.d.ts +2 -0
  188. package/dist/invites/__tests__/conformance.test.d.ts.map +1 -0
  189. package/dist/invites/__tests__/conformance.test.js +21 -0
  190. package/dist/invites/__tests__/conformance.test.js.map +1 -0
  191. package/dist/organizations/LocalOrganizationProvider.d.ts +41 -0
  192. package/dist/organizations/LocalOrganizationProvider.d.ts.map +1 -0
  193. package/dist/organizations/LocalOrganizationProvider.js +198 -0
  194. package/dist/organizations/LocalOrganizationProvider.js.map +1 -0
  195. package/dist/organizations/__tests__/conformance.test.d.ts +2 -0
  196. package/dist/organizations/__tests__/conformance.test.d.ts.map +1 -0
  197. package/dist/organizations/__tests__/conformance.test.js +20 -0
  198. package/dist/organizations/__tests__/conformance.test.js.map +1 -0
  199. package/dist/organizations/index.d.ts +2 -0
  200. package/dist/organizations/index.d.ts.map +1 -0
  201. package/dist/organizations/index.js +2 -0
  202. package/dist/organizations/index.js.map +1 -0
  203. package/dist/pagination.d.ts +15 -0
  204. package/dist/pagination.d.ts.map +1 -0
  205. package/dist/pagination.js +36 -0
  206. package/dist/pagination.js.map +1 -0
  207. package/dist/permissions/LocalPlatformPermissionStore.d.ts +39 -0
  208. package/dist/permissions/LocalPlatformPermissionStore.d.ts.map +1 -0
  209. package/dist/permissions/LocalPlatformPermissionStore.js +117 -0
  210. package/dist/permissions/LocalPlatformPermissionStore.js.map +1 -0
  211. package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
  212. package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
  213. package/dist/permissions/__tests__/conformance.test.js +37 -0
  214. package/dist/permissions/__tests__/conformance.test.js.map +1 -0
  215. package/dist/permissions/index.d.ts +2 -0
  216. package/dist/permissions/index.d.ts.map +1 -0
  217. package/dist/permissions/index.js +2 -0
  218. package/dist/permissions/index.js.map +1 -0
  219. package/dist/projects/LocalProjectProvider.d.ts +21 -0
  220. package/dist/projects/LocalProjectProvider.d.ts.map +1 -0
  221. package/dist/projects/LocalProjectProvider.js +125 -0
  222. package/dist/projects/LocalProjectProvider.js.map +1 -0
  223. package/dist/projects/__tests__/conformance.test.d.ts +2 -0
  224. package/dist/projects/__tests__/conformance.test.d.ts.map +1 -0
  225. package/dist/projects/__tests__/conformance.test.js +44 -0
  226. package/dist/projects/__tests__/conformance.test.js.map +1 -0
  227. package/dist/projects/index.d.ts +2 -0
  228. package/dist/projects/index.d.ts.map +1 -0
  229. package/dist/projects/index.js +2 -0
  230. package/dist/projects/index.js.map +1 -0
  231. package/dist/storage/LocalStorageProvider.d.ts +27 -0
  232. package/dist/storage/LocalStorageProvider.d.ts.map +1 -0
  233. package/dist/storage/LocalStorageProvider.js +74 -0
  234. package/dist/storage/LocalStorageProvider.js.map +1 -0
  235. package/dist/storage/__tests__/conformance.test.d.ts +2 -0
  236. package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
  237. package/dist/storage/__tests__/conformance.test.js +20 -0
  238. package/dist/storage/__tests__/conformance.test.js.map +1 -0
  239. package/dist/storage/index.d.ts +2 -0
  240. package/dist/storage/index.d.ts.map +1 -0
  241. package/dist/storage/index.js +2 -0
  242. package/dist/storage/index.js.map +1 -0
  243. package/dist/userProfile/LocalUserProfileProvider.d.ts +25 -0
  244. package/dist/userProfile/LocalUserProfileProvider.d.ts.map +1 -0
  245. package/dist/userProfile/LocalUserProfileProvider.js +110 -0
  246. package/dist/userProfile/LocalUserProfileProvider.js.map +1 -0
  247. package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
  248. package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
  249. package/dist/userProfile/__tests__/conformance.test.js +40 -0
  250. package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
  251. package/dist/userProfile/index.d.ts +2 -0
  252. package/dist/userProfile/index.d.ts.map +1 -0
  253. package/dist/userProfile/index.js +2 -0
  254. package/dist/userProfile/index.js.map +1 -0
  255. package/package.json +70 -0
  256. package/src/README.md +37 -0
  257. package/src/auth/LocalAuthProvider.ts +165 -0
  258. package/src/auth/__tests__/conformance.test.ts +40 -0
  259. package/src/auth/hmac.ts +53 -0
  260. package/src/auth/index.ts +5 -0
  261. package/src/auth/users.ts +151 -0
  262. package/src/data/LocalComputeServerStore.ts +290 -0
  263. package/src/data/LocalDataProvider.ts +148 -0
  264. package/src/data/LocalDefinitionStore.ts +369 -0
  265. package/src/data/LocalInviteStore.ts +117 -0
  266. package/src/data/LocalOrgStore.ts +356 -0
  267. package/src/data/LocalPlatformProjectGrantStore.ts +85 -0
  268. package/src/data/LocalProjectStore.ts +274 -0
  269. package/src/data/LocalShareLinkStore.ts +138 -0
  270. package/src/data/__tests__/cascade.test.ts +300 -0
  271. package/src/data/__tests__/compute-server-conformance.test.ts +26 -0
  272. package/src/data/__tests__/compute-server-encryption.test.ts +185 -0
  273. package/src/data/__tests__/definition-conformance.test.ts +23 -0
  274. package/src/data/__tests__/event-sink-conformance.test.ts +28 -0
  275. package/src/data/__tests__/invite-conformance.test.ts +24 -0
  276. package/src/data/__tests__/org-conformance.test.ts +43 -0
  277. package/src/data/__tests__/platform-project-grant-conformance.test.ts +24 -0
  278. package/src/data/__tests__/project-conformance.test.ts +64 -0
  279. package/src/data/__tests__/rules.test.ts +682 -0
  280. package/src/data/__tests__/share-link-conformance.test.ts +23 -0
  281. package/src/data/fsJson.ts +28 -0
  282. package/src/data/index.ts +16 -0
  283. package/src/data/pagination.ts +48 -0
  284. package/src/data/secretCrypto.ts +69 -0
  285. package/src/data/userData.ts +134 -0
  286. package/src/index.ts +42 -0
  287. package/src/permissions/LocalPlatformPermissionStore.ts +129 -0
  288. package/src/permissions/__tests__/conformance.test.ts +40 -0
  289. package/src/permissions/index.ts +1 -0
  290. package/src/storage/LocalStorageProvider.ts +78 -0
  291. package/src/storage/__tests__/conformance.test.ts +23 -0
  292. package/src/storage/index.ts +1 -0
  293. package/src/userProfile/LocalUserProfileProvider.ts +135 -0
  294. package/src/userProfile/__tests__/conformance.test.ts +43 -0
  295. package/src/userProfile/index.ts +1 -0
@@ -0,0 +1,356 @@
1
+ import * as path from 'node:path';
2
+ import type {
3
+ IOrgStore,
4
+ IInviteStore,
5
+ IComputeServerStore,
6
+ IPlatformProjectGrantStore,
7
+ IEventSink,
8
+ Organization,
9
+ OrgRole,
10
+ OrgPermission,
11
+ OrgMember,
12
+ Project,
13
+ ProjectMember,
14
+ RequestContext,
15
+ ListOptions,
16
+ Page
17
+ } from '@selvajs/platform';
18
+ import {
19
+ DEFAULT_ORG_PERMISSIONS,
20
+ ProviderError,
21
+ auditUpdate,
22
+ auditSoftDelete,
23
+ actorFrom,
24
+ NoopEventSink
25
+ } from '@selvajs/platform';
26
+ import { paginate, applyOrder } from './pagination.js';
27
+ import { readJsonFile, writeJsonFile } from './fsJson.js';
28
+ import { LocalInviteStore } from './LocalInviteStore.js';
29
+ import { LocalComputeServerStore } from './LocalComputeServerStore.js';
30
+ import { LocalPlatformProjectGrantStore } from './LocalPlatformProjectGrantStore.js';
31
+
32
+ /** Shape of the on-disk local-org.json file. */
33
+ export interface LocalOrgStoreData {
34
+ orgs: Organization[];
35
+ projects: Project[];
36
+ orgMembers: OrgMember[];
37
+ projectMembers: ProjectMember[];
38
+ }
39
+
40
+ /** Data-access-layer filter: row is live (not soft-deleted). */
41
+ function isLive<T extends { deletedAt?: string | null }>(row: T): boolean {
42
+ return row.deletedAt == null;
43
+ }
44
+
45
+ /**
46
+ * Shared by LocalOrgStore and LocalProjectStore — one instance so all
47
+ * reads/writes go through one cache and one atomic write path. The store
48
+ * starts empty; orgs are created explicitly via `createOrg`.
49
+ *
50
+ * Concurrent first-callers share a single in-flight read promise so they
51
+ * end up with the same cached object reference. Mutations after that point
52
+ * stack on the shared object, and `writeJsonFile`'s temp+rename keeps the
53
+ * on-disk view atomic.
54
+ */
55
+ export class LocalOrgStoreLoader {
56
+ readonly storePath: string;
57
+ private store: LocalOrgStoreData | null = null;
58
+ private loading: Promise<LocalOrgStoreData> | null = null;
59
+
60
+ constructor(dataPath: string) {
61
+ this.storePath = path.join(dataPath, 'local-org.json');
62
+ }
63
+
64
+ async get(): Promise<LocalOrgStoreData> {
65
+ if (this.store) return this.store;
66
+ this.loading ??= readJsonFile<LocalOrgStoreData>(this.storePath, {
67
+ orgs: [],
68
+ projects: [],
69
+ orgMembers: [],
70
+ projectMembers: []
71
+ }).then((data) => {
72
+ this.store = data;
73
+ this.loading = null;
74
+ return data;
75
+ });
76
+ return this.loading;
77
+ }
78
+
79
+ async write(store: LocalOrgStoreData): Promise<void> {
80
+ this.store = store;
81
+ await writeJsonFile(this.storePath, store);
82
+ }
83
+ }
84
+
85
+ export interface LocalOrgStoreOptions {
86
+ loader: LocalOrgStoreLoader;
87
+ /**
88
+ * Sibling stores wired in for the `deleteOrg` cascade. The local provider
89
+ * splits invites, compute config, and platform-project grants into
90
+ * separate JSON files that the loader can't reach — so they're injected
91
+ * here. Required, not optional: an unwired cascade silently leaks
92
+ * operational data, exactly the footgun the cascade fix was meant to
93
+ * remove.
94
+ */
95
+ invites: IInviteStore;
96
+ computeServer: IComputeServerStore;
97
+ grants: IPlatformProjectGrantStore;
98
+ events?: IEventSink;
99
+ }
100
+
101
+ export class LocalOrgStore implements IOrgStore {
102
+ private readonly loader: LocalOrgStoreLoader;
103
+ private readonly events: IEventSink;
104
+ private readonly invites: IInviteStore;
105
+ private readonly computeServer: IComputeServerStore;
106
+ private readonly grants: IPlatformProjectGrantStore;
107
+
108
+ static fromEnv(env: Record<string, string | undefined>): LocalOrgStore {
109
+ if (!env.DATA_PATH) throw new Error('Missing required env var: DATA_PATH');
110
+ return new LocalOrgStore({
111
+ loader: new LocalOrgStoreLoader(env.DATA_PATH),
112
+ invites: LocalInviteStore.fromEnv(env),
113
+ computeServer: LocalComputeServerStore.fromEnv(env),
114
+ grants: LocalPlatformProjectGrantStore.fromEnv(env)
115
+ });
116
+ }
117
+
118
+ constructor(opts: LocalOrgStoreOptions) {
119
+ this.loader = opts.loader;
120
+ this.invites = opts.invites;
121
+ this.computeServer = opts.computeServer;
122
+ this.grants = opts.grants;
123
+ this.events = opts.events ?? new NoopEventSink();
124
+ }
125
+
126
+ async listOrgs(_ctx: RequestContext, opts?: ListOptions): Promise<Page<Organization>> {
127
+ const { orgs } = await this.loader.get();
128
+ return paginate(applyOrder(orgs.filter(isLive), opts), opts);
129
+ }
130
+
131
+ async getOrg(_ctx: RequestContext, id: string): Promise<Organization | null> {
132
+ const { orgs } = await this.loader.get();
133
+ const o = orgs.find((o) => o.id === id);
134
+ return o && isLive(o) ? o : null;
135
+ }
136
+
137
+ async getOrgBySlug(_ctx: RequestContext, slug: string): Promise<Organization | null> {
138
+ const { orgs } = await this.loader.get();
139
+ const o = orgs.find((o) => o.slug === slug);
140
+ return o && isLive(o) ? o : null;
141
+ }
142
+
143
+ async createOrg(ctx: RequestContext, org: Organization): Promise<void> {
144
+ const store = await this.loader.get();
145
+ if (store.orgs.some((o) => o.id === org.id && isLive(o))) {
146
+ throw new ProviderError(`Org '${org.id}' already exists`, 409);
147
+ }
148
+ if (store.orgs.some((o) => o.slug === org.slug && isLive(o))) {
149
+ throw new ProviderError(`Org slug '${org.slug}' already in use`, 409);
150
+ }
151
+ store.orgs.push({ ...org, deletedAt: null });
152
+ const now = new Date().toISOString();
153
+ store.orgMembers.push({
154
+ orgId: org.id,
155
+ userId: org.ownerId,
156
+ role: 'owner',
157
+ permissions: [...DEFAULT_ORG_PERMISSIONS.owner],
158
+ joinedAt: now,
159
+ ...auditUpdate(ctx, org.ownerId),
160
+ deletedAt: null
161
+ });
162
+ await this.loader.write(store);
163
+ await this.events.emit({ type: 'org.created', orgId: org.id, actorId: actorFrom(ctx) });
164
+ }
165
+
166
+ async updateOrg(
167
+ ctx: RequestContext,
168
+ id: string,
169
+ patch: Partial<Pick<Organization, 'name' | 'slug'>>
170
+ ): Promise<void> {
171
+ const store = await this.loader.get();
172
+ const idx = store.orgs.findIndex((o) => o.id === id && isLive(o));
173
+ if (idx === -1) throw new ProviderError(`Org '${id}' not found`, 404);
174
+ if (patch.slug && patch.slug !== store.orgs[idx].slug) {
175
+ if (store.orgs.some((o) => o.id !== id && o.slug === patch.slug && isLive(o))) {
176
+ throw new ProviderError(`Org slug '${patch.slug}' already in use`, 409);
177
+ }
178
+ }
179
+ store.orgs[idx] = {
180
+ ...store.orgs[idx],
181
+ ...patch,
182
+ ...auditUpdate(ctx, store.orgs[idx].updatedBy ?? store.orgs[idx].ownerId)
183
+ };
184
+ await this.loader.write(store);
185
+ }
186
+
187
+ async deleteOrg(ctx: RequestContext, id: string): Promise<void> {
188
+ const store = await this.loader.get();
189
+ const idx = store.orgs.findIndex((o) => o.id === id && isLive(o));
190
+ if (idx === -1) throw new ProviderError(`Org '${id}' not found`, 404);
191
+ const stamp = auditSoftDelete(ctx, store.orgs[idx].updatedBy ?? store.orgs[idx].ownerId);
192
+ // Cascade soft-delete into members, projects, and project members.
193
+ store.orgs[idx] = { ...store.orgs[idx], ...stamp };
194
+ store.orgMembers = store.orgMembers.map((m) =>
195
+ m.orgId === id && isLive(m) ? { ...m, ...stamp } : m
196
+ );
197
+ const orgProjectIds = new Set(
198
+ store.projects.filter((p) => p.orgId === id && isLive(p)).map((p) => p.id)
199
+ );
200
+ store.projects = store.projects.map((p) =>
201
+ p.orgId === id && isLive(p) ? { ...p, ...stamp } : p
202
+ );
203
+ store.projectMembers = store.projectMembers.map((m) =>
204
+ orgProjectIds.has(m.projectId) && isLive(m) ? { ...m, ...stamp } : m
205
+ );
206
+ await this.loader.write(store);
207
+ // Cascade into hard-delete-only sibling stores. Pending invites and
208
+ // org compute config are operational, not user data — no audit trail
209
+ // to preserve, and leaving them behind only creates orphans.
210
+ await this.invites.deleteByOrg(ctx, id);
211
+ await this.computeServer.deleteByOrg(ctx, id);
212
+ // Drop grants for projects we just soft-deleted, plus any grant where
213
+ // this org is the grantee. User grants survive — they're identity-
214
+ // scoped, not org-scoped.
215
+ for (const projectId of orgProjectIds) {
216
+ await this.grants.deleteByProject(ctx, projectId);
217
+ }
218
+ await this.grants.deleteByGranteeOrg(ctx, id);
219
+ await this.events.emit({ type: 'org.deleted', orgId: id, actorId: actorFrom(ctx) });
220
+ }
221
+
222
+ async listOrgMembers(
223
+ _ctx: RequestContext,
224
+ orgId: string,
225
+ opts?: ListOptions
226
+ ): Promise<Page<OrgMember>> {
227
+ const { orgMembers } = await this.loader.get();
228
+ return paginate(
229
+ orgMembers.filter((m) => m.orgId === orgId && isLive(m)),
230
+ opts
231
+ );
232
+ }
233
+
234
+ async getOrgMember(
235
+ _ctx: RequestContext,
236
+ orgId: string,
237
+ userId: string
238
+ ): Promise<OrgMember | null> {
239
+ const { orgMembers } = await this.loader.get();
240
+ const m = orgMembers.find((m) => m.orgId === orgId && m.userId === userId);
241
+ return m && isLive(m) ? m : null;
242
+ }
243
+
244
+ async findUserMembership(
245
+ _ctx: RequestContext,
246
+ userId: string
247
+ ): Promise<{ org: Organization; member: OrgMember } | null> {
248
+ // Single pass over org_members for this user (filesystem JSON, so the
249
+ // file IS the index). Pick the first live membership; ordering follows
250
+ // insertion order in the JSON file, stable across reads.
251
+ const store = await this.loader.get();
252
+ const member = store.orgMembers.find((m) => m.userId === userId && isLive(m));
253
+ if (!member) return null;
254
+ const org = store.orgs.find((o) => o.id === member.orgId);
255
+ // A membership pointing at a soft-deleted org is treated as gone — same
256
+ // invariant the SQL adapter gets via FK + RLS on `orgs.deleted_at`.
257
+ if (!org || !isLive(org)) return null;
258
+ return { org, member };
259
+ }
260
+
261
+ async addOrgMember(ctx: RequestContext, member: OrgMember): Promise<void> {
262
+ const store = await this.loader.get();
263
+ // Reactivate a prior soft-deleted row rather than piling rows up.
264
+ const existing = store.orgMembers.find(
265
+ (m) => m.orgId === member.orgId && m.userId === member.userId
266
+ );
267
+ if (existing) {
268
+ Object.assign(existing, member, {
269
+ ...auditUpdate(ctx, member.userId),
270
+ deletedAt: null
271
+ });
272
+ } else {
273
+ store.orgMembers.push({ ...member, deletedAt: null });
274
+ }
275
+ await this.loader.write(store);
276
+ await this.events.emit({
277
+ type: 'org_member.added',
278
+ orgId: member.orgId,
279
+ userId: member.userId,
280
+ actorId: actorFrom(ctx)
281
+ });
282
+ }
283
+
284
+ async updateOrgMemberRole(
285
+ ctx: RequestContext,
286
+ orgId: string,
287
+ userId: string,
288
+ role: OrgRole
289
+ ): Promise<void> {
290
+ const store = await this.loader.get();
291
+ const m = store.orgMembers.find((m) => m.orgId === orgId && m.userId === userId && isLive(m));
292
+ if (!m) throw new ProviderError(`Org member '${userId}' not found`, 404);
293
+ m.role = role;
294
+ // Role change re-seeds the permission defaults. To preserve a custom
295
+ // OrgPermission set, call updateOrgMemberPermissions after the role change.
296
+ m.permissions = [...DEFAULT_ORG_PERMISSIONS[role]];
297
+ Object.assign(m, auditUpdate(ctx, m.updatedBy));
298
+ await this.loader.write(store);
299
+ await this.events.emit({
300
+ type: 'org_member.role_changed',
301
+ orgId,
302
+ userId,
303
+ role,
304
+ actorId: actorFrom(ctx)
305
+ });
306
+ }
307
+
308
+ async updateOrgMemberPermissions(
309
+ ctx: RequestContext,
310
+ orgId: string,
311
+ userId: string,
312
+ permissions: readonly OrgPermission[]
313
+ ): Promise<void> {
314
+ const store = await this.loader.get();
315
+ const m = store.orgMembers.find((m) => m.orgId === orgId && m.userId === userId && isLive(m));
316
+ if (!m) throw new ProviderError(`Org member '${userId}' not found`, 404);
317
+ m.permissions = [...permissions];
318
+ Object.assign(m, auditUpdate(ctx, m.updatedBy));
319
+ await this.loader.write(store);
320
+ await this.events.emit({
321
+ type: 'org_member.permissions_changed',
322
+ orgId,
323
+ userId,
324
+ permissions: [...permissions],
325
+ actorId: actorFrom(ctx)
326
+ });
327
+ }
328
+
329
+ async removeOrgMember(ctx: RequestContext, orgId: string, userId: string): Promise<void> {
330
+ const store = await this.loader.get();
331
+ const m = store.orgMembers.find((m) => m.orgId === orgId && m.userId === userId && isLive(m));
332
+ if (!m) return;
333
+ const stamp = auditSoftDelete(ctx, m.updatedBy);
334
+ Object.assign(m, stamp);
335
+
336
+ // §9: losing org membership ends every project membership scoped to
337
+ // that tenant. Cascading here keeps the "members ⊂ org members"
338
+ // invariant true by construction so reads don't need to re-check it.
339
+ const projectIdsInOrg = new Set(
340
+ store.projects.filter((p) => p.orgId === orgId).map((p) => p.id)
341
+ );
342
+ store.projectMembers = store.projectMembers.map((pm) =>
343
+ pm.userId === userId && projectIdsInOrg.has(pm.projectId) && isLive(pm)
344
+ ? { ...pm, ...stamp }
345
+ : pm
346
+ );
347
+
348
+ await this.loader.write(store);
349
+ await this.events.emit({
350
+ type: 'org_member.removed',
351
+ orgId,
352
+ userId,
353
+ actorId: actorFrom(ctx)
354
+ });
355
+ }
356
+ }
@@ -0,0 +1,85 @@
1
+ import * as path from 'node:path';
2
+ import type {
3
+ IPlatformProjectGrantStore,
4
+ PlatformProjectGrant,
5
+ RequestContext
6
+ } from '@selvajs/platform';
7
+ import { ProviderError } from '@selvajs/platform';
8
+ import { readJsonFile, writeJsonFile } from './fsJson.js';
9
+
10
+ interface OnDiskShape {
11
+ grants: PlatformProjectGrant[];
12
+ }
13
+
14
+ const empty = (): OnDiskShape => ({ grants: [] });
15
+
16
+ export class LocalPlatformProjectGrantStore implements IPlatformProjectGrantStore {
17
+ private readonly filePath: string;
18
+
19
+ static fromEnv(env: Record<string, string | undefined>): LocalPlatformProjectGrantStore {
20
+ if (!env.DATA_PATH) throw new Error('Missing required env var: DATA_PATH');
21
+ return new LocalPlatformProjectGrantStore(
22
+ path.join(env.DATA_PATH, 'platform-project-grants.json')
23
+ );
24
+ }
25
+
26
+ constructor(filePath: string) {
27
+ this.filePath = filePath;
28
+ }
29
+
30
+ private async read(): Promise<OnDiskShape> {
31
+ return readJsonFile<OnDiskShape>(this.filePath, empty());
32
+ }
33
+
34
+ private async write(data: OnDiskShape): Promise<void> {
35
+ await writeJsonFile(this.filePath, data);
36
+ }
37
+
38
+ async listByProject(_ctx: RequestContext, projectId: string): Promise<PlatformProjectGrant[]> {
39
+ const { grants } = await this.read();
40
+ return grants.filter((g) => g.projectId === projectId);
41
+ }
42
+
43
+ async create(_ctx: RequestContext, grant: PlatformProjectGrant): Promise<void> {
44
+ const data = await this.read();
45
+ if (data.grants.some((g) => g.id === grant.id)) {
46
+ throw new ProviderError(`Grant '${grant.id}' already exists`, 409);
47
+ }
48
+ const duplicate = data.grants.find(
49
+ (g) =>
50
+ g.projectId === grant.projectId &&
51
+ g.granteeType === grant.granteeType &&
52
+ g.granteeId === grant.granteeId
53
+ );
54
+ if (duplicate) {
55
+ throw new ProviderError(
56
+ `A grant for this ${grant.granteeType} already exists on this project`,
57
+ 409
58
+ );
59
+ }
60
+ data.grants.push(grant);
61
+ await this.write(data);
62
+ }
63
+
64
+ async delete(_ctx: RequestContext, id: string): Promise<void> {
65
+ const data = await this.read();
66
+ const idx = data.grants.findIndex((g) => g.id === id);
67
+ if (idx === -1) throw new ProviderError(`Grant '${id}' not found`, 404);
68
+ data.grants.splice(idx, 1);
69
+ await this.write(data);
70
+ }
71
+
72
+ async deleteByProject(_ctx: RequestContext, projectId: string): Promise<void> {
73
+ const data = await this.read();
74
+ const before = data.grants.length;
75
+ data.grants = data.grants.filter((g) => g.projectId !== projectId);
76
+ if (data.grants.length !== before) await this.write(data);
77
+ }
78
+
79
+ async deleteByGranteeOrg(_ctx: RequestContext, orgId: string): Promise<void> {
80
+ const data = await this.read();
81
+ const before = data.grants.length;
82
+ data.grants = data.grants.filter((g) => !(g.granteeType === 'org' && g.granteeId === orgId));
83
+ if (data.grants.length !== before) await this.write(data);
84
+ }
85
+ }
@@ -0,0 +1,274 @@
1
+ import type {
2
+ IProjectStore,
3
+ IPlatformProjectGrantStore,
4
+ IEventSink,
5
+ Project,
6
+ ProjectRole,
7
+ ProjectMember,
8
+ RequestContext,
9
+ ListOptions,
10
+ Page
11
+ } from '@selvajs/platform';
12
+ import {
13
+ ProviderError,
14
+ auditUpdate,
15
+ auditSoftDelete,
16
+ actorFrom,
17
+ NoopEventSink
18
+ } from '@selvajs/platform';
19
+ import { paginate, applyOrder } from './pagination.js';
20
+ import type { LocalOrgStoreLoader } from './LocalOrgStore.js';
21
+
22
+ /** Data-access-layer filter — never let a soft-deleted row surface to callers. */
23
+ function isLive<T extends { deletedAt?: string | null }>(row: T): boolean {
24
+ return row.deletedAt == null;
25
+ }
26
+
27
+ export interface LocalProjectStoreOptions {
28
+ loader: LocalOrgStoreLoader;
29
+ /**
30
+ * Sibling store wired in for the `deleteProject` cascade. Grants live in
31
+ * a separate JSON file the loader can't reach. Required, not optional —
32
+ * an unwired cascade leaks grants on platform projects after deletion.
33
+ */
34
+ grants: IPlatformProjectGrantStore;
35
+ events?: IEventSink;
36
+ }
37
+
38
+ export class LocalProjectStore implements IProjectStore {
39
+ private readonly loader: LocalOrgStoreLoader;
40
+ private readonly events: IEventSink;
41
+ private readonly grants: IPlatformProjectGrantStore;
42
+
43
+ constructor(opts: LocalProjectStoreOptions) {
44
+ this.loader = opts.loader;
45
+ this.grants = opts.grants;
46
+ this.events = opts.events ?? new NoopEventSink();
47
+ }
48
+
49
+ async listProjects(
50
+ _ctx: RequestContext,
51
+ orgId: string,
52
+ opts?: ListOptions
53
+ ): Promise<Page<Project>> {
54
+ const { projects } = await this.loader.get();
55
+ return paginate(
56
+ applyOrder(
57
+ projects.filter((p) => p.orgId === orgId && isLive(p)),
58
+ opts
59
+ ),
60
+ opts
61
+ );
62
+ }
63
+
64
+ async getProject(_ctx: RequestContext, id: string): Promise<Project | null> {
65
+ const { projects } = await this.loader.get();
66
+ const p = projects.find((p) => p.id === id);
67
+ return p && isLive(p) ? p : null;
68
+ }
69
+
70
+ async getProjectBySlug(
71
+ _ctx: RequestContext,
72
+ orgId: string,
73
+ slug: string
74
+ ): Promise<Project | null> {
75
+ const { projects } = await this.loader.get();
76
+ const p = projects.find((p) => p.orgId === orgId && p.slug === slug);
77
+ return p && isLive(p) ? p : null;
78
+ }
79
+
80
+ async createProject(ctx: RequestContext, project: Project): Promise<void> {
81
+ const store = await this.loader.get();
82
+ if (!store.orgs.some((o) => o.id === project.orgId && isLive(o))) {
83
+ throw new ProviderError(`Org '${project.orgId}' not found`, 404);
84
+ }
85
+ if (store.projects.some((p) => p.id === project.id && isLive(p))) {
86
+ throw new ProviderError(`Project '${project.id}' already exists`, 409);
87
+ }
88
+ const nameKey = project.name.toLowerCase();
89
+ if (
90
+ store.projects.some(
91
+ (p) => p.orgId === project.orgId && isLive(p) && p.name.toLowerCase() === nameKey
92
+ )
93
+ ) {
94
+ throw new ProviderError('projects_org_name_unique: project name already in use', 409);
95
+ }
96
+ if (
97
+ store.projects.some((p) => p.orgId === project.orgId && isLive(p) && p.slug === project.slug)
98
+ ) {
99
+ throw new ProviderError('projects_org_id_slug_key: project slug already in use', 409);
100
+ }
101
+ store.projects.push({ ...project, deletedAt: null });
102
+ store.projectMembers.push({
103
+ projectId: project.id,
104
+ userId: project.ownerId,
105
+ role: 'owner',
106
+ joinedAt: project.createdAt,
107
+ updatedAt: project.createdAt,
108
+ updatedBy: project.ownerId,
109
+ deletedAt: null
110
+ });
111
+ await this.loader.write(store);
112
+ await this.events.emit({
113
+ type: 'project.created',
114
+ projectId: project.id,
115
+ orgId: project.orgId,
116
+ actorId: actorFrom(ctx)
117
+ });
118
+ }
119
+
120
+ async updateProject(
121
+ ctx: RequestContext,
122
+ id: string,
123
+ patch: Partial<
124
+ Pick<Project, 'name' | 'slug' | 'description' | 'visibility' | 'autoJoinOnUpload'>
125
+ >
126
+ ): Promise<void> {
127
+ const store = await this.loader.get();
128
+ const idx = store.projects.findIndex((p) => p.id === id && isLive(p));
129
+ if (idx === -1) throw new ProviderError(`Project '${id}' not found`, 404);
130
+
131
+ const current = store.projects[idx];
132
+
133
+ if (patch.name && patch.name.toLowerCase() !== current.name.toLowerCase()) {
134
+ const nameKey = patch.name.toLowerCase();
135
+ if (
136
+ store.projects.some(
137
+ (p) =>
138
+ p.orgId === current.orgId &&
139
+ p.id !== id &&
140
+ isLive(p) &&
141
+ p.name.toLowerCase() === nameKey
142
+ )
143
+ ) {
144
+ throw new ProviderError('projects_org_name_unique: project name already in use', 409);
145
+ }
146
+ }
147
+
148
+ if (patch.slug && patch.slug !== current.slug) {
149
+ if (
150
+ store.projects.some(
151
+ (p) => p.orgId === current.orgId && p.slug === patch.slug && p.id !== id && isLive(p)
152
+ )
153
+ ) {
154
+ throw new ProviderError('projects_org_id_slug_key: project slug already in use', 409);
155
+ }
156
+ }
157
+
158
+ store.projects[idx] = {
159
+ ...current,
160
+ ...patch,
161
+ ...auditUpdate(ctx, current.updatedBy ?? current.ownerId)
162
+ };
163
+ await this.loader.write(store);
164
+ }
165
+
166
+ async deleteProject(ctx: RequestContext, id: string): Promise<void> {
167
+ const store = await this.loader.get();
168
+ const idx = store.projects.findIndex((p) => p.id === id && isLive(p));
169
+ if (idx === -1) throw new ProviderError(`Project '${id}' not found`, 404);
170
+ const stamp = auditSoftDelete(
171
+ ctx,
172
+ store.projects[idx].updatedBy ?? store.projects[idx].ownerId
173
+ );
174
+ store.projects[idx] = { ...store.projects[idx], ...stamp };
175
+ store.projectMembers = store.projectMembers.map((m) =>
176
+ m.projectId === id && isLive(m) ? { ...m, ...stamp } : m
177
+ );
178
+ await this.loader.write(store);
179
+ // Hard-delete grants — they have no soft-delete column and a deleted
180
+ // project should never resolve grants again.
181
+ await this.grants.deleteByProject(ctx, id);
182
+ await this.events.emit({ type: 'project.deleted', projectId: id, actorId: actorFrom(ctx) });
183
+ }
184
+
185
+ async listProjectMembers(
186
+ _ctx: RequestContext,
187
+ projectId: string,
188
+ opts?: ListOptions
189
+ ): Promise<Page<ProjectMember>> {
190
+ const { projectMembers } = await this.loader.get();
191
+ return paginate(
192
+ projectMembers.filter((m) => m.projectId === projectId && isLive(m)),
193
+ opts
194
+ );
195
+ }
196
+
197
+ async getProjectMember(
198
+ _ctx: RequestContext,
199
+ projectId: string,
200
+ userId: string
201
+ ): Promise<ProjectMember | null> {
202
+ const { projectMembers } = await this.loader.get();
203
+ const m = projectMembers.find((m) => m.projectId === projectId && m.userId === userId);
204
+ return m && isLive(m) ? m : null;
205
+ }
206
+
207
+ async addProjectMember(ctx: RequestContext, member: ProjectMember): Promise<void> {
208
+ const store = await this.loader.get();
209
+ // Reactivate a prior soft-deleted row rather than piling rows up.
210
+ const existing = store.projectMembers.find(
211
+ (m) => m.projectId === member.projectId && m.userId === member.userId
212
+ );
213
+ if (existing) {
214
+ Object.assign(existing, member, {
215
+ ...auditUpdate(ctx, member.userId),
216
+ deletedAt: null
217
+ });
218
+ } else {
219
+ const stamp = auditUpdate(ctx, member.userId);
220
+ store.projectMembers.push({
221
+ ...member,
222
+ updatedAt: member.updatedAt ?? stamp.updatedAt,
223
+ updatedBy: member.updatedBy ?? stamp.updatedBy,
224
+ deletedAt: null
225
+ });
226
+ }
227
+ await this.loader.write(store);
228
+ await this.events.emit({
229
+ type: 'project_member.added',
230
+ projectId: member.projectId,
231
+ userId: member.userId,
232
+ actorId: actorFrom(ctx)
233
+ });
234
+ }
235
+
236
+ async updateProjectMemberRole(
237
+ ctx: RequestContext,
238
+ projectId: string,
239
+ userId: string,
240
+ role: ProjectRole
241
+ ): Promise<void> {
242
+ const store = await this.loader.get();
243
+ const m = store.projectMembers.find(
244
+ (m) => m.projectId === projectId && m.userId === userId && isLive(m)
245
+ );
246
+ if (!m) throw new ProviderError(`Project member '${userId}' not found`, 404);
247
+ m.role = role;
248
+ Object.assign(m, auditUpdate(ctx, m.updatedBy));
249
+ await this.loader.write(store);
250
+ await this.events.emit({
251
+ type: 'project_member.role_changed',
252
+ projectId,
253
+ userId,
254
+ role,
255
+ actorId: actorFrom(ctx)
256
+ });
257
+ }
258
+
259
+ async removeProjectMember(ctx: RequestContext, projectId: string, userId: string): Promise<void> {
260
+ const store = await this.loader.get();
261
+ const m = store.projectMembers.find(
262
+ (m) => m.projectId === projectId && m.userId === userId && isLive(m)
263
+ );
264
+ if (!m) return;
265
+ Object.assign(m, auditSoftDelete(ctx, m.updatedBy));
266
+ await this.loader.write(store);
267
+ await this.events.emit({
268
+ type: 'project_member.removed',
269
+ projectId,
270
+ userId,
271
+ actorId: actorFrom(ctx)
272
+ });
273
+ }
274
+ }