@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,28 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read a JSON file, returning a fallback if the file does not exist.
|
|
6
|
+
* Any other error propagates.
|
|
7
|
+
*/
|
|
8
|
+
export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
11
|
+
return JSON.parse(raw) as T;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Atomically write JSON: ensure the parent directory exists, write to a
|
|
20
|
+
* sibling .tmp file, then rename into place. Prevents partial writes from
|
|
21
|
+
* corrupting the target on crash or interrupt.
|
|
22
|
+
*/
|
|
23
|
+
export async function writeJsonFile<T>(filePath: string, data: T): Promise<void> {
|
|
24
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
25
|
+
const tmp = `${filePath}.tmp`;
|
|
26
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, '\t'), 'utf-8');
|
|
27
|
+
await fs.rename(tmp, filePath);
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { LocalOrgStore, LocalOrgStoreLoader } from './LocalOrgStore.js';
|
|
2
|
+
export type { LocalOrgStoreData, LocalOrgStoreOptions } from './LocalOrgStore.js';
|
|
3
|
+
export { LocalProjectStore } from './LocalProjectStore.js';
|
|
4
|
+
export type { LocalProjectStoreOptions } from './LocalProjectStore.js';
|
|
5
|
+
export { LocalDefinitionStore } from './LocalDefinitionStore.js';
|
|
6
|
+
export { LocalInviteStore } from './LocalInviteStore.js';
|
|
7
|
+
export { LocalComputeServerStore } from './LocalComputeServerStore.js';
|
|
8
|
+
export type {
|
|
9
|
+
SecretVerificationFailure,
|
|
10
|
+
SecretVerificationFailureReason,
|
|
11
|
+
SecretVerificationReport
|
|
12
|
+
} from './LocalComputeServerStore.js';
|
|
13
|
+
export { LocalShareLinkStore } from './LocalShareLinkStore.js';
|
|
14
|
+
export type { LocalShareLinkStoreOptions } from './LocalShareLinkStore.js';
|
|
15
|
+
export { LocalPlatformProjectGrantStore } from './LocalPlatformProjectGrantStore.js';
|
|
16
|
+
export { LocalDataProvider } from './LocalDataProvider.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PAGE_LIMIT,
|
|
3
|
+
MAX_PAGE_LIMIT,
|
|
4
|
+
type ListOptions,
|
|
5
|
+
type DefinitionListOptions,
|
|
6
|
+
type Page
|
|
7
|
+
} from '@selvajs/platform';
|
|
8
|
+
|
|
9
|
+
type AnyListOptions = ListOptions | DefinitionListOptions;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory pagination for filesystem-backed adapters.
|
|
13
|
+
* Cursor is an offset encoded as a string. Good enough for single-node local use.
|
|
14
|
+
*/
|
|
15
|
+
export function paginate<T>(items: T[], opts?: AnyListOptions): Page<T> {
|
|
16
|
+
const limit = Math.min(Math.max(1, opts?.limit ?? DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
|
|
17
|
+
const offset = opts?.cursor ? parseInt(opts.cursor, 10) || 0 : 0;
|
|
18
|
+
const slice = items.slice(offset, offset + limit);
|
|
19
|
+
const nextOffset = offset + slice.length;
|
|
20
|
+
return {
|
|
21
|
+
items: slice,
|
|
22
|
+
nextCursor: nextOffset < items.length ? String(nextOffset) : undefined
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sort a list in place per ListOptions. Mutates the input.
|
|
28
|
+
* Pass `keyFn` to customize how the comparison value is derived (e.g. nested
|
|
29
|
+
* fields, case-folded strings).
|
|
30
|
+
*/
|
|
31
|
+
export function applyOrder<T>(
|
|
32
|
+
items: T[],
|
|
33
|
+
opts?: AnyListOptions,
|
|
34
|
+
keyFn: (item: T, field: string) => unknown = (item, field) =>
|
|
35
|
+
(item as Record<string, unknown>)[field]
|
|
36
|
+
): T[] {
|
|
37
|
+
const field = opts?.orderBy ?? 'createdAt';
|
|
38
|
+
const dir = opts?.orderDir ?? 'desc';
|
|
39
|
+
const mul = dir === 'asc' ? 1 : -1;
|
|
40
|
+
items.sort((a, b) => {
|
|
41
|
+
const av = (keyFn(a, field) ?? '') as string | number;
|
|
42
|
+
const bv = (keyFn(b, field) ?? '') as string | number;
|
|
43
|
+
if (av < bv) return -1 * mul;
|
|
44
|
+
if (av > bv) return 1 * mul;
|
|
45
|
+
return 0;
|
|
46
|
+
});
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ALGO = 'aes-256-gcm';
|
|
4
|
+
const IV_BYTES = 12;
|
|
5
|
+
const TAG_BYTES = 16;
|
|
6
|
+
const PREFIX = 'enc:v1:';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AES-256-GCM envelope for at-rest secrets (e.g. compute API keys).
|
|
10
|
+
*
|
|
11
|
+
* On-disk format: `enc:v1:<base64(iv|tag|ciphertext)>`. The version prefix
|
|
12
|
+
* lets us migrate to a new algorithm later without ambiguity. Plaintext
|
|
13
|
+
* never sits on disk.
|
|
14
|
+
*
|
|
15
|
+
* Security model: defends against backup leaks, accidental file sharing,
|
|
16
|
+
* and read-only disk access. An attacker with both the data file *and*
|
|
17
|
+
* the master key (env var, process memory) can still decrypt — this is
|
|
18
|
+
* encryption at rest, not a secret manager.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export function isEncryptedSecret(value: string): boolean {
|
|
22
|
+
return value.startsWith(PREFIX);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function encryptSecret(plaintext: string, key: Buffer): string {
|
|
26
|
+
if (key.length !== 32) {
|
|
27
|
+
throw new Error(`Secret key must be 32 bytes; got ${key.length}`);
|
|
28
|
+
}
|
|
29
|
+
if (isEncryptedSecret(plaintext)) {
|
|
30
|
+
throw new Error('Refusing to encrypt a value that is already encrypted');
|
|
31
|
+
}
|
|
32
|
+
const iv = randomBytes(IV_BYTES);
|
|
33
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
34
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
35
|
+
const tag = cipher.getAuthTag();
|
|
36
|
+
return PREFIX + Buffer.concat([iv, tag, ciphertext]).toString('base64');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function decryptSecret(envelope: string, key: Buffer): string {
|
|
40
|
+
if (key.length !== 32) {
|
|
41
|
+
throw new Error(`Secret key must be 32 bytes; got ${key.length}`);
|
|
42
|
+
}
|
|
43
|
+
if (!isEncryptedSecret(envelope)) {
|
|
44
|
+
throw new Error('Value is not an encrypted secret envelope');
|
|
45
|
+
}
|
|
46
|
+
const buf = Buffer.from(envelope.slice(PREFIX.length), 'base64');
|
|
47
|
+
const iv = buf.subarray(0, IV_BYTES);
|
|
48
|
+
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
|
49
|
+
const ciphertext = buf.subarray(IV_BYTES + TAG_BYTES);
|
|
50
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
51
|
+
decipher.setAuthTag(tag);
|
|
52
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decode a `SELVA_AT_REST_KEY` env var into a 32-byte buffer. Accepts either
|
|
57
|
+
* 64-char hex or base64 (with or without padding). Throws on anything else
|
|
58
|
+
* so misconfiguration fails loudly at boot rather than silently producing
|
|
59
|
+
* an undecryptable file.
|
|
60
|
+
*/
|
|
61
|
+
export function decodeSecretKey(raw: string): Buffer {
|
|
62
|
+
if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, 'hex');
|
|
63
|
+
const buf = Buffer.from(raw, 'base64');
|
|
64
|
+
if (buf.length === 32) return buf;
|
|
65
|
+
throw new Error(
|
|
66
|
+
'SELVA_AT_REST_KEY must be 32 bytes encoded as 64-char hex or base64. ' +
|
|
67
|
+
"Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ProviderError } from '@selvajs/platform';
|
|
2
|
+
import type { PlatformPermission, RecentRun } from '@selvajs/platform';
|
|
3
|
+
import { readJsonFile, writeJsonFile } from './fsJson.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-user state owned by the data layer, keyed by the auth provider's user
|
|
7
|
+
* ID. Mirrors `public.user_profiles` in the Supabase schema. **Never holds
|
|
8
|
+
* identity** (email, password hash, createdAt) — those live with the auth
|
|
9
|
+
* provider, which may be anything from `LocalAuthProvider` to an external
|
|
10
|
+
* IdP like Eterna ID.
|
|
11
|
+
*
|
|
12
|
+
* The local equivalent of Supabase's `handle_new_auth_user` trigger lives in
|
|
13
|
+
* `hooks.server.ts`, which calls `IDataProvider.ensureUser` once per
|
|
14
|
+
* authenticated request. After that call, every read here is guaranteed to
|
|
15
|
+
* find a row.
|
|
16
|
+
*/
|
|
17
|
+
export interface StoredUserData {
|
|
18
|
+
userId: string;
|
|
19
|
+
displayName?: string;
|
|
20
|
+
platformPermissions: PlatformPermission[];
|
|
21
|
+
starredDefinitions: string[];
|
|
22
|
+
recentRuns: RecentRun[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UserDataFile {
|
|
26
|
+
users: StoredUserData[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_RECENT_RUNS = 20;
|
|
30
|
+
|
|
31
|
+
const empty = (): UserDataFile => ({ users: [] });
|
|
32
|
+
|
|
33
|
+
function emptyRow(userId: string): StoredUserData {
|
|
34
|
+
return {
|
|
35
|
+
userId,
|
|
36
|
+
platformPermissions: [],
|
|
37
|
+
starredDefinitions: [],
|
|
38
|
+
recentRuns: []
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LocalUserDataStore {
|
|
43
|
+
/**
|
|
44
|
+
* Idempotent. Adds an empty row if missing; no-op if present. Safe to call
|
|
45
|
+
* on every authed request.
|
|
46
|
+
*/
|
|
47
|
+
ensure(userId: string): Promise<void>;
|
|
48
|
+
findById(userId: string): Promise<StoredUserData | null>;
|
|
49
|
+
listAll(): Promise<StoredUserData[]>;
|
|
50
|
+
updatePermissions(userId: string, permissions: PlatformPermission[]): Promise<void>;
|
|
51
|
+
updateDisplayName(userId: string, displayName: string | undefined): Promise<void>;
|
|
52
|
+
starDefinition(userId: string, definitionId: string): Promise<void>;
|
|
53
|
+
unstarDefinition(userId: string, definitionId: string): Promise<void>;
|
|
54
|
+
recordRun(userId: string, run: RecentRun): Promise<void>;
|
|
55
|
+
deleteUser(userId: string): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createLocalUserDataStore(filePath: string): LocalUserDataStore {
|
|
59
|
+
async function findOrThrow(userId: string): Promise<{ file: UserDataFile; row: StoredUserData }> {
|
|
60
|
+
const file = await readJsonFile<UserDataFile>(filePath, empty());
|
|
61
|
+
const row = file.users.find((u) => u.userId === userId);
|
|
62
|
+
if (!row) throw new ProviderError(`User-data row "${userId}" not found`, 404);
|
|
63
|
+
return { file, row };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async ensure(userId) {
|
|
68
|
+
const file = await readJsonFile<UserDataFile>(filePath, empty());
|
|
69
|
+
if (file.users.some((u) => u.userId === userId)) return;
|
|
70
|
+
file.users.push(emptyRow(userId));
|
|
71
|
+
await writeJsonFile(filePath, file);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async findById(userId) {
|
|
75
|
+
const { users } = await readJsonFile<UserDataFile>(filePath, empty());
|
|
76
|
+
return users.find((u) => u.userId === userId) ?? null;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async listAll() {
|
|
80
|
+
const { users } = await readJsonFile<UserDataFile>(filePath, empty());
|
|
81
|
+
return users;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async updatePermissions(userId, permissions) {
|
|
85
|
+
const { file, row } = await findOrThrow(userId);
|
|
86
|
+
row.platformPermissions = permissions;
|
|
87
|
+
await writeJsonFile(filePath, file);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async updateDisplayName(userId, displayName) {
|
|
91
|
+
const { file, row } = await findOrThrow(userId);
|
|
92
|
+
if (displayName === undefined) {
|
|
93
|
+
delete row.displayName;
|
|
94
|
+
} else {
|
|
95
|
+
row.displayName = displayName;
|
|
96
|
+
}
|
|
97
|
+
await writeJsonFile(filePath, file);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async starDefinition(userId, definitionId) {
|
|
101
|
+
const { file, row } = await findOrThrow(userId);
|
|
102
|
+
if (!row.starredDefinitions.includes(definitionId)) {
|
|
103
|
+
row.starredDefinitions.push(definitionId);
|
|
104
|
+
await writeJsonFile(filePath, file);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async unstarDefinition(userId, definitionId) {
|
|
109
|
+
const { file, row } = await findOrThrow(userId);
|
|
110
|
+
row.starredDefinitions = row.starredDefinitions.filter((d) => d !== definitionId);
|
|
111
|
+
await writeJsonFile(filePath, file);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async recordRun(userId, run) {
|
|
115
|
+
const { file, row } = await findOrThrow(userId);
|
|
116
|
+
// Remove older entry for same definition, then prepend newest.
|
|
117
|
+
row.recentRuns = [
|
|
118
|
+
run,
|
|
119
|
+
...row.recentRuns.filter((r) => r.definitionId !== run.definitionId)
|
|
120
|
+
].slice(0, MAX_RECENT_RUNS);
|
|
121
|
+
await writeJsonFile(filePath, file);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async deleteUser(userId) {
|
|
125
|
+
const file = await readJsonFile<UserDataFile>(filePath, empty());
|
|
126
|
+
const before = file.users.length;
|
|
127
|
+
file.users = file.users.filter((u) => u.userId !== userId);
|
|
128
|
+
if (file.users.length === before) {
|
|
129
|
+
throw new ProviderError(`User-data row "${userId}" not found`, 404);
|
|
130
|
+
}
|
|
131
|
+
await writeJsonFile(filePath, file);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Auth — identity-only (auth-users.json)
|
|
2
|
+
export { LocalAuthProvider } from './auth/LocalAuthProvider.js';
|
|
3
|
+
export type { LocalAuthProviderConfig } from './auth/LocalAuthProvider.js';
|
|
4
|
+
export { signHmacToken, verifyHmacToken } from './auth/hmac.js';
|
|
5
|
+
export { hashPassword, verifyPasswordHash, createLocalAuthUserStore } from './auth/users.js';
|
|
6
|
+
export type { StoredAuthUser, AuthUsersFile, LocalAuthUserStore } from './auth/users.js';
|
|
7
|
+
|
|
8
|
+
// Data — class names use the `*Store` suffix to match the Supabase provider.
|
|
9
|
+
export {
|
|
10
|
+
LocalDataProvider,
|
|
11
|
+
LocalOrgStore,
|
|
12
|
+
LocalOrgStoreLoader,
|
|
13
|
+
LocalProjectStore,
|
|
14
|
+
LocalDefinitionStore,
|
|
15
|
+
LocalInviteStore,
|
|
16
|
+
LocalComputeServerStore,
|
|
17
|
+
LocalShareLinkStore,
|
|
18
|
+
LocalPlatformProjectGrantStore
|
|
19
|
+
} from './data/index.js';
|
|
20
|
+
export type {
|
|
21
|
+
LocalOrgStoreData,
|
|
22
|
+
LocalOrgStoreOptions,
|
|
23
|
+
LocalProjectStoreOptions,
|
|
24
|
+
LocalShareLinkStoreOptions,
|
|
25
|
+
SecretVerificationFailure,
|
|
26
|
+
SecretVerificationFailureReason,
|
|
27
|
+
SecretVerificationReport
|
|
28
|
+
} from './data/index.js';
|
|
29
|
+
|
|
30
|
+
// Per-user data layer (user-data.json) — keyed by user ID, paired with any
|
|
31
|
+
// auth provider. The permission and profile stores both read/write here.
|
|
32
|
+
export { createLocalUserDataStore } from './data/userData.js';
|
|
33
|
+
export type { LocalUserDataStore, StoredUserData, UserDataFile } from './data/userData.js';
|
|
34
|
+
|
|
35
|
+
// Storage
|
|
36
|
+
export { LocalStorageProvider } from './storage/LocalStorageProvider.js';
|
|
37
|
+
|
|
38
|
+
// User profile
|
|
39
|
+
export { LocalUserProfileProvider } from './userProfile/LocalUserProfileProvider.js';
|
|
40
|
+
|
|
41
|
+
// Platform permissions (data-layer authorization store)
|
|
42
|
+
export { LocalPlatformPermissionStore } from './permissions/LocalPlatformPermissionStore.js';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
IPlatformPermissionStore,
|
|
4
|
+
PlatformPermission,
|
|
5
|
+
RequestContext,
|
|
6
|
+
UserManagementResult
|
|
7
|
+
} from '@selvajs/platform';
|
|
8
|
+
import { ProviderError, hasPermission } from '@selvajs/platform';
|
|
9
|
+
import { createLocalUserDataStore, type LocalUserDataStore } from '../data/userData.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Filesystem-backed platform-permission store. Reads and writes the
|
|
13
|
+
* `platformPermissions` field on `user-data.json` — a data-layer file owned
|
|
14
|
+
* by `LocalDataProvider`, distinct from `auth-users.json` which the
|
|
15
|
+
* `LocalAuthProvider` owns. This split lets the local data layer pair with
|
|
16
|
+
* any auth provider (local, Supabase, Entra, Eterna, …): the data layer only
|
|
17
|
+
* cares about the user ID, never about how identity is stored.
|
|
18
|
+
*
|
|
19
|
+
* The "user is known to the data layer" precondition is established by
|
|
20
|
+
* `IDataProvider.ensureUser`, called from `hooks.server.ts` on every authed
|
|
21
|
+
* request — the local equivalent of the Supabase `handle_new_auth_user`
|
|
22
|
+
* trigger. After that call, `set` finds a row and `getFor` reads it; before
|
|
23
|
+
* it, `set` returns `not_found` (matching the conformance contract).
|
|
24
|
+
*
|
|
25
|
+
* Enforces:
|
|
26
|
+
* - The §2 sole-`instance_admin` invariant on `set` (refuses to drop the
|
|
27
|
+
* last admin)
|
|
28
|
+
* - Authorization on read/write (`assertCanRead` / `assertCanWrite`)
|
|
29
|
+
*
|
|
30
|
+
* "Disabled" state lives on the auth provider's user record. The local
|
|
31
|
+
* provider's permission store can't see across that boundary, so the
|
|
32
|
+
* invariant counters here treat every row in `user-data.json` as enabled.
|
|
33
|
+
* Code that disables a user is expected to also drop their `instance_admin`
|
|
34
|
+
* grant via `set` (the §2 invariant kicks in there) before the auth-side
|
|
35
|
+
* disable lands — see `LocalAuthProvider.disableUser`.
|
|
36
|
+
*/
|
|
37
|
+
export class LocalPlatformPermissionStore implements IPlatformPermissionStore {
|
|
38
|
+
private readonly data: LocalUserDataStore;
|
|
39
|
+
|
|
40
|
+
static fromEnv(env: Record<string, string | undefined>): LocalPlatformPermissionStore {
|
|
41
|
+
if (!env.DATA_PATH) throw new Error('Missing required env var: DATA_PATH');
|
|
42
|
+
return new LocalPlatformPermissionStore(path.join(env.DATA_PATH, 'user-data.json'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
constructor(userDataFilePath: string) {
|
|
46
|
+
this.data = createLocalUserDataStore(userDataFilePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getFor(ctx: RequestContext, userId: string): Promise<PlatformPermission[]> {
|
|
50
|
+
assertCanRead(ctx, userId);
|
|
51
|
+
const row = await this.data.findById(userId);
|
|
52
|
+
return row?.platformPermissions ?? [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getForBatch(
|
|
56
|
+
ctx: RequestContext,
|
|
57
|
+
userIds: readonly string[]
|
|
58
|
+
): Promise<Map<string, PlatformPermission[]>> {
|
|
59
|
+
assertCanReadBatch(ctx);
|
|
60
|
+
const all = await this.data.listAll();
|
|
61
|
+
const wanted = new Set(userIds);
|
|
62
|
+
const out = new Map<string, PlatformPermission[]>();
|
|
63
|
+
for (const u of all) {
|
|
64
|
+
if (wanted.has(u.userId)) out.set(u.userId, u.platformPermissions ?? []);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async set(
|
|
70
|
+
ctx: RequestContext,
|
|
71
|
+
userId: string,
|
|
72
|
+
permissions: readonly PlatformPermission[]
|
|
73
|
+
): Promise<UserManagementResult> {
|
|
74
|
+
assertAdmin(ctx);
|
|
75
|
+
const target = await this.data.findById(userId);
|
|
76
|
+
if (!target) return 'not_found';
|
|
77
|
+
const wasAdmin = target.platformPermissions.includes('instance_admin');
|
|
78
|
+
const willBeAdmin = permissions.includes('instance_admin');
|
|
79
|
+
if (wasAdmin && !willBeAdmin) {
|
|
80
|
+
const others = await this.countOtherAdmins(userId);
|
|
81
|
+
if (others === 0) return 'last_admin';
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
await this.data.updatePermissions(userId, [...permissions]);
|
|
85
|
+
return 'ok';
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err instanceof ProviderError && err.statusCode === 404) return 'not_found';
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async hasInstanceAdmin(_ctx: RequestContext): Promise<boolean> {
|
|
93
|
+
// First-run + invariant check — always allowed (read-only existence
|
|
94
|
+
// probe). The route layer decides what to do with the answer.
|
|
95
|
+
const all = await this.data.listAll();
|
|
96
|
+
return all.some((u) => u.platformPermissions.includes('instance_admin'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async countInstanceAdminsExcluding(_ctx: RequestContext, excludeUserId: string): Promise<number> {
|
|
100
|
+
return this.countOtherAdmins(excludeUserId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async countOtherAdmins(excludeUserId: string): Promise<number> {
|
|
104
|
+
const all = await this.data.listAll();
|
|
105
|
+
return all.filter(
|
|
106
|
+
(u) => u.userId !== excludeUserId && u.platformPermissions.includes('instance_admin')
|
|
107
|
+
).length;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertCanRead(ctx: RequestContext, userId: string): void {
|
|
112
|
+
if (ctx.system) return;
|
|
113
|
+
if (ctx.userId === userId) return;
|
|
114
|
+
if (hasPermission(ctx, 'instance_admin')) return;
|
|
115
|
+
throw new ProviderError('Forbidden: cannot read another user’s permissions', 403);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertAdmin(ctx: RequestContext): void {
|
|
119
|
+
if (ctx.system) return;
|
|
120
|
+
if (hasPermission(ctx, 'instance_admin')) return;
|
|
121
|
+
throw new ProviderError('Forbidden: instance admin required', 403);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assertCanReadBatch(ctx: RequestContext): void {
|
|
125
|
+
if (ctx.system) return;
|
|
126
|
+
if (hasPermission(ctx, 'instance_admin')) return;
|
|
127
|
+
if (hasPermission(ctx, 'manage_instance_users')) return;
|
|
128
|
+
throw new ProviderError('Forbidden: instance admin or manage_instance_users required', 403);
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { runPlatformPermissionStoreConformance } from '@selvajs/platform/testing';
|
|
6
|
+
import { LocalPlatformPermissionStore } from '../LocalPlatformPermissionStore.js';
|
|
7
|
+
import { createLocalUserDataStore } from '../../data/userData.js';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
describe('LocalPlatformPermissionStore', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-perm-test-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
runPlatformPermissionStoreConformance({
|
|
22
|
+
name: 'LocalPlatformPermissionStore',
|
|
23
|
+
createStore: async () => {
|
|
24
|
+
const userDataPath = path.join(tempDir, 'user-data.json');
|
|
25
|
+
const userData = createLocalUserDataStore(userDataPath);
|
|
26
|
+
const store = new LocalPlatformPermissionStore(userDataPath);
|
|
27
|
+
return {
|
|
28
|
+
store,
|
|
29
|
+
seedUser: async () => {
|
|
30
|
+
// Mirrors `IDataProvider.ensureUser` — produces a user the data
|
|
31
|
+
// layer knows about, regardless of which auth provider would
|
|
32
|
+
// hand out the ID in production.
|
|
33
|
+
const id = randomUUID();
|
|
34
|
+
await userData.ensure(id);
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LocalPlatformPermissionStore } from './LocalPlatformPermissionStore.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { transcodeImageIfNeeded } from '@selvajs/platform/storage';
|
|
4
|
+
import type { IStorageProvider } from '@selvajs/platform/storage';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filesystem implementation of IStorageProvider.
|
|
8
|
+
* Files are stored under DATA_PATH/{path}.
|
|
9
|
+
* Images are auto-transcoded to WebP on put() via the shared platform helper
|
|
10
|
+
* (`transcodeImageIfNeeded`) — the same helper the Supabase adapter uses,
|
|
11
|
+
* so both providers produce identical bytes for the same upload.
|
|
12
|
+
*/
|
|
13
|
+
export class LocalStorageProvider implements IStorageProvider {
|
|
14
|
+
private readonly basePath: string;
|
|
15
|
+
private readonly publicUrlBase: string;
|
|
16
|
+
|
|
17
|
+
static fromEnv(env: Record<string, string | undefined>): LocalStorageProvider {
|
|
18
|
+
if (!env.DATA_PATH) throw new Error('Missing required env var: DATA_PATH');
|
|
19
|
+
return new LocalStorageProvider(env.DATA_PATH, '/api/files');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
constructor(basePath: string, publicUrlBase = '/api/files') {
|
|
23
|
+
this.basePath = basePath;
|
|
24
|
+
this.publicUrlBase = publicUrlBase;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a caller-provided path under basePath, rejecting anything that
|
|
29
|
+
* would escape the root. Last line of defense against traversal — while
|
|
30
|
+
* platform-side helpers (definitionPaths) also assert safe keys, this
|
|
31
|
+
* adapter is reached by any IStorageProvider caller.
|
|
32
|
+
*/
|
|
33
|
+
private resolvePath(storagePath: string): string {
|
|
34
|
+
const base = path.resolve(this.basePath);
|
|
35
|
+
const full = path.resolve(base, storagePath);
|
|
36
|
+
if (full !== base && !full.startsWith(base + path.sep)) {
|
|
37
|
+
throw new Error(`Path escapes base: ${storagePath}`);
|
|
38
|
+
}
|
|
39
|
+
return full;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async get(storagePath: string): Promise<Uint8Array | null> {
|
|
43
|
+
try {
|
|
44
|
+
const buffer = await fs.readFile(this.resolvePath(storagePath));
|
|
45
|
+
return new Uint8Array(buffer);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async put(storagePath: string, data: Uint8Array, contentType?: string): Promise<void> {
|
|
53
|
+
// Normalize images through the shared transcoder — rewrites `.png` → `.webp`,
|
|
54
|
+
// caps dimensions, and re-encodes at the platform's canonical quality.
|
|
55
|
+
// Non-images pass through untouched.
|
|
56
|
+
const transcoded = await transcodeImageIfNeeded(data, contentType, storagePath);
|
|
57
|
+
const fullPath = this.resolvePath(transcoded.path);
|
|
58
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
59
|
+
await fs.writeFile(fullPath, Buffer.from(transcoded.data));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(storagePath: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
await fs.unlink(this.resolvePath(storagePath));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async deletePrefix(prefix: string): Promise<void> {
|
|
71
|
+
const fullPath = this.resolvePath(prefix);
|
|
72
|
+
await fs.rm(fullPath, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getPublicUrl(storagePath: string): string {
|
|
76
|
+
return `${this.publicUrlBase}/${storagePath}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { runStorageProviderConformance } from '@selvajs/platform/testing';
|
|
6
|
+
import { LocalStorageProvider } from '../LocalStorageProvider.js';
|
|
7
|
+
|
|
8
|
+
describe('LocalStorageProvider', () => {
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-storage-test-'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
runStorageProviderConformance({
|
|
20
|
+
name: 'LocalStorageProvider',
|
|
21
|
+
createStorage: () => new LocalStorageProvider(tempDir)
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LocalStorageProvider } from './LocalStorageProvider.js';
|