@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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/auth/LocalAuthProvider.d.ts +28 -0
- package/dist/auth/LocalAuthProvider.d.ts.map +1 -0
- package/dist/auth/LocalAuthProvider.js +142 -0
- package/dist/auth/LocalAuthProvider.js.map +1 -0
- package/dist/auth/__tests__/conformance.test.d.ts +2 -0
- package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/auth/__tests__/conformance.test.js +36 -0
- package/dist/auth/__tests__/conformance.test.js.map +1 -0
- package/dist/auth/hmac.d.ts +18 -0
- package/dist/auth/hmac.d.ts.map +1 -0
- package/dist/auth/hmac.js +41 -0
- package/dist/auth/hmac.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/users.d.ts +38 -0
- package/dist/auth/users.d.ts.map +1 -0
- package/dist/auth/users.js +100 -0
- package/dist/auth/users.js.map +1 -0
- package/dist/compute/FilesystemComputeProvider.d.ts +16 -0
- package/dist/compute/FilesystemComputeProvider.d.ts.map +1 -0
- package/dist/compute/FilesystemComputeProvider.js +51 -0
- package/dist/compute/FilesystemComputeProvider.js.map +1 -0
- package/dist/compute/SingleComputeServerProvider.d.ts +15 -0
- package/dist/compute/SingleComputeServerProvider.d.ts.map +1 -0
- package/dist/compute/SingleComputeServerProvider.js +26 -0
- package/dist/compute/SingleComputeServerProvider.js.map +1 -0
- package/dist/compute/types.d.ts +30 -0
- package/dist/compute/types.d.ts.map +1 -0
- package/dist/compute/types.js +2 -0
- package/dist/compute/types.js.map +1 -0
- package/dist/computeServer/FilesystemComputeServerStore.d.ts +14 -0
- package/dist/computeServer/FilesystemComputeServerStore.d.ts.map +1 -0
- package/dist/computeServer/FilesystemComputeServerStore.js +35 -0
- package/dist/computeServer/FilesystemComputeServerStore.js.map +1 -0
- package/dist/computeServer/LocalComputeServerProvider.d.ts +18 -0
- package/dist/computeServer/LocalComputeServerProvider.d.ts.map +1 -0
- package/dist/computeServer/LocalComputeServerProvider.js +29 -0
- package/dist/computeServer/LocalComputeServerProvider.js.map +1 -0
- package/dist/computeServer/__tests__/conformance.test.d.ts +2 -0
- package/dist/computeServer/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/computeServer/__tests__/conformance.test.js +20 -0
- package/dist/computeServer/__tests__/conformance.test.js.map +1 -0
- package/dist/computeServer/index.d.ts +2 -0
- package/dist/computeServer/index.d.ts.map +1 -0
- package/dist/computeServer/index.js +2 -0
- package/dist/computeServer/index.js.map +1 -0
- package/dist/data/LocalComputeServerStore.d.ts +72 -0
- package/dist/data/LocalComputeServerStore.d.ts.map +1 -0
- package/dist/data/LocalComputeServerStore.js +207 -0
- package/dist/data/LocalComputeServerStore.js.map +1 -0
- package/dist/data/LocalDataProvider.d.ts +47 -0
- package/dist/data/LocalDataProvider.d.ts.map +1 -0
- package/dist/data/LocalDataProvider.js +118 -0
- package/dist/data/LocalDataProvider.js.map +1 -0
- package/dist/data/LocalDefinitionMetaProvider.d.ts +22 -0
- package/dist/data/LocalDefinitionMetaProvider.d.ts.map +1 -0
- package/dist/data/LocalDefinitionMetaProvider.js +131 -0
- package/dist/data/LocalDefinitionMetaProvider.js.map +1 -0
- package/dist/data/LocalDefinitionStore.d.ts +33 -0
- package/dist/data/LocalDefinitionStore.d.ts.map +1 -0
- package/dist/data/LocalDefinitionStore.js +274 -0
- package/dist/data/LocalDefinitionStore.js.map +1 -0
- package/dist/data/LocalInviteStore.d.ts +23 -0
- package/dist/data/LocalInviteStore.d.ts.map +1 -0
- package/dist/data/LocalInviteStore.js +98 -0
- package/dist/data/LocalInviteStore.js.map +1 -0
- package/dist/data/LocalOrgStore.d.ts +67 -0
- package/dist/data/LocalOrgStore.d.ts.map +1 -0
- package/dist/data/LocalOrgStore.js +255 -0
- package/dist/data/LocalOrgStore.js.map +1 -0
- package/dist/data/LocalPlatformProjectGrantStore.d.ts +14 -0
- package/dist/data/LocalPlatformProjectGrantStore.d.ts.map +1 -0
- package/dist/data/LocalPlatformProjectGrantStore.js +62 -0
- package/dist/data/LocalPlatformProjectGrantStore.js.map +1 -0
- package/dist/data/LocalProjectStore.d.ts +30 -0
- package/dist/data/LocalProjectStore.d.ts.map +1 -0
- package/dist/data/LocalProjectStore.js +171 -0
- package/dist/data/LocalProjectStore.js.map +1 -0
- package/dist/data/LocalShareLinkStore.d.ts +39 -0
- package/dist/data/LocalShareLinkStore.d.ts.map +1 -0
- package/dist/data/LocalShareLinkStore.js +108 -0
- package/dist/data/LocalShareLinkStore.js.map +1 -0
- package/dist/data/__tests__/LocalDefinitionMetaProvider.test.d.ts +2 -0
- package/dist/data/__tests__/LocalDefinitionMetaProvider.test.d.ts.map +1 -0
- package/dist/data/__tests__/LocalDefinitionMetaProvider.test.js +21 -0
- package/dist/data/__tests__/LocalDefinitionMetaProvider.test.js.map +1 -0
- package/dist/data/__tests__/cascade.test.d.ts +2 -0
- package/dist/data/__tests__/cascade.test.d.ts.map +1 -0
- package/dist/data/__tests__/cascade.test.js +265 -0
- package/dist/data/__tests__/cascade.test.js.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.js +21 -0
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
- package/dist/data/__tests__/compute-server-encryption.test.d.ts +2 -0
- package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-encryption.test.js +131 -0
- package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.js +20 -0
- package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.js +24 -0
- package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.js +21 -0
- package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
- package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/org-conformance.test.js +36 -0
- package/dist/data/__tests__/org-conformance.test.js.map +1 -0
- package/dist/data/__tests__/platform-project-grant-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/platform-project-grant-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/platform-project-grant-conformance.test.js +20 -0
- package/dist/data/__tests__/platform-project-grant-conformance.test.js.map +1 -0
- package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/project-conformance.test.js +53 -0
- package/dist/data/__tests__/project-conformance.test.js.map +1 -0
- package/dist/data/__tests__/rules.test.d.ts +2 -0
- package/dist/data/__tests__/rules.test.d.ts.map +1 -0
- package/dist/data/__tests__/rules.test.js +484 -0
- package/dist/data/__tests__/rules.test.js.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.js +20 -0
- package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
- package/dist/data/fsJson.d.ts +12 -0
- package/dist/data/fsJson.d.ts.map +1 -0
- package/dist/data/fsJson.js +29 -0
- package/dist/data/fsJson.js.map +1 -0
- package/dist/data/index.d.ts +13 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +9 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/pagination.d.ts +15 -0
- package/dist/data/pagination.d.ts.map +1 -0
- package/dist/data/pagination.js +36 -0
- package/dist/data/pagination.js.map +1 -0
- package/dist/data/secretCrypto.d.ts +23 -0
- package/dist/data/secretCrypto.d.ts.map +1 -0
- package/dist/data/secretCrypto.js +64 -0
- package/dist/data/secretCrypto.js.map +1 -0
- package/dist/data/userData.d.ts +40 -0
- package/dist/data/userData.d.ts.map +1 -0
- package/dist/data/userData.js +84 -0
- package/dist/data/userData.js.map +1 -0
- package/dist/definitions/LocalDefinitionMetaProvider.d.ts +27 -0
- package/dist/definitions/LocalDefinitionMetaProvider.d.ts.map +1 -0
- package/dist/definitions/LocalDefinitionMetaProvider.js +188 -0
- package/dist/definitions/LocalDefinitionMetaProvider.js.map +1 -0
- package/dist/definitions/__tests__/conformance.test.d.ts +2 -0
- package/dist/definitions/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/definitions/__tests__/conformance.test.js +20 -0
- package/dist/definitions/__tests__/conformance.test.js.map +1 -0
- package/dist/definitions/index.d.ts +2 -0
- package/dist/definitions/index.d.ts.map +1 -0
- package/dist/definitions/index.js +2 -0
- package/dist/definitions/index.js.map +1 -0
- package/dist/definitions/providers/filesystem-files.d.ts +24 -0
- package/dist/definitions/providers/filesystem-files.d.ts.map +1 -0
- package/dist/definitions/providers/filesystem-files.js +170 -0
- package/dist/definitions/providers/filesystem-files.js.map +1 -0
- package/dist/definitions/providers/filesystem-meta.d.ts +17 -0
- package/dist/definitions/providers/filesystem-meta.d.ts.map +1 -0
- package/dist/definitions/providers/filesystem-meta.js +216 -0
- package/dist/definitions/providers/filesystem-meta.js.map +1 -0
- package/dist/fsJson.d.ts +12 -0
- package/dist/fsJson.d.ts.map +1 -0
- package/dist/fsJson.js +29 -0
- package/dist/fsJson.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/invites/LocalInviteProvider.d.ts +24 -0
- package/dist/invites/LocalInviteProvider.d.ts.map +1 -0
- package/dist/invites/LocalInviteProvider.js +89 -0
- package/dist/invites/LocalInviteProvider.js.map +1 -0
- package/dist/invites/__tests__/conformance.test.d.ts +2 -0
- package/dist/invites/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/invites/__tests__/conformance.test.js +21 -0
- package/dist/invites/__tests__/conformance.test.js.map +1 -0
- package/dist/organizations/LocalOrganizationProvider.d.ts +41 -0
- package/dist/organizations/LocalOrganizationProvider.d.ts.map +1 -0
- package/dist/organizations/LocalOrganizationProvider.js +198 -0
- package/dist/organizations/LocalOrganizationProvider.js.map +1 -0
- package/dist/organizations/__tests__/conformance.test.d.ts +2 -0
- package/dist/organizations/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/organizations/__tests__/conformance.test.js +20 -0
- package/dist/organizations/__tests__/conformance.test.js.map +1 -0
- package/dist/organizations/index.d.ts +2 -0
- package/dist/organizations/index.d.ts.map +1 -0
- package/dist/organizations/index.js +2 -0
- package/dist/organizations/index.js.map +1 -0
- package/dist/pagination.d.ts +15 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +36 -0
- package/dist/pagination.js.map +1 -0
- package/dist/permissions/LocalPlatformPermissionStore.d.ts +39 -0
- package/dist/permissions/LocalPlatformPermissionStore.d.ts.map +1 -0
- package/dist/permissions/LocalPlatformPermissionStore.js +117 -0
- package/dist/permissions/LocalPlatformPermissionStore.js.map +1 -0
- package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
- package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/permissions/__tests__/conformance.test.js +37 -0
- package/dist/permissions/__tests__/conformance.test.js.map +1 -0
- package/dist/permissions/index.d.ts +2 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/projects/LocalProjectProvider.d.ts +21 -0
- package/dist/projects/LocalProjectProvider.d.ts.map +1 -0
- package/dist/projects/LocalProjectProvider.js +125 -0
- package/dist/projects/LocalProjectProvider.js.map +1 -0
- package/dist/projects/__tests__/conformance.test.d.ts +2 -0
- package/dist/projects/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/projects/__tests__/conformance.test.js +44 -0
- package/dist/projects/__tests__/conformance.test.js.map +1 -0
- package/dist/projects/index.d.ts +2 -0
- package/dist/projects/index.d.ts.map +1 -0
- package/dist/projects/index.js +2 -0
- package/dist/projects/index.js.map +1 -0
- package/dist/storage/LocalStorageProvider.d.ts +27 -0
- package/dist/storage/LocalStorageProvider.d.ts.map +1 -0
- package/dist/storage/LocalStorageProvider.js +74 -0
- package/dist/storage/LocalStorageProvider.js.map +1 -0
- package/dist/storage/__tests__/conformance.test.d.ts +2 -0
- package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/storage/__tests__/conformance.test.js +20 -0
- package/dist/storage/__tests__/conformance.test.js.map +1 -0
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/userProfile/LocalUserProfileProvider.d.ts +25 -0
- package/dist/userProfile/LocalUserProfileProvider.d.ts.map +1 -0
- package/dist/userProfile/LocalUserProfileProvider.js +110 -0
- package/dist/userProfile/LocalUserProfileProvider.js.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.js +40 -0
- package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
- package/dist/userProfile/index.d.ts +2 -0
- package/dist/userProfile/index.d.ts.map +1 -0
- package/dist/userProfile/index.js +2 -0
- package/dist/userProfile/index.js.map +1 -0
- package/package.json +70 -0
- package/src/README.md +37 -0
- package/src/auth/LocalAuthProvider.ts +165 -0
- package/src/auth/__tests__/conformance.test.ts +40 -0
- package/src/auth/hmac.ts +53 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/users.ts +151 -0
- package/src/data/LocalComputeServerStore.ts +290 -0
- package/src/data/LocalDataProvider.ts +148 -0
- package/src/data/LocalDefinitionStore.ts +369 -0
- package/src/data/LocalInviteStore.ts +117 -0
- package/src/data/LocalOrgStore.ts +356 -0
- package/src/data/LocalPlatformProjectGrantStore.ts +85 -0
- package/src/data/LocalProjectStore.ts +274 -0
- package/src/data/LocalShareLinkStore.ts +138 -0
- package/src/data/__tests__/cascade.test.ts +300 -0
- package/src/data/__tests__/compute-server-conformance.test.ts +26 -0
- package/src/data/__tests__/compute-server-encryption.test.ts +185 -0
- package/src/data/__tests__/definition-conformance.test.ts +23 -0
- package/src/data/__tests__/event-sink-conformance.test.ts +28 -0
- package/src/data/__tests__/invite-conformance.test.ts +24 -0
- package/src/data/__tests__/org-conformance.test.ts +43 -0
- package/src/data/__tests__/platform-project-grant-conformance.test.ts +24 -0
- package/src/data/__tests__/project-conformance.test.ts +64 -0
- package/src/data/__tests__/rules.test.ts +682 -0
- package/src/data/__tests__/share-link-conformance.test.ts +23 -0
- package/src/data/fsJson.ts +28 -0
- package/src/data/index.ts +16 -0
- package/src/data/pagination.ts +48 -0
- package/src/data/secretCrypto.ts +69 -0
- package/src/data/userData.ts +134 -0
- package/src/index.ts +42 -0
- package/src/permissions/LocalPlatformPermissionStore.ts +129 -0
- package/src/permissions/__tests__/conformance.test.ts +40 -0
- package/src/permissions/index.ts +1 -0
- package/src/storage/LocalStorageProvider.ts +78 -0
- package/src/storage/__tests__/conformance.test.ts +23 -0
- package/src/storage/index.ts +1 -0
- package/src/userProfile/LocalUserProfileProvider.ts +135 -0
- package/src/userProfile/__tests__/conformance.test.ts +43 -0
- 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
|
+
}
|