@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,138 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
IShareLinkStore,
|
|
4
|
+
IDefinitionStore,
|
|
5
|
+
IEventSink,
|
|
6
|
+
ShareLink,
|
|
7
|
+
RequestContext,
|
|
8
|
+
ListOptions,
|
|
9
|
+
Page
|
|
10
|
+
} from '@selvajs/platform';
|
|
11
|
+
import { ProviderError, SYSTEM_CONTEXT, actorFrom, NoopEventSink } from '@selvajs/platform';
|
|
12
|
+
import { paginate } from './pagination.js';
|
|
13
|
+
import { readJsonFile, writeJsonFile } from './fsJson.js';
|
|
14
|
+
|
|
15
|
+
interface OnDiskShape {
|
|
16
|
+
links: Record<string, ShareLink>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const empty = (): OnDiskShape => ({ links: {} });
|
|
20
|
+
|
|
21
|
+
export interface LocalShareLinkStoreOptions {
|
|
22
|
+
/** Absolute path to the JSON file backing this store. */
|
|
23
|
+
filePath: string;
|
|
24
|
+
events?: IEventSink;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spec §7 — share-link store backed by share-links.json.
|
|
29
|
+
*
|
|
30
|
+
* `tryIncrementSolveCount` is the load-bearing race-sensitive method; in
|
|
31
|
+
* the local provider we read-modify-write under a single fs round-trip,
|
|
32
|
+
* which is acceptable at single-node scale. Postgres adapters use a true
|
|
33
|
+
* atomic UPDATE.
|
|
34
|
+
*/
|
|
35
|
+
export class LocalShareLinkStore implements IShareLinkStore {
|
|
36
|
+
private definitionProvider?: IDefinitionStore;
|
|
37
|
+
private readonly events: IEventSink;
|
|
38
|
+
private readonly configFilePath: string;
|
|
39
|
+
|
|
40
|
+
static fromEnv(env: Record<string, string | undefined>): LocalShareLinkStore {
|
|
41
|
+
if (!env.DATA_PATH) throw new Error('Missing required env var: DATA_PATH');
|
|
42
|
+
return new LocalShareLinkStore({
|
|
43
|
+
filePath: path.join(env.DATA_PATH, 'share-links.json')
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
constructor(opts: LocalShareLinkStoreOptions) {
|
|
48
|
+
this.configFilePath = opts.filePath;
|
|
49
|
+
this.events = opts.events ?? new NoopEventSink();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wire the definition store so token resolution can check the parent
|
|
54
|
+
* definition's `deletedAt` (Permissions.md §7 cascade contract). Mirrors
|
|
55
|
+
* Supabase, which performs the equivalent JOIN. Optional: when unset, the
|
|
56
|
+
* store falls back to the local-only revoke check; the route layer in
|
|
57
|
+
* the selva app does the parent lookup as a safety net either way.
|
|
58
|
+
*/
|
|
59
|
+
setDefinitionProvider(definitions: IDefinitionStore): void {
|
|
60
|
+
this.definitionProvider = definitions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async readAll(): Promise<OnDiskShape> {
|
|
64
|
+
return readJsonFile<OnDiskShape>(this.configFilePath, empty());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async writeAll(data: OnDiskShape): Promise<void> {
|
|
68
|
+
await writeJsonFile(this.configFilePath, data);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private isLive(l: ShareLink | undefined | null): l is ShareLink {
|
|
72
|
+
return Boolean(l && l.revokedAt == null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async create(ctx: RequestContext, link: ShareLink): Promise<void> {
|
|
76
|
+
const all = await this.readAll();
|
|
77
|
+
if (all.links[link.id]) {
|
|
78
|
+
throw new ProviderError(`Share link '${link.id}' already exists`, 409);
|
|
79
|
+
}
|
|
80
|
+
all.links[link.id] = { ...link, revokedAt: null };
|
|
81
|
+
await this.writeAll(all);
|
|
82
|
+
await this.events.emit({
|
|
83
|
+
type: 'share_link.minted',
|
|
84
|
+
linkId: link.id,
|
|
85
|
+
definitionId: link.definitionId,
|
|
86
|
+
actorId: actorFrom(ctx)
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async listByDefinition(
|
|
91
|
+
_ctx: RequestContext,
|
|
92
|
+
definitionId: string,
|
|
93
|
+
opts?: ListOptions
|
|
94
|
+
): Promise<Page<ShareLink>> {
|
|
95
|
+
const all = await this.readAll();
|
|
96
|
+
const rows = Object.values(all.links)
|
|
97
|
+
.filter((l) => l.definitionId === definitionId && this.isLive(l))
|
|
98
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
99
|
+
return paginate(rows, opts);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getById(_ctx: RequestContext, id: string): Promise<ShareLink | null> {
|
|
103
|
+
const all = await this.readAll();
|
|
104
|
+
return all.links[id] ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getByTokenHash(_ctx: RequestContext, tokenHash: string): Promise<ShareLink | null> {
|
|
108
|
+
const all = await this.readAll();
|
|
109
|
+
const found = Object.values(all.links).find((l) => l.tokenHash === tokenHash);
|
|
110
|
+
if (!this.isLive(found)) return null;
|
|
111
|
+
// §7: token resolution MUST NOT see links whose parent definition is
|
|
112
|
+
// soft-deleted. Supabase enforces this via JOIN; here we look up.
|
|
113
|
+
if (this.definitionProvider) {
|
|
114
|
+
const parent = await this.definitionProvider.get(SYSTEM_CONTEXT, found.definitionId);
|
|
115
|
+
if (!parent) return null;
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async revoke(ctx: RequestContext, id: string): Promise<void> {
|
|
121
|
+
const all = await this.readAll();
|
|
122
|
+
const l = all.links[id];
|
|
123
|
+
if (!l || !this.isLive(l)) return; // idempotent
|
|
124
|
+
l.revokedAt = new Date().toISOString();
|
|
125
|
+
await this.writeAll(all);
|
|
126
|
+
await this.events.emit({ type: 'share_link.revoked', linkId: id, actorId: actorFrom(ctx) });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async tryIncrementSolveCount(_ctx: RequestContext, id: string): Promise<number | null> {
|
|
130
|
+
const all = await this.readAll();
|
|
131
|
+
const l = all.links[id];
|
|
132
|
+
if (!l || !this.isLive(l)) return null;
|
|
133
|
+
if (l.maxSolves != null && l.solveCount >= l.maxSolves) return null;
|
|
134
|
+
l.solveCount += 1;
|
|
135
|
+
await this.writeAll(all);
|
|
136
|
+
return l.solveCount;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, it, expect, 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 { randomUUID } from 'node:crypto';
|
|
6
|
+
import {
|
|
7
|
+
ALL_ORG_PERMISSIONS,
|
|
8
|
+
ALL_PLATFORM_PERMISSIONS,
|
|
9
|
+
SYSTEM_CONTEXT,
|
|
10
|
+
type RequestContext,
|
|
11
|
+
type DefinitionRecord,
|
|
12
|
+
type ShareLink
|
|
13
|
+
} from '@selvajs/platform';
|
|
14
|
+
import { LocalOrgStore, LocalOrgStoreLoader } from '../LocalOrgStore.js';
|
|
15
|
+
import { LocalProjectStore } from '../LocalProjectStore.js';
|
|
16
|
+
import { LocalDefinitionStore } from '../LocalDefinitionStore.js';
|
|
17
|
+
import { LocalShareLinkStore } from '../LocalShareLinkStore.js';
|
|
18
|
+
import { LocalInviteStore } from '../LocalInviteStore.js';
|
|
19
|
+
import { LocalComputeServerStore } from '../LocalComputeServerStore.js';
|
|
20
|
+
import { LocalPlatformProjectGrantStore } from '../LocalPlatformProjectGrantStore.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cross-store cascade behavior. Lives outside the per-store conformance suites
|
|
24
|
+
* because the invariants involve both `IOrgStore` and `IProjectStore`.
|
|
25
|
+
*/
|
|
26
|
+
describe('Cross-store cascade', () => {
|
|
27
|
+
let tempDir: string;
|
|
28
|
+
let orgs: LocalOrgStore;
|
|
29
|
+
let projects: LocalProjectStore;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-test-'));
|
|
33
|
+
const loader = new LocalOrgStoreLoader(tempDir);
|
|
34
|
+
const invites = new LocalInviteStore(tempDir);
|
|
35
|
+
const computeServer = new LocalComputeServerStore(
|
|
36
|
+
path.join(tempDir, 'compute.config.json'),
|
|
37
|
+
Buffer.alloc(32, 0x42)
|
|
38
|
+
);
|
|
39
|
+
const grants = new LocalPlatformProjectGrantStore(
|
|
40
|
+
path.join(tempDir, 'platform-project-grants.json')
|
|
41
|
+
);
|
|
42
|
+
orgs = new LocalOrgStore({ loader, invites, computeServer, grants });
|
|
43
|
+
projects = new LocalProjectStore({ loader, grants });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function ctxFor(userId: string): RequestContext {
|
|
51
|
+
return {
|
|
52
|
+
userId,
|
|
53
|
+
platformPermissions: [...ALL_PLATFORM_PERMISSIONS],
|
|
54
|
+
orgPermissions: [...ALL_ORG_PERMISSIONS]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('removeOrgMember soft-deletes the user’s project memberships in that org (§9)', async () => {
|
|
59
|
+
const ownerId = randomUUID();
|
|
60
|
+
const memberId = randomUUID();
|
|
61
|
+
const orgId = randomUUID();
|
|
62
|
+
const projectAId = randomUUID();
|
|
63
|
+
const projectBId = randomUUID();
|
|
64
|
+
const now = new Date().toISOString();
|
|
65
|
+
const ownerCtx = ctxFor(ownerId);
|
|
66
|
+
|
|
67
|
+
await orgs.createOrg(ownerCtx, {
|
|
68
|
+
id: orgId,
|
|
69
|
+
name: 'Acme',
|
|
70
|
+
slug: 'acme',
|
|
71
|
+
ownerId,
|
|
72
|
+
createdBy: ownerId,
|
|
73
|
+
updatedBy: ownerId,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
deletedAt: null
|
|
77
|
+
});
|
|
78
|
+
await orgs.addOrgMember(ownerCtx, {
|
|
79
|
+
orgId,
|
|
80
|
+
userId: memberId,
|
|
81
|
+
role: 'member',
|
|
82
|
+
permissions: [],
|
|
83
|
+
joinedAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
updatedBy: ownerId,
|
|
86
|
+
deletedAt: null
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const id of [projectAId, projectBId]) {
|
|
90
|
+
await projects.createProject(ownerCtx, {
|
|
91
|
+
id,
|
|
92
|
+
orgId,
|
|
93
|
+
ownerId,
|
|
94
|
+
name: `Project ${id.slice(0, 4)}`,
|
|
95
|
+
slug: `p-${id.slice(0, 4)}`,
|
|
96
|
+
visibility: 'private',
|
|
97
|
+
autoJoinOnUpload: false,
|
|
98
|
+
createdBy: ownerId,
|
|
99
|
+
updatedBy: ownerId,
|
|
100
|
+
createdAt: now,
|
|
101
|
+
updatedAt: now,
|
|
102
|
+
deletedAt: null
|
|
103
|
+
});
|
|
104
|
+
await projects.addProjectMember(ownerCtx, {
|
|
105
|
+
projectId: id,
|
|
106
|
+
userId: memberId,
|
|
107
|
+
role: 'editor',
|
|
108
|
+
joinedAt: now,
|
|
109
|
+
updatedAt: now,
|
|
110
|
+
updatedBy: ownerId,
|
|
111
|
+
deletedAt: null
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Sanity: memberships are live before the cascade fires.
|
|
116
|
+
expect(await projects.getProjectMember(ownerCtx, projectAId, memberId)).not.toBeNull();
|
|
117
|
+
expect(await projects.getProjectMember(ownerCtx, projectBId, memberId)).not.toBeNull();
|
|
118
|
+
|
|
119
|
+
await orgs.removeOrgMember(ownerCtx, orgId, memberId);
|
|
120
|
+
|
|
121
|
+
expect(await orgs.getOrgMember(ownerCtx, orgId, memberId)).toBeNull();
|
|
122
|
+
expect(await projects.getProjectMember(ownerCtx, projectAId, memberId)).toBeNull();
|
|
123
|
+
expect(await projects.getProjectMember(ownerCtx, projectBId, memberId)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('removeOrgMember does not touch project memberships in other orgs', async () => {
|
|
127
|
+
const ownerAId = randomUUID();
|
|
128
|
+
const ownerBId = randomUUID();
|
|
129
|
+
const memberId = randomUUID();
|
|
130
|
+
const orgAId = randomUUID();
|
|
131
|
+
const orgBId = randomUUID();
|
|
132
|
+
const projectAId = randomUUID();
|
|
133
|
+
const projectBId = randomUUID();
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
|
|
136
|
+
await orgs.createOrg(ctxFor(ownerAId), {
|
|
137
|
+
id: orgAId,
|
|
138
|
+
name: 'Acme',
|
|
139
|
+
slug: 'acme',
|
|
140
|
+
ownerId: ownerAId,
|
|
141
|
+
createdBy: ownerAId,
|
|
142
|
+
updatedBy: ownerAId,
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
deletedAt: null
|
|
146
|
+
});
|
|
147
|
+
await orgs.createOrg(ctxFor(ownerBId), {
|
|
148
|
+
id: orgBId,
|
|
149
|
+
name: 'BigClient',
|
|
150
|
+
slug: 'bigclient',
|
|
151
|
+
ownerId: ownerBId,
|
|
152
|
+
createdBy: ownerBId,
|
|
153
|
+
updatedBy: ownerBId,
|
|
154
|
+
createdAt: now,
|
|
155
|
+
updatedAt: now,
|
|
156
|
+
deletedAt: null
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (const [orgOwner, orgId] of [
|
|
160
|
+
[ownerAId, orgAId],
|
|
161
|
+
[ownerBId, orgBId]
|
|
162
|
+
]) {
|
|
163
|
+
await orgs.addOrgMember(ctxFor(orgOwner), {
|
|
164
|
+
orgId,
|
|
165
|
+
userId: memberId,
|
|
166
|
+
role: 'member',
|
|
167
|
+
permissions: [],
|
|
168
|
+
joinedAt: now,
|
|
169
|
+
updatedAt: now,
|
|
170
|
+
updatedBy: orgOwner,
|
|
171
|
+
deletedAt: null
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const [orgOwner, orgId, projectId] of [
|
|
176
|
+
[ownerAId, orgAId, projectAId],
|
|
177
|
+
[ownerBId, orgBId, projectBId]
|
|
178
|
+
]) {
|
|
179
|
+
await projects.createProject(ctxFor(orgOwner), {
|
|
180
|
+
id: projectId,
|
|
181
|
+
orgId,
|
|
182
|
+
ownerId: orgOwner,
|
|
183
|
+
name: `Project ${projectId.slice(0, 4)}`,
|
|
184
|
+
slug: `p-${projectId.slice(0, 4)}`,
|
|
185
|
+
visibility: 'private',
|
|
186
|
+
autoJoinOnUpload: false,
|
|
187
|
+
createdBy: orgOwner,
|
|
188
|
+
updatedBy: orgOwner,
|
|
189
|
+
createdAt: now,
|
|
190
|
+
updatedAt: now,
|
|
191
|
+
deletedAt: null
|
|
192
|
+
});
|
|
193
|
+
await projects.addProjectMember(ctxFor(orgOwner), {
|
|
194
|
+
projectId,
|
|
195
|
+
userId: memberId,
|
|
196
|
+
role: 'editor',
|
|
197
|
+
joinedAt: now,
|
|
198
|
+
updatedAt: now,
|
|
199
|
+
updatedBy: orgOwner,
|
|
200
|
+
deletedAt: null
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Remove from org A only.
|
|
205
|
+
await orgs.removeOrgMember(ctxFor(ownerAId), orgAId, memberId);
|
|
206
|
+
|
|
207
|
+
expect(await projects.getProjectMember(ctxFor(ownerAId), projectAId, memberId)).toBeNull();
|
|
208
|
+
// Org B membership and its project membership are untouched.
|
|
209
|
+
expect(await orgs.getOrgMember(ctxFor(ownerBId), orgBId, memberId)).not.toBeNull();
|
|
210
|
+
expect(await projects.getProjectMember(ctxFor(ownerBId), projectBId, memberId)).not.toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('share-link getByTokenHash returns null when parent definition is soft-deleted (§7)', async () => {
|
|
214
|
+
// §7: token resolution MUST fail closed when its parent definition is
|
|
215
|
+
// soft-deleted. Supabase enforces this via JOIN; the local store gets
|
|
216
|
+
// the same behavior by injecting a definition provider through the
|
|
217
|
+
// LocalDataProvider wiring (see LocalShareLinkStore.setDefinitionProvider).
|
|
218
|
+
const ownerId = randomUUID();
|
|
219
|
+
const orgId = randomUUID();
|
|
220
|
+
const projectId = randomUUID();
|
|
221
|
+
const definitionId = randomUUID();
|
|
222
|
+
const linkId = randomUUID();
|
|
223
|
+
const tokenHash = `hash-${randomUUID()}`;
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
const ownerCtx = ctxFor(ownerId);
|
|
226
|
+
|
|
227
|
+
await orgs.createOrg(ownerCtx, {
|
|
228
|
+
id: orgId,
|
|
229
|
+
name: 'Acme',
|
|
230
|
+
slug: 'acme',
|
|
231
|
+
ownerId,
|
|
232
|
+
createdBy: ownerId,
|
|
233
|
+
updatedBy: ownerId,
|
|
234
|
+
createdAt: now,
|
|
235
|
+
updatedAt: now,
|
|
236
|
+
deletedAt: null
|
|
237
|
+
});
|
|
238
|
+
await projects.createProject(ownerCtx, {
|
|
239
|
+
id: projectId,
|
|
240
|
+
orgId,
|
|
241
|
+
ownerId,
|
|
242
|
+
name: 'P',
|
|
243
|
+
slug: 'p',
|
|
244
|
+
visibility: 'private',
|
|
245
|
+
autoJoinOnUpload: false,
|
|
246
|
+
createdBy: ownerId,
|
|
247
|
+
updatedBy: ownerId,
|
|
248
|
+
createdAt: now,
|
|
249
|
+
updatedAt: now,
|
|
250
|
+
deletedAt: null
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const definitions = new LocalDefinitionStore(tempDir);
|
|
254
|
+
const shareLinks = new LocalShareLinkStore({
|
|
255
|
+
filePath: path.join(tempDir, 'share-links.json')
|
|
256
|
+
});
|
|
257
|
+
shareLinks.setDefinitionProvider(definitions);
|
|
258
|
+
|
|
259
|
+
const definition: DefinitionRecord = {
|
|
260
|
+
guid: definitionId,
|
|
261
|
+
projectId,
|
|
262
|
+
ownerId,
|
|
263
|
+
createdBy: ownerId,
|
|
264
|
+
updatedBy: ownerId,
|
|
265
|
+
displayName: 'Def',
|
|
266
|
+
status: 'published',
|
|
267
|
+
solveCount: 0,
|
|
268
|
+
liveVersionId: null,
|
|
269
|
+
draftVersionId: null,
|
|
270
|
+
createdAt: now,
|
|
271
|
+
updatedAt: now,
|
|
272
|
+
deletedAt: null
|
|
273
|
+
};
|
|
274
|
+
await definitions.create(ownerCtx, definition);
|
|
275
|
+
|
|
276
|
+
const link: ShareLink = {
|
|
277
|
+
id: linkId,
|
|
278
|
+
definitionId,
|
|
279
|
+
channel: 'live',
|
|
280
|
+
tokenHash,
|
|
281
|
+
createdBy: ownerId,
|
|
282
|
+
createdAt: now,
|
|
283
|
+
expiresAt: null,
|
|
284
|
+
revokedAt: null,
|
|
285
|
+
allowSolve: true,
|
|
286
|
+
maxSolves: null,
|
|
287
|
+
solveCount: 0
|
|
288
|
+
};
|
|
289
|
+
await shareLinks.create(SYSTEM_CONTEXT, link);
|
|
290
|
+
|
|
291
|
+
// Sanity: token resolves while parent is live.
|
|
292
|
+
expect(await shareLinks.getByTokenHash(SYSTEM_CONTEXT, tokenHash)).not.toBeNull();
|
|
293
|
+
|
|
294
|
+
// Soft-delete the parent definition.
|
|
295
|
+
await definitions.delete(ownerCtx, definitionId);
|
|
296
|
+
|
|
297
|
+
// Cascade contract: token resolution must now fail closed.
|
|
298
|
+
expect(await shareLinks.getByTokenHash(SYSTEM_CONTEXT, tokenHash)).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { runComputeServerStoreConformance } from '@selvajs/platform/testing';
|
|
6
|
+
import { LocalComputeServerStore } from '../LocalComputeServerStore.js';
|
|
7
|
+
|
|
8
|
+
const TEST_SECRET_KEY = Buffer.alloc(32, 0x42);
|
|
9
|
+
|
|
10
|
+
describe('LocalComputeServerStore', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-compute-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
runComputeServerStoreConformance({
|
|
22
|
+
name: 'LocalComputeServerStore',
|
|
23
|
+
createStore: () =>
|
|
24
|
+
new LocalComputeServerStore(path.join(tempDir, 'compute.config.json'), TEST_SECRET_KEY)
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, 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 { SYSTEM_CONTEXT, type PlatformComputeServer } from '@selvajs/platform';
|
|
6
|
+
import { LocalComputeServerStore } from '../LocalComputeServerStore.js';
|
|
7
|
+
|
|
8
|
+
const TEST_SECRET_KEY = Buffer.alloc(32, 0x42);
|
|
9
|
+
const OTHER_KEY = Buffer.alloc(32, 0x99);
|
|
10
|
+
|
|
11
|
+
function makePlatformServer(
|
|
12
|
+
overrides: Partial<Omit<PlatformComputeServer, 'scope'>> = {}
|
|
13
|
+
): PlatformComputeServer {
|
|
14
|
+
return {
|
|
15
|
+
id: overrides.id ?? 'a1',
|
|
16
|
+
scope: 'platform',
|
|
17
|
+
sharedWith: overrides.sharedWith ?? 'all',
|
|
18
|
+
label: overrides.label ?? 'Test',
|
|
19
|
+
serverUrl: overrides.serverUrl ?? 'http://localhost:5000',
|
|
20
|
+
apiKey: overrides.apiKey,
|
|
21
|
+
timeoutMs: overrides.timeoutMs,
|
|
22
|
+
retryCount: overrides.retryCount
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('LocalComputeServerStore — apiKey encryption at rest', () => {
|
|
27
|
+
let tempDir: string;
|
|
28
|
+
let configPath: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-compute-enc-'));
|
|
32
|
+
configPath = path.join(tempDir, 'compute.config.json');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('round-trips apiKey plaintext through the store', async () => {
|
|
40
|
+
const store = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
41
|
+
await store.savePlatformServers(
|
|
42
|
+
SYSTEM_CONTEXT,
|
|
43
|
+
[makePlatformServer({ apiKey: 'super-secret-value' })],
|
|
44
|
+
'a1'
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const got = await store.getConfig(SYSTEM_CONTEXT);
|
|
48
|
+
expect(got.servers[0].apiKey).toBe('super-secret-value');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not write the plaintext apiKey to disk', async () => {
|
|
52
|
+
const store = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
53
|
+
const plaintext = 'plaintext-must-not-appear-on-disk';
|
|
54
|
+
await store.savePlatformServers(
|
|
55
|
+
SYSTEM_CONTEXT,
|
|
56
|
+
[makePlatformServer({ apiKey: plaintext })],
|
|
57
|
+
'a1'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const onDisk = await fs.readFile(configPath, 'utf-8');
|
|
61
|
+
expect(onDisk).not.toContain(plaintext);
|
|
62
|
+
expect(onDisk).toContain('enc:v1:');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('getConfig with the wrong key returns the server with apiKey omitted (does not throw)', async () => {
|
|
66
|
+
// Per-row tolerance: a key mismatch on `getConfig` must not blank out
|
|
67
|
+
// the entire compute-config response, because every page that reads
|
|
68
|
+
// it (e.g. /projects) would otherwise 500. Strict detection is the
|
|
69
|
+
// job of `verifySecrets` at boot.
|
|
70
|
+
const writer = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
71
|
+
await writer.savePlatformServers(
|
|
72
|
+
SYSTEM_CONTEXT,
|
|
73
|
+
[makePlatformServer({ apiKey: 'value' })],
|
|
74
|
+
'a1'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const reader = new LocalComputeServerStore(configPath, OTHER_KEY);
|
|
78
|
+
const got = await reader.getConfig(SYSTEM_CONTEXT);
|
|
79
|
+
expect(got.servers).toHaveLength(1);
|
|
80
|
+
expect(got.servers[0].apiKey).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('verifySecrets reports key_mismatch when the at-rest key has rotated', async () => {
|
|
84
|
+
const writer = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
85
|
+
await writer.savePlatformServers(
|
|
86
|
+
SYSTEM_CONTEXT,
|
|
87
|
+
[makePlatformServer({ id: 'a1', label: 'Prod', apiKey: 'value' })],
|
|
88
|
+
'a1'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const reader = new LocalComputeServerStore(configPath, OTHER_KEY);
|
|
92
|
+
const report = await reader.verifySecrets();
|
|
93
|
+
expect(report.ok).toBe(false);
|
|
94
|
+
expect(report.plaintextFound).toBe(false);
|
|
95
|
+
expect(report.failures).toHaveLength(1);
|
|
96
|
+
expect(report.failures[0]).toMatchObject({
|
|
97
|
+
serverId: 'a1',
|
|
98
|
+
serverLabel: 'Prod',
|
|
99
|
+
reason: 'key_mismatch'
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('verifySecrets reports plaintext_on_disk for hand-edited config', async () => {
|
|
104
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
105
|
+
await fs.writeFile(
|
|
106
|
+
configPath,
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
servers: [
|
|
109
|
+
{
|
|
110
|
+
id: 'a1',
|
|
111
|
+
scope: 'platform',
|
|
112
|
+
sharedWith: 'all',
|
|
113
|
+
label: 'Legacy',
|
|
114
|
+
serverUrl: 'http://localhost:5000',
|
|
115
|
+
apiKey: 'plaintext-leftover'
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}),
|
|
119
|
+
'utf-8'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const store = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
123
|
+
const report = await store.verifySecrets();
|
|
124
|
+
expect(report.ok).toBe(false);
|
|
125
|
+
expect(report.plaintextFound).toBe(true);
|
|
126
|
+
expect(report.failures[0]).toMatchObject({
|
|
127
|
+
serverId: 'a1',
|
|
128
|
+
reason: 'plaintext_on_disk'
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('verifySecrets returns ok when every encrypted apiKey decrypts', async () => {
|
|
133
|
+
const store = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
134
|
+
await store.savePlatformServers(
|
|
135
|
+
SYSTEM_CONTEXT,
|
|
136
|
+
[
|
|
137
|
+
makePlatformServer({ id: 'a1', apiKey: 'one' }),
|
|
138
|
+
makePlatformServer({ id: 'a2', apiKey: 'two' }),
|
|
139
|
+
makePlatformServer({ id: 'a3' }) // no apiKey — ignored
|
|
140
|
+
],
|
|
141
|
+
'a1'
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const report = await store.verifySecrets();
|
|
145
|
+
expect(report.ok).toBe(true);
|
|
146
|
+
expect(report.failures).toEqual([]);
|
|
147
|
+
expect(report.plaintextFound).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('refuses to read a legacy plaintext apiKey from disk', async () => {
|
|
151
|
+
// Simulate a hand-edited or legacy file with a plaintext apiKey.
|
|
152
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
153
|
+
await fs.writeFile(
|
|
154
|
+
configPath,
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
servers: [
|
|
157
|
+
{
|
|
158
|
+
id: 'a1',
|
|
159
|
+
scope: 'platform',
|
|
160
|
+
sharedWith: 'all',
|
|
161
|
+
label: 'Legacy',
|
|
162
|
+
serverUrl: 'http://localhost:5000',
|
|
163
|
+
apiKey: 'plaintext-leftover'
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}),
|
|
167
|
+
'utf-8'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const store = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
171
|
+
await expect(store.getConfig(SYSTEM_CONTEXT)).rejects.toThrow(/unencrypted apiKey/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('preserves servers with no apiKey', async () => {
|
|
175
|
+
const store = new LocalComputeServerStore(configPath, TEST_SECRET_KEY);
|
|
176
|
+
await store.savePlatformServers(
|
|
177
|
+
SYSTEM_CONTEXT,
|
|
178
|
+
[makePlatformServer({ label: 'No Key' })],
|
|
179
|
+
'a1'
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const got = await store.getConfig(SYSTEM_CONTEXT);
|
|
183
|
+
expect(got.servers[0].apiKey).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -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 { runDefinitionStoreConformance } from '@selvajs/platform/testing';
|
|
6
|
+
import { LocalDefinitionStore } from '../LocalDefinitionStore.js';
|
|
7
|
+
|
|
8
|
+
describe('LocalDefinitionStore', () => {
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-test-'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
runDefinitionStoreConformance({
|
|
20
|
+
name: 'LocalDefinitionStore',
|
|
21
|
+
createStore: () => new LocalDefinitionStore(tempDir)
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { runEventSinkConformance, type RecordingEventSink } from '@selvajs/platform/testing';
|
|
6
|
+
import { LocalDataProvider } from '../LocalDataProvider.js';
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
runEventSinkConformance({
|
|
11
|
+
name: 'LocalDataProvider',
|
|
12
|
+
createProvider: async (sink: RecordingEventSink) => {
|
|
13
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'selva-events-'));
|
|
14
|
+
tempDirs.push(tempDir);
|
|
15
|
+
return LocalDataProvider.fromEnv(
|
|
16
|
+
{
|
|
17
|
+
DATA_PATH: tempDir,
|
|
18
|
+
SELVA_AT_REST_KEY: '0'.repeat(64)
|
|
19
|
+
},
|
|
20
|
+
sink
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
createActorId: async () => ({ userId: randomUUID() }),
|
|
24
|
+
cleanup: async () => {
|
|
25
|
+
await Promise.all(tempDirs.map((d) => fs.rm(d, { recursive: true, force: true })));
|
|
26
|
+
tempDirs.length = 0;
|
|
27
|
+
}
|
|
28
|
+
});
|