@selvajs/server 0.2.0-beta.3 → 0.2.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +40 -0
- package/dist/compute/client-cache.d.ts +7 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +12 -1
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/index.d.ts +2 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +2 -2
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +26 -5
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +51 -25
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/rate-limit.d.ts +40 -0
- package/dist/compute/rate-limit.d.ts.map +1 -1
- package/dist/compute/rate-limit.js +72 -1
- package/dist/compute/rate-limit.js.map +1 -1
- package/dist/compute/remote-definition.d.ts +7 -0
- package/dist/compute/remote-definition.d.ts.map +1 -1
- package/dist/compute/remote-definition.js +3 -1
- package/dist/compute/remote-definition.js.map +1 -1
- package/dist/compute/safe-url.d.ts +2 -1
- package/dist/compute/safe-url.d.ts.map +1 -1
- package/dist/compute/safe-url.js +31 -1
- package/dist/compute/safe-url.js.map +1 -1
- package/dist/compute/solve-cache-key.d.ts +5 -6
- package/dist/compute/solve-cache-key.d.ts.map +1 -1
- package/dist/compute/solve-cache-key.js +4 -3
- package/dist/compute/solve-cache-key.js.map +1 -1
- package/dist/compute/solve-cache-single-flight.d.ts +8 -1
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -1
- package/dist/compute/solve-cache-single-flight.js +4 -2
- package/dist/compute/solve-cache-single-flight.js.map +1 -1
- package/dist/compute/solve-pipeline.d.ts +31 -0
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +42 -1
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/definitions/load-for-render.d.ts +10 -1
- package/dist/definitions/load-for-render.d.ts.map +1 -1
- package/dist/definitions/load-for-render.js +33 -6
- package/dist/definitions/load-for-render.js.map +1 -1
- package/dist/logging/PinoLogger.d.ts +39 -0
- package/dist/logging/PinoLogger.d.ts.map +1 -0
- package/dist/logging/PinoLogger.js +189 -0
- package/dist/logging/PinoLogger.js.map +1 -0
- package/dist/logging/index.d.ts +3 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/logging/index.js +5 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/logging/requestId.d.ts +45 -0
- package/dist/logging/requestId.d.ts.map +1 -0
- package/dist/logging/requestId.js +82 -0
- package/dist/logging/requestId.js.map +1 -0
- package/dist/providers/create-selva-providers.d.ts +11 -1
- package/dist/providers/create-selva-providers.d.ts.map +1 -1
- package/dist/providers/create-selva-providers.js +21 -8
- package/dist/providers/create-selva-providers.js.map +1 -1
- package/package.json +23 -19
- package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
- package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
- package/dist/access/__tests__/project-access-input.test.js +0 -146
- package/dist/access/__tests__/project-access-input.test.js.map +0 -1
- package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
- package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/client-cache.test.js +0 -218
- package/dist/compute/__tests__/client-cache.test.js.map +0 -1
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts +0 -2
- package/dist/compute/__tests__/definition-byte-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/definition-byte-cache.test.js +0 -107
- package/dist/compute/__tests__/definition-byte-cache.test.js.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.js +0 -98
- package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.js +0 -114
- package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
- package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
- package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
- package/dist/compute/__tests__/safe-url.test.js +0 -71
- package/dist/compute/__tests__/safe-url.test.js.map +0 -1
- package/dist/compute/__tests__/solve-cache.test.d.ts +0 -13
- package/dist/compute/__tests__/solve-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-cache.test.js +0 -234
- package/dist/compute/__tests__/solve-cache.test.js.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +0 -557
- package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
- package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
- package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
- package/dist/compute/__tests__/transform-input.test.js +0 -120
- package/dist/compute/__tests__/transform-input.test.js.map +0 -1
- package/dist/definitions/__tests__/definition-service.test.d.ts +0 -14
- package/dist/definitions/__tests__/definition-service.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/definition-service.test.js +0 -95
- package/dist/definitions/__tests__/definition-service.test.js.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.js +0 -196
- package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
- package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
- package/dist/http/__tests__/body-size.test.d.ts +0 -2
- package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
- package/dist/http/__tests__/body-size.test.js +0 -24
- package/dist/http/__tests__/body-size.test.js.map +0 -1
- package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
- package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
- package/dist/http/__tests__/route-classifier.test.js +0 -54
- package/dist/http/__tests__/route-classifier.test.js.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.js +0 -44
- package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
- package/dist/http/__tests__/security-headers.test.d.ts +0 -2
- package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
- package/dist/http/__tests__/security-headers.test.js +0 -24
- package/dist/http/__tests__/security-headers.test.js.map +0 -1
- package/dist/ops/__tests__/semver.test.d.ts +0 -2
- package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
- package/dist/ops/__tests__/semver.test.js +0 -38
- package/dist/ops/__tests__/semver.test.js.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
- package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.js +0 -50
- package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
- package/src/access/__tests__/project-access-input.test.ts +0 -175
- package/src/access/index.ts +0 -9
- package/src/access/project-access-input.ts +0 -145
- package/src/compute/__tests__/client-cache.test.ts +0 -244
- package/src/compute/__tests__/definition-byte-cache.test.ts +0 -129
- package/src/compute/__tests__/rate-limit.test.ts +0 -106
- package/src/compute/__tests__/remote-definition.test.ts +0 -140
- package/src/compute/__tests__/safe-url.test.ts +0 -72
- package/src/compute/__tests__/solve-cache.test.ts +0 -268
- package/src/compute/__tests__/solve-pipeline.test.ts +0 -660
- package/src/compute/__tests__/transform-input.test.ts +0 -162
- package/src/compute/client-cache.ts +0 -321
- package/src/compute/definition-byte-cache.ts +0 -168
- package/src/compute/index.ts +0 -82
- package/src/compute/limits.ts +0 -237
- package/src/compute/memory-solve-cache.ts +0 -0
- package/src/compute/rate-limit.ts +0 -110
- package/src/compute/remote-definition.ts +0 -149
- package/src/compute/safe-url.ts +0 -186
- package/src/compute/solve-cache-envelope.ts +0 -98
- package/src/compute/solve-cache-key.ts +0 -85
- package/src/compute/solve-cache-single-flight.ts +0 -59
- package/src/compute/solve-pipeline.ts +0 -603
- package/src/compute/transform-input.ts +0 -66
- package/src/definitions/__tests__/definition-service.test.ts +0 -131
- package/src/definitions/__tests__/load-for-render.test.ts +0 -244
- package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
- package/src/definitions/definition-service.ts +0 -247
- package/src/definitions/index.ts +0 -22
- package/src/definitions/load-for-render.ts +0 -272
- package/src/definitions/schema-extraction.ts +0 -117
- package/src/errors/SentryErrorReporter.ts +0 -88
- package/src/errors/index.ts +0 -3
- package/src/http/__tests__/body-size.test.ts +0 -27
- package/src/http/__tests__/route-classifier.test.ts +0 -60
- package/src/http/__tests__/safe-redirect.test.ts +0 -47
- package/src/http/__tests__/security-headers.test.ts +0 -29
- package/src/http/body-size.ts +0 -24
- package/src/http/index.ts +0 -10
- package/src/http/redirect.ts +0 -16
- package/src/http/route-classifier.ts +0 -74
- package/src/http/security-headers.ts +0 -42
- package/src/index.ts +0 -27
- package/src/ops/__tests__/semver.test.ts +0 -42
- package/src/ops/index.ts +0 -3
- package/src/ops/semver.ts +0 -60
- package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
- package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
- package/src/providers/create-selva-providers.ts +0 -241
- package/src/providers/index.ts +0 -12
- package/src/tokens/__tests__/token-codec.test.ts +0 -63
- package/src/tokens/index.ts +0 -8
- package/src/tokens/token-codec.ts +0 -86
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import type {
|
|
3
|
-
Project,
|
|
4
|
-
ProjectAccessInput,
|
|
5
|
-
ProjectVisibility,
|
|
6
|
-
RequestContext
|
|
7
|
-
} from '@selvajs/platform';
|
|
8
|
-
import {
|
|
9
|
-
createProjectAccessInputBuilder,
|
|
10
|
-
type ProjectAccessFlags
|
|
11
|
-
} from '../project-access-input.js';
|
|
12
|
-
|
|
13
|
-
const CTX: RequestContext = {
|
|
14
|
-
userId: 'user-1',
|
|
15
|
-
actingOrgId: 'org-1',
|
|
16
|
-
platformPermissions: ['manage_compute'],
|
|
17
|
-
orgPermissions: ['manage_org_members']
|
|
18
|
-
} as RequestContext;
|
|
19
|
-
|
|
20
|
-
function project(visibility: ProjectVisibility): Project {
|
|
21
|
-
return {
|
|
22
|
-
id: 'proj-1',
|
|
23
|
-
orgId: 'org-1',
|
|
24
|
-
name: 'P',
|
|
25
|
-
slug: 'p',
|
|
26
|
-
visibility,
|
|
27
|
-
ownerId: 'user-9',
|
|
28
|
-
createdBy: 'user-9',
|
|
29
|
-
updatedBy: 'user-9',
|
|
30
|
-
autoJoinOnUpload: false,
|
|
31
|
-
createdAt: '2026-01-01T00:00:00Z',
|
|
32
|
-
updatedAt: '2026-01-01T00:00:00Z'
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Row identity is all the builder consumes — it passes rows through opaquely.
|
|
37
|
-
const MEMBER = {
|
|
38
|
-
projectId: 'proj-1',
|
|
39
|
-
userId: 'user-1',
|
|
40
|
-
role: 'editor'
|
|
41
|
-
} as unknown as NonNullable<ProjectAccessInput['member']>;
|
|
42
|
-
const ORG_MEMBER = {
|
|
43
|
-
orgId: 'org-1',
|
|
44
|
-
userId: 'user-1',
|
|
45
|
-
role: 'member'
|
|
46
|
-
} as unknown as NonNullable<ProjectAccessInput['orgMember']>;
|
|
47
|
-
const GRANT = {
|
|
48
|
-
projectId: 'proj-1',
|
|
49
|
-
orgId: 'org-2',
|
|
50
|
-
canSolve: true
|
|
51
|
-
} as unknown as ProjectAccessInput['platformGrants'][number];
|
|
52
|
-
|
|
53
|
-
function makeDeps(flags: Partial<ProjectAccessFlags> = {}) {
|
|
54
|
-
return {
|
|
55
|
-
getProjectMember: vi.fn().mockResolvedValue(MEMBER),
|
|
56
|
-
getOrgMember: vi.fn().mockResolvedValue(ORG_MEMBER),
|
|
57
|
-
listPlatformGrants: vi.fn().mockResolvedValue([GRANT]),
|
|
58
|
-
flags: vi.fn().mockReturnValue({
|
|
59
|
-
allowCrossOrgPublic: false,
|
|
60
|
-
enablePlatformProjects: false,
|
|
61
|
-
...flags
|
|
62
|
-
})
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
describe('buildProjectAccessInput — per-visibility row fetching', () => {
|
|
67
|
-
it('private: fetches only the project member row', async () => {
|
|
68
|
-
const deps = makeDeps();
|
|
69
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
70
|
-
const input = await builder.buildProjectAccessInput(CTX, project('private'));
|
|
71
|
-
|
|
72
|
-
expect(input.member).toEqual(MEMBER);
|
|
73
|
-
expect(input.orgMember).toBeNull();
|
|
74
|
-
expect(input.platformGrants).toEqual([]);
|
|
75
|
-
expect(deps.getOrgMember).not.toHaveBeenCalled();
|
|
76
|
-
expect(deps.listPlatformGrants).not.toHaveBeenCalled();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it.each(['org', 'public'] as const)('%s: fetches project member + org member', async (vis) => {
|
|
80
|
-
const deps = makeDeps();
|
|
81
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
82
|
-
const input = await builder.buildProjectAccessInput(CTX, project(vis));
|
|
83
|
-
|
|
84
|
-
expect(input.member).toEqual(MEMBER);
|
|
85
|
-
expect(input.orgMember).toEqual(ORG_MEMBER);
|
|
86
|
-
expect(deps.getOrgMember).toHaveBeenCalledWith(CTX, 'org-1', 'user-1');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('public + ALLOW_CROSS_ORG_PUBLIC: skips the org member row', async () => {
|
|
90
|
-
const deps = makeDeps({ allowCrossOrgPublic: true });
|
|
91
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
92
|
-
const input = await builder.buildProjectAccessInput(CTX, project('public'));
|
|
93
|
-
|
|
94
|
-
expect(input.member).toEqual(MEMBER);
|
|
95
|
-
expect(input.orgMember).toBeNull();
|
|
96
|
-
expect(deps.getOrgMember).not.toHaveBeenCalled();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('org visibility still fetches the org row when cross-org public is on', async () => {
|
|
100
|
-
const deps = makeDeps({ allowCrossOrgPublic: true });
|
|
101
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
102
|
-
const input = await builder.buildProjectAccessInput(CTX, project('org'));
|
|
103
|
-
expect(input.orgMember).toEqual(ORG_MEMBER);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('platform + flag on: fetches grants only', async () => {
|
|
107
|
-
const deps = makeDeps({ enablePlatformProjects: true });
|
|
108
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
109
|
-
const input = await builder.buildProjectAccessInput(CTX, project('platform'));
|
|
110
|
-
|
|
111
|
-
expect(input.platformGrants).toEqual([GRANT]);
|
|
112
|
-
expect(input.member).toBeNull();
|
|
113
|
-
expect(deps.getProjectMember).not.toHaveBeenCalled();
|
|
114
|
-
expect(deps.getOrgMember).not.toHaveBeenCalled();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('platform + flag off: skips the grant lookup entirely (feature disabled stays cheap)', async () => {
|
|
118
|
-
const deps = makeDeps({ enablePlatformProjects: false });
|
|
119
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
120
|
-
const input = await builder.buildProjectAccessInput(CTX, project('platform'));
|
|
121
|
-
|
|
122
|
-
expect(input.platformGrants).toEqual([]);
|
|
123
|
-
expect(deps.listPlatformGrants).not.toHaveBeenCalled();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('threads ctx fields and flags into the input, with overrides winning', async () => {
|
|
127
|
-
const deps = makeDeps({ allowCrossOrgPublic: true });
|
|
128
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
129
|
-
const input = await builder.buildProjectAccessInput(CTX, project('private'), {
|
|
130
|
-
member: null
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
expect(input.userId).toBe('user-1');
|
|
134
|
-
expect(input.actingOrgId).toBe('org-1');
|
|
135
|
-
expect(input.orgPermissions).toEqual(CTX.orgPermissions);
|
|
136
|
-
expect(input.platformPermissions).toEqual(CTX.platformPermissions);
|
|
137
|
-
expect(input.allowCrossOrgPublic).toBe(true);
|
|
138
|
-
expect(input.member).toBeNull(); // override beat the fetched row
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('reads flags per call, not at builder creation', async () => {
|
|
142
|
-
const deps = makeDeps();
|
|
143
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
144
|
-
await builder.buildProjectAccessInput(CTX, project('private'));
|
|
145
|
-
deps.flags.mockReturnValue({ allowCrossOrgPublic: true, enablePlatformProjects: true });
|
|
146
|
-
const input = await builder.buildProjectAccessInput(CTX, project('private'));
|
|
147
|
-
expect(input.enablePlatformProjects).toBe(true);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe('projectAccessInputFromRows', () => {
|
|
152
|
-
it('assembles from caller rows with zero I/O', () => {
|
|
153
|
-
const deps = makeDeps();
|
|
154
|
-
const builder = createProjectAccessInputBuilder(deps);
|
|
155
|
-
const input = builder.projectAccessInputFromRows(CTX, project('org'), {
|
|
156
|
-
member: MEMBER,
|
|
157
|
-
orgMember: ORG_MEMBER
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
expect(input.member).toEqual(MEMBER);
|
|
161
|
-
expect(input.orgMember).toEqual(ORG_MEMBER);
|
|
162
|
-
expect(input.platformGrants).toEqual([]);
|
|
163
|
-
expect(deps.getProjectMember).not.toHaveBeenCalled();
|
|
164
|
-
expect(deps.getOrgMember).not.toHaveBeenCalled();
|
|
165
|
-
expect(deps.listPlatformGrants).not.toHaveBeenCalled();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('defaults omitted rows to null/empty', () => {
|
|
169
|
-
const builder = createProjectAccessInputBuilder(makeDeps());
|
|
170
|
-
const input = builder.projectAccessInputFromRows(CTX, project('private'), {});
|
|
171
|
-
expect(input.member).toBeNull();
|
|
172
|
-
expect(input.orgMember).toBeNull();
|
|
173
|
-
expect(input.platformGrants).toEqual([]);
|
|
174
|
-
});
|
|
175
|
-
});
|
package/src/access/index.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Access-rule input assembly — marshals providers + flags into what
|
|
2
|
-
// platform's pure canView/canEdit/… rules consume.
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
createProjectAccessInputBuilder,
|
|
6
|
-
type ProjectAccessFlags,
|
|
7
|
-
type ProjectAccessInputBuilder,
|
|
8
|
-
type ProjectAccessInputDeps
|
|
9
|
-
} from './project-access-input.js';
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import type { Project, ProjectAccessInput, RequestContext } from '@selvajs/platform';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Assembly of `ProjectAccessInput` — the marshalling layer between an app's
|
|
5
|
-
* providers and platform's pure access rules (`canView`/`canEdit`/`canSolve`/
|
|
6
|
-
* `canManage`/…). The rules are pure functions over one input shape; the
|
|
7
|
-
* knowledge captured here is **which rows each project visibility needs**, so
|
|
8
|
-
* a consuming app doesn't reimplement (and mis-fetch) it:
|
|
9
|
-
*
|
|
10
|
-
* - `platform` → grants (skipped entirely while the feature flag is off)
|
|
11
|
-
* - `private` → caller's project member row
|
|
12
|
-
* - `org` / `public` → caller's project member row (for canEdit/canManage) and
|
|
13
|
-
* org member row (for canView). Cross-org public skips the org row.
|
|
14
|
-
*
|
|
15
|
-
* Wiring is injected: row lookups as functions (so lazily-initialized
|
|
16
|
-
* providers work unchanged) and flags read per call (so runtime flag flips
|
|
17
|
-
* behave like the app's own `flag()` reads).
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export interface ProjectAccessFlags {
|
|
21
|
-
/** `ALLOW_CROSS_ORG_PUBLIC` — public visibility spans orgs when true. */
|
|
22
|
-
allowCrossOrgPublic: boolean;
|
|
23
|
-
/** `ENABLE_PLATFORM_PROJECTS` — platform visibility is live when true. */
|
|
24
|
-
enablePlatformProjects: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface ProjectAccessInputDeps {
|
|
28
|
-
getProjectMember(
|
|
29
|
-
ctx: RequestContext,
|
|
30
|
-
projectId: string,
|
|
31
|
-
userId: string
|
|
32
|
-
): Promise<ProjectAccessInput['member']>;
|
|
33
|
-
getOrgMember(
|
|
34
|
-
ctx: RequestContext,
|
|
35
|
-
orgId: string,
|
|
36
|
-
userId: string
|
|
37
|
-
): Promise<ProjectAccessInput['orgMember']>;
|
|
38
|
-
listPlatformGrants(
|
|
39
|
-
ctx: RequestContext,
|
|
40
|
-
projectId: string
|
|
41
|
-
): Promise<ProjectAccessInput['platformGrants']>;
|
|
42
|
-
/** Read the two access-relevant feature flags. Called once per assembly. */
|
|
43
|
-
flags(): ProjectAccessFlags;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface ProjectAccessInputBuilder {
|
|
47
|
-
/**
|
|
48
|
-
* Build the rule input for any project-scope rule, fetching exactly the
|
|
49
|
-
* rows the rule will consult based on `project.visibility`. Other fields
|
|
50
|
-
* default to safe values; pass `overrides` for the rare callers that
|
|
51
|
-
* already loaded a row (e.g. tests, batched listing pages).
|
|
52
|
-
*/
|
|
53
|
-
buildProjectAccessInput(
|
|
54
|
-
ctx: RequestContext,
|
|
55
|
-
project: Project,
|
|
56
|
-
overrides?: Partial<ProjectAccessInput>
|
|
57
|
-
): Promise<ProjectAccessInput>;
|
|
58
|
-
/**
|
|
59
|
-
* Build a `ProjectAccessInput` from caller-provided rows without any I/O.
|
|
60
|
-
* Used by listing pages that have already batch-loaded membership for many
|
|
61
|
-
* projects; the per-row predicate calls this instead of
|
|
62
|
-
* `buildProjectAccessInput` to avoid an N+1 fetch.
|
|
63
|
-
*/
|
|
64
|
-
projectAccessInputFromRows(
|
|
65
|
-
ctx: RequestContext,
|
|
66
|
-
project: Project,
|
|
67
|
-
rows: {
|
|
68
|
-
member?: ProjectAccessInput['member'];
|
|
69
|
-
orgMember?: ProjectAccessInput['orgMember'];
|
|
70
|
-
platformGrants?: ProjectAccessInput['platformGrants'];
|
|
71
|
-
}
|
|
72
|
-
): ProjectAccessInput;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function createProjectAccessInputBuilder(
|
|
76
|
-
deps: ProjectAccessInputDeps
|
|
77
|
-
): ProjectAccessInputBuilder {
|
|
78
|
-
return {
|
|
79
|
-
async buildProjectAccessInput(
|
|
80
|
-
ctx: RequestContext,
|
|
81
|
-
project: Project,
|
|
82
|
-
overrides: Partial<ProjectAccessInput> = {}
|
|
83
|
-
): Promise<ProjectAccessInput> {
|
|
84
|
-
const { allowCrossOrgPublic, enablePlatformProjects } = deps.flags();
|
|
85
|
-
|
|
86
|
-
let member: ProjectAccessInput['member'] = null;
|
|
87
|
-
let orgMember: ProjectAccessInput['orgMember'] = null;
|
|
88
|
-
let platformGrants: ProjectAccessInput['platformGrants'] = [];
|
|
89
|
-
|
|
90
|
-
if (project.visibility === 'platform') {
|
|
91
|
-
// When the flag is off the rule short-circuits before reading grants —
|
|
92
|
-
// skip the lookup to keep "feature disabled" cheap.
|
|
93
|
-
if (enablePlatformProjects) {
|
|
94
|
-
platformGrants = await deps.listPlatformGrants(ctx, project.id);
|
|
95
|
-
}
|
|
96
|
-
} else if (project.visibility === 'private') {
|
|
97
|
-
member = await deps.getProjectMember(ctx, project.id, ctx.userId);
|
|
98
|
-
} else {
|
|
99
|
-
const skipOrgMember = project.visibility === 'public' && allowCrossOrgPublic;
|
|
100
|
-
[member, orgMember] = await Promise.all([
|
|
101
|
-
deps.getProjectMember(ctx, project.id, ctx.userId),
|
|
102
|
-
skipOrgMember ? Promise.resolve(null) : deps.getOrgMember(ctx, project.orgId, ctx.userId)
|
|
103
|
-
]);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
orgPermissions: ctx.orgPermissions,
|
|
108
|
-
platformPermissions: ctx.platformPermissions,
|
|
109
|
-
project,
|
|
110
|
-
member,
|
|
111
|
-
orgMember,
|
|
112
|
-
allowCrossOrgPublic,
|
|
113
|
-
enablePlatformProjects,
|
|
114
|
-
platformGrants,
|
|
115
|
-
actingOrgId: ctx.actingOrgId ?? null,
|
|
116
|
-
userId: ctx.userId,
|
|
117
|
-
...overrides
|
|
118
|
-
};
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
projectAccessInputFromRows(
|
|
122
|
-
ctx: RequestContext,
|
|
123
|
-
project: Project,
|
|
124
|
-
rows: {
|
|
125
|
-
member?: ProjectAccessInput['member'];
|
|
126
|
-
orgMember?: ProjectAccessInput['orgMember'];
|
|
127
|
-
platformGrants?: ProjectAccessInput['platformGrants'];
|
|
128
|
-
}
|
|
129
|
-
): ProjectAccessInput {
|
|
130
|
-
const { allowCrossOrgPublic, enablePlatformProjects } = deps.flags();
|
|
131
|
-
return {
|
|
132
|
-
orgPermissions: ctx.orgPermissions,
|
|
133
|
-
platformPermissions: ctx.platformPermissions,
|
|
134
|
-
project,
|
|
135
|
-
member: rows.member ?? null,
|
|
136
|
-
orgMember: rows.orgMember ?? null,
|
|
137
|
-
allowCrossOrgPublic,
|
|
138
|
-
enablePlatformProjects,
|
|
139
|
-
platformGrants: rows.platformGrants ?? [],
|
|
140
|
-
actingOrgId: ctx.actingOrgId ?? null,
|
|
141
|
-
userId: ctx.userId
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
}
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `createClientCache` — the per-server warm-client LRU.
|
|
3
|
-
*
|
|
4
|
-
* `@selvajs/compute`'s `GrasshopperClient.create` does a real network preflight,
|
|
5
|
-
* so we mock the module: `create` records the config it was called with and
|
|
6
|
-
* returns a fake client whose `createScheduler` returns a disposable stub. That
|
|
7
|
-
* lets us assert the cache's own behavior (id-keying, LRU eviction, evict,
|
|
8
|
-
* disposeAll, the `X-Selva-Definition` header) without a live compute server.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
12
|
-
|
|
13
|
-
// --- Mock @selvajs/compute -------------------------------------------------
|
|
14
|
-
const createdConfigs: any[] = [];
|
|
15
|
-
const createdSchedulerOptions: any[] = [];
|
|
16
|
-
const disposedSchedulers: any[] = [];
|
|
17
|
-
|
|
18
|
-
vi.mock('@selvajs/compute', () => {
|
|
19
|
-
class GrasshopperClient {
|
|
20
|
-
static async create(config: any) {
|
|
21
|
-
createdConfigs.push(config);
|
|
22
|
-
return new GrasshopperClient(config);
|
|
23
|
-
}
|
|
24
|
-
constructor(public config: any) {}
|
|
25
|
-
createScheduler(options: any) {
|
|
26
|
-
createdSchedulerOptions.push(options);
|
|
27
|
-
const scheduler = {
|
|
28
|
-
disposed: false,
|
|
29
|
-
dispose() {
|
|
30
|
-
this.disposed = true;
|
|
31
|
-
disposedSchedulers.push(this);
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
return scheduler;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return { GrasshopperClient, enableDebugLogging: vi.fn() };
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
import { createClientCache, serverIdentity } from '../client-cache.js';
|
|
41
|
-
|
|
42
|
-
function baseConfig() {
|
|
43
|
-
return {
|
|
44
|
-
maxSolveDurationMs: 30_000,
|
|
45
|
-
maxConcurrentSolves: 4,
|
|
46
|
-
maxQueueDepth: 0,
|
|
47
|
-
queueWaitMs: 0,
|
|
48
|
-
cachesolve: true,
|
|
49
|
-
cacheerroredsolves: false,
|
|
50
|
-
reuseServerDefinitionCache: true,
|
|
51
|
-
debug: false,
|
|
52
|
-
debugVerbose: false
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const server = (id: string, over: Partial<{ serverUrl: string; apiKey: string }> = {}) => ({
|
|
57
|
-
id,
|
|
58
|
-
serverUrl: over.serverUrl ?? `http://compute-${id}:6500`,
|
|
59
|
-
apiKey: over.apiKey ?? `key-${id}`
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
createdConfigs.length = 0;
|
|
64
|
-
createdSchedulerOptions.length = 0;
|
|
65
|
-
disposedSchedulers.length = 0;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('createClientCache — keying', () => {
|
|
69
|
-
it('keys on id: two calls for the same id build one client', async () => {
|
|
70
|
-
const cache = createClientCache(baseConfig());
|
|
71
|
-
const a = await cache.getClient(server('s1'));
|
|
72
|
-
const b = await cache.getClient(server('s1'));
|
|
73
|
-
expect(a).toBe(b);
|
|
74
|
-
expect(createdConfigs).toHaveLength(1);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('reuses the entry even when the URL/apiKey rotate under the same id', async () => {
|
|
78
|
-
// The whole point of ADR 0004 D1: identity is the id, so a rotated URL is
|
|
79
|
-
// the SAME entry (stale until evicted) — not a new client.
|
|
80
|
-
const cache = createClientCache(baseConfig());
|
|
81
|
-
const a = await cache.getClient(server('s1', { serverUrl: 'http://old:6500' }));
|
|
82
|
-
const b = await cache.getClient(server('s1', { serverUrl: 'http://new:6500' }));
|
|
83
|
-
expect(a).toBe(b);
|
|
84
|
-
expect(createdConfigs).toHaveLength(1);
|
|
85
|
-
// It kept the ORIGINAL connection details — proving eviction is required.
|
|
86
|
-
expect(createdConfigs[0].serverUrl).toBe('http://old:6500');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('builds distinct clients for distinct ids', async () => {
|
|
90
|
-
const cache = createClientCache(baseConfig());
|
|
91
|
-
const a = await cache.getClient(server('s1'));
|
|
92
|
-
const b = await cache.getClient(server('s2'));
|
|
93
|
-
expect(a).not.toBe(b);
|
|
94
|
-
expect(createdConfigs).toHaveLength(2);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('createClientCache — LRU eviction', () => {
|
|
99
|
-
it('evicts the least-recently-used entry past capacity and disposes its scheduler', async () => {
|
|
100
|
-
const cache = createClientCache({ ...baseConfig(), maxCachedClients: 2 });
|
|
101
|
-
const s1 = await cache.getClient(server('s1'));
|
|
102
|
-
await cache.getClient(server('s2'));
|
|
103
|
-
// Touch s1 so s2 becomes the LRU.
|
|
104
|
-
await cache.getClient(server('s1'));
|
|
105
|
-
await cache.getClient(server('s3')); // over capacity → evict s2
|
|
106
|
-
|
|
107
|
-
expect(disposedSchedulers).toHaveLength(1);
|
|
108
|
-
// s1 still warm (same entry, no rebuild); s2 rebuilt on next access.
|
|
109
|
-
expect(await cache.getClient(server('s1'))).toBe(s1);
|
|
110
|
-
expect(createdConfigs.filter((c) => c.serverUrl.includes('s2'))).toHaveLength(1);
|
|
111
|
-
await cache.getClient(server('s2'));
|
|
112
|
-
expect(createdConfigs.filter((c) => c.serverUrl.includes('s2'))).toHaveLength(2);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('createClientCache — explicit invalidation', () => {
|
|
117
|
-
it('evict(id) disposes the scheduler and forces a rebuild (config-write hook)', async () => {
|
|
118
|
-
const cache = createClientCache(baseConfig());
|
|
119
|
-
await cache.getClient(server('s1'));
|
|
120
|
-
cache.evict('s1');
|
|
121
|
-
expect(disposedSchedulers).toHaveLength(1);
|
|
122
|
-
await cache.getClient(server('s1'));
|
|
123
|
-
expect(createdConfigs).toHaveLength(2);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('evict accepts a ServerIdentity', async () => {
|
|
127
|
-
const cache = createClientCache(baseConfig());
|
|
128
|
-
await cache.getClient(server('s1'));
|
|
129
|
-
cache.evict(serverIdentity({ id: 's1' }));
|
|
130
|
-
expect(disposedSchedulers).toHaveLength(1);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('evict on an unknown id is a no-op', async () => {
|
|
134
|
-
const cache = createClientCache(baseConfig());
|
|
135
|
-
await cache.getClient(server('s1'));
|
|
136
|
-
cache.evict('nope');
|
|
137
|
-
expect(disposedSchedulers).toHaveLength(0);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('disposeAll disposes every warm scheduler and clears the cache', async () => {
|
|
141
|
-
const cache = createClientCache(baseConfig());
|
|
142
|
-
await cache.getClient(server('s1'));
|
|
143
|
-
await cache.getClient(server('s2'));
|
|
144
|
-
cache.disposeAll();
|
|
145
|
-
expect(disposedSchedulers).toHaveLength(2);
|
|
146
|
-
// Next access rebuilds.
|
|
147
|
-
await cache.getClient(server('s1'));
|
|
148
|
-
expect(createdConfigs).toHaveLength(3);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('createClientCache — X-Selva-Definition (ADR 0004 D2)', () => {
|
|
153
|
-
it('stamps the definition guid on the client headers when provided', async () => {
|
|
154
|
-
const cache = createClientCache(baseConfig());
|
|
155
|
-
await cache.getClient(server('s1'), { definitionGuid: 'guid-123' });
|
|
156
|
-
expect(createdConfigs[0].headers).toEqual({ 'X-Selva-Definition': 'guid-123' });
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('omits the header entirely when no guid is given', async () => {
|
|
160
|
-
const cache = createClientCache(baseConfig());
|
|
161
|
-
await cache.getClient(server('s1'));
|
|
162
|
-
expect(createdConfigs[0].headers).toBeUndefined();
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe('createClientCache — scheduler concurrency (audit B6)', () => {
|
|
167
|
-
it('forwards maxConcurrentSolves as the scheduler maxConcurrent in queue mode', async () => {
|
|
168
|
-
const cache = createClientCache({ ...baseConfig(), maxConcurrentSolves: 7 });
|
|
169
|
-
await cache.getClient(server('s1'));
|
|
170
|
-
expect(createdSchedulerOptions).toHaveLength(1);
|
|
171
|
-
// Queue mode with NO maxConcurrent defaults to 1 in the scheduler,
|
|
172
|
-
// serializing every solve on the server — the option must always be set.
|
|
173
|
-
expect(createdSchedulerOptions[0].mode).toBe('queue');
|
|
174
|
-
expect(createdSchedulerOptions[0].maxConcurrent).toBe(7);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
describe('createClientCache — scheduler backpressure (audit B7)', () => {
|
|
179
|
-
it('forwards non-zero queue bounds to the scheduler', async () => {
|
|
180
|
-
const cache = createClientCache({ ...baseConfig(), maxQueueDepth: 12, queueWaitMs: 90_000 });
|
|
181
|
-
await cache.getClient(server('s1'));
|
|
182
|
-
expect(createdSchedulerOptions[0].maxQueueDepth).toBe(12);
|
|
183
|
-
expect(createdSchedulerOptions[0].queueWaitMs).toBe(90_000);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('maps a 0 (disabled) queue bound to undefined so the scheduler stays unbounded', async () => {
|
|
187
|
-
// The scheduler treats undefined as unbounded/no-deadline; passing 0 would
|
|
188
|
-
// shed EVERY queued solve. Our `0 = off` convention must become undefined.
|
|
189
|
-
const cache = createClientCache({ ...baseConfig(), maxQueueDepth: 0, queueWaitMs: 0 });
|
|
190
|
-
await cache.getClient(server('s1'));
|
|
191
|
-
expect(createdSchedulerOptions[0].maxQueueDepth).toBeUndefined();
|
|
192
|
-
expect(createdSchedulerOptions[0].queueWaitMs).toBeUndefined();
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe('createClientCache — per-request telemetry sequence counters', () => {
|
|
197
|
-
it('onServerTiming bumps rhinoTiming.seq on every write', async () => {
|
|
198
|
-
const cache = createClientCache(baseConfig());
|
|
199
|
-
const entry = await cache.getClient(server('s1'));
|
|
200
|
-
expect(entry.rhinoTiming).toEqual({ last: null, seq: 0 });
|
|
201
|
-
createdConfigs[0].onServerTiming({ decode: 5, solve: 100, encode: 3 });
|
|
202
|
-
expect(entry.rhinoTiming.seq).toBe(1);
|
|
203
|
-
expect(entry.rhinoTiming.last).toEqual({ decode: 5, solve: 100, encode: 3 });
|
|
204
|
-
createdConfigs[0].onServerTiming({ decode: 1, solve: 2, encode: 3 });
|
|
205
|
-
expect(entry.rhinoTiming.seq).toBe(2);
|
|
206
|
-
expect(entry.rhinoTiming.last).toEqual({ decode: 1, solve: 2, encode: 3 });
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('onSettle bumps solveMeta.seq on every settle but writes last only on success', async () => {
|
|
210
|
-
const cache = createClientCache(baseConfig());
|
|
211
|
-
const entry = await cache.getClient(server('s1'));
|
|
212
|
-
const onSettle = createdSchedulerOptions[0].onSettle;
|
|
213
|
-
expect(entry.solveMeta).toEqual({ last: null, seq: 0 });
|
|
214
|
-
|
|
215
|
-
onSettle(
|
|
216
|
-
{ key: 'k' },
|
|
217
|
-
{
|
|
218
|
-
status: 'success',
|
|
219
|
-
fromCache: false,
|
|
220
|
-
definitionReuploaded: false,
|
|
221
|
-
durationMs: 10,
|
|
222
|
-
response: {}
|
|
223
|
-
}
|
|
224
|
-
);
|
|
225
|
-
expect(entry.solveMeta.seq).toBe(1);
|
|
226
|
-
expect(entry.solveMeta.last).toEqual({ fromCache: false, definitionReuploaded: false });
|
|
227
|
-
|
|
228
|
-
// An error settle must still count toward seq (the attribution guard in
|
|
229
|
-
// the pipeline needs to see ALL concurrent activity) without clobbering last.
|
|
230
|
-
onSettle({ key: 'k' }, { status: 'error', error: new Error('x'), durationMs: 5 });
|
|
231
|
-
expect(entry.solveMeta.seq).toBe(2);
|
|
232
|
-
expect(entry.solveMeta.last).toEqual({ fromCache: false, definitionReuploaded: false });
|
|
233
|
-
|
|
234
|
-
onSettle({ key: 'k' }, { status: 'success', fromCache: true, durationMs: 0, response: {} });
|
|
235
|
-
expect(entry.solveMeta.seq).toBe(3);
|
|
236
|
-
expect(entry.solveMeta.last).toEqual({ fromCache: true, definitionReuploaded: undefined });
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe('serverIdentity', () => {
|
|
241
|
-
it('derives identity from the id', () => {
|
|
242
|
-
expect(serverIdentity({ id: 'abc' })).toBe('abc');
|
|
243
|
-
});
|
|
244
|
-
});
|