@selvajs/server 0.2.0-beta.1 → 0.2.0-beta.2
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/dist/access/__tests__/project-access-input.test.d.ts +2 -0
- package/dist/access/__tests__/project-access-input.test.d.ts.map +1 -0
- package/dist/access/__tests__/project-access-input.test.js +146 -0
- package/dist/access/__tests__/project-access-input.test.js.map +1 -0
- package/dist/access/index.d.ts +2 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +4 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/project-access-input.d.ts +52 -0
- package/dist/access/project-access-input.d.ts.map +1 -0
- package/dist/access/project-access-input.js +56 -0
- package/dist/access/project-access-input.js.map +1 -0
- package/dist/compute/__tests__/client-cache.test.js +52 -1
- package/dist/compute/__tests__/client-cache.test.js.map +1 -1
- package/dist/compute/__tests__/rate-limit.test.js +36 -0
- package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +60 -13
- package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
- package/dist/compute/__tests__/transform-input.test.js +7 -1
- package/dist/compute/__tests__/transform-input.test.js.map +1 -1
- package/dist/compute/client-cache.d.ts +20 -8
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +12 -3
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/limits.d.ts +10 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +3 -1
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/rate-limit.d.ts +12 -0
- package/dist/compute/rate-limit.d.ts.map +1 -1
- package/dist/compute/rate-limit.js +18 -0
- package/dist/compute/rate-limit.js.map +1 -1
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +20 -7
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/compute/transform-input.d.ts.map +1 -1
- package/dist/compute/transform-input.js +21 -6
- package/dist/compute/transform-input.js.map +1 -1
- package/dist/errors/SentryErrorReporter.d.ts +17 -0
- package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
- package/dist/errors/SentryErrorReporter.js +60 -0
- package/dist/errors/SentryErrorReporter.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/http/__tests__/body-size.test.d.ts +2 -0
- package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
- package/dist/http/__tests__/body-size.test.js +24 -0
- package/dist/http/__tests__/body-size.test.js.map +1 -0
- package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
- package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
- package/dist/http/__tests__/route-classifier.test.js +54 -0
- package/dist/http/__tests__/route-classifier.test.js.map +1 -0
- package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
- package/dist/http/__tests__/safe-redirect.test.js +44 -0
- package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
- package/dist/http/__tests__/security-headers.test.d.ts +2 -0
- package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
- package/dist/http/__tests__/security-headers.test.js +24 -0
- package/dist/http/__tests__/security-headers.test.js.map +1 -0
- package/dist/http/body-size.d.ts +21 -0
- package/dist/http/body-size.d.ts.map +1 -0
- package/dist/http/body-size.js +20 -0
- package/dist/http/body-size.js.map +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +6 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/redirect.d.ts +11 -0
- package/dist/http/redirect.d.ts.map +1 -0
- package/dist/http/redirect.js +20 -0
- package/dist/http/redirect.js.map +1 -0
- package/dist/http/route-classifier.d.ts +47 -0
- package/dist/http/route-classifier.d.ts.map +1 -0
- package/dist/http/route-classifier.js +37 -0
- package/dist/http/route-classifier.js.map +1 -0
- package/dist/http/security-headers.d.ts +31 -0
- package/dist/http/security-headers.d.ts.map +1 -0
- package/dist/http/security-headers.js +31 -0
- package/dist/http/security-headers.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/ops/__tests__/semver.test.d.ts +2 -0
- package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
- package/dist/ops/__tests__/semver.test.js +38 -0
- package/dist/ops/__tests__/semver.test.js.map +1 -0
- package/dist/ops/index.d.ts +2 -0
- package/dist/ops/index.d.ts.map +1 -0
- package/dist/ops/index.js +3 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/ops/semver.d.ts +35 -0
- package/dist/ops/semver.d.ts.map +1 -0
- package/dist/ops/semver.js +55 -0
- package/dist/ops/semver.js.map +1 -0
- package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
- package/dist/tokens/__tests__/token-codec.test.js +50 -0
- package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +3 -0
- package/dist/tokens/index.js.map +1 -0
- package/dist/tokens/token-codec.d.ts +54 -0
- package/dist/tokens/token-codec.d.ts.map +1 -0
- package/dist/tokens/token-codec.js +62 -0
- package/dist/tokens/token-codec.js.map +1 -0
- package/package.json +35 -2
- package/src/access/__tests__/project-access-input.test.ts +175 -0
- package/src/access/index.ts +9 -0
- package/src/access/project-access-input.ts +145 -0
- package/src/compute/__tests__/client-cache.test.ts +61 -1
- package/src/compute/__tests__/rate-limit.test.ts +37 -0
- package/src/compute/__tests__/solve-pipeline.test.ts +60 -13
- package/src/compute/__tests__/transform-input.test.ts +8 -1
- package/src/compute/client-cache.ts +35 -13
- package/src/compute/limits.ts +13 -1
- package/src/compute/rate-limit.ts +27 -0
- package/src/compute/solve-pipeline.ts +22 -7
- package/src/compute/transform-input.ts +20 -6
- package/src/errors/SentryErrorReporter.ts +88 -0
- package/src/errors/index.ts +3 -0
- package/src/http/__tests__/body-size.test.ts +27 -0
- package/src/http/__tests__/route-classifier.test.ts +60 -0
- package/src/http/__tests__/safe-redirect.test.ts +47 -0
- package/src/http/__tests__/security-headers.test.ts +29 -0
- package/src/http/body-size.ts +24 -0
- package/src/http/index.ts +10 -0
- package/src/http/redirect.ts +16 -0
- package/src/http/route-classifier.ts +74 -0
- package/src/http/security-headers.ts +42 -0
- package/src/index.ts +11 -2
- package/src/ops/__tests__/semver.test.ts +42 -0
- package/src/ops/index.ts +3 -0
- package/src/ops/semver.ts +60 -0
- package/src/tokens/__tests__/token-codec.test.ts +63 -0
- package/src/tokens/index.ts +8 -0
- package/src/tokens/token-codec.ts +86 -0
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
}
|
|
@@ -12,6 +12,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
12
12
|
|
|
13
13
|
// --- Mock @selvajs/compute -------------------------------------------------
|
|
14
14
|
const createdConfigs: any[] = [];
|
|
15
|
+
const createdSchedulerOptions: any[] = [];
|
|
15
16
|
const disposedSchedulers: any[] = [];
|
|
16
17
|
|
|
17
18
|
vi.mock('@selvajs/compute', () => {
|
|
@@ -21,7 +22,8 @@ vi.mock('@selvajs/compute', () => {
|
|
|
21
22
|
return new GrasshopperClient(config);
|
|
22
23
|
}
|
|
23
24
|
constructor(public config: any) {}
|
|
24
|
-
createScheduler() {
|
|
25
|
+
createScheduler(options: any) {
|
|
26
|
+
createdSchedulerOptions.push(options);
|
|
25
27
|
const scheduler = {
|
|
26
28
|
disposed: false,
|
|
27
29
|
dispose() {
|
|
@@ -40,6 +42,7 @@ import { createClientCache, serverIdentity } from '../client-cache.js';
|
|
|
40
42
|
function baseConfig() {
|
|
41
43
|
return {
|
|
42
44
|
maxSolveDurationMs: 30_000,
|
|
45
|
+
maxConcurrentSolves: 4,
|
|
43
46
|
cachesolve: true,
|
|
44
47
|
cacheerroredsolves: false,
|
|
45
48
|
reuseServerDefinitionCache: true,
|
|
@@ -56,6 +59,7 @@ const server = (id: string, over: Partial<{ serverUrl: string; apiKey: string }>
|
|
|
56
59
|
|
|
57
60
|
beforeEach(() => {
|
|
58
61
|
createdConfigs.length = 0;
|
|
62
|
+
createdSchedulerOptions.length = 0;
|
|
59
63
|
disposedSchedulers.length = 0;
|
|
60
64
|
});
|
|
61
65
|
|
|
@@ -157,6 +161,62 @@ describe('createClientCache — X-Selva-Definition (ADR 0004 D2)', () => {
|
|
|
157
161
|
});
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
describe('createClientCache — scheduler concurrency (audit B6)', () => {
|
|
165
|
+
it('forwards maxConcurrentSolves as the scheduler maxConcurrent in queue mode', async () => {
|
|
166
|
+
const cache = createClientCache({ ...baseConfig(), maxConcurrentSolves: 7 });
|
|
167
|
+
await cache.getClient(server('s1'));
|
|
168
|
+
expect(createdSchedulerOptions).toHaveLength(1);
|
|
169
|
+
// Queue mode with NO maxConcurrent defaults to 1 in the scheduler,
|
|
170
|
+
// serializing every solve on the server — the option must always be set.
|
|
171
|
+
expect(createdSchedulerOptions[0].mode).toBe('queue');
|
|
172
|
+
expect(createdSchedulerOptions[0].maxConcurrent).toBe(7);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('createClientCache — per-request telemetry sequence counters', () => {
|
|
177
|
+
it('onServerTiming bumps rhinoTiming.seq on every write', async () => {
|
|
178
|
+
const cache = createClientCache(baseConfig());
|
|
179
|
+
const entry = await cache.getClient(server('s1'));
|
|
180
|
+
expect(entry.rhinoTiming).toEqual({ last: null, seq: 0 });
|
|
181
|
+
createdConfigs[0].onServerTiming({ decode: 5, solve: 100, encode: 3 });
|
|
182
|
+
expect(entry.rhinoTiming.seq).toBe(1);
|
|
183
|
+
expect(entry.rhinoTiming.last).toEqual({ decode: 5, solve: 100, encode: 3 });
|
|
184
|
+
createdConfigs[0].onServerTiming({ decode: 1, solve: 2, encode: 3 });
|
|
185
|
+
expect(entry.rhinoTiming.seq).toBe(2);
|
|
186
|
+
expect(entry.rhinoTiming.last).toEqual({ decode: 1, solve: 2, encode: 3 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('onSettle bumps solveMeta.seq on every settle but writes last only on success', async () => {
|
|
190
|
+
const cache = createClientCache(baseConfig());
|
|
191
|
+
const entry = await cache.getClient(server('s1'));
|
|
192
|
+
const onSettle = createdSchedulerOptions[0].onSettle;
|
|
193
|
+
expect(entry.solveMeta).toEqual({ last: null, seq: 0 });
|
|
194
|
+
|
|
195
|
+
onSettle(
|
|
196
|
+
{ key: 'k' },
|
|
197
|
+
{
|
|
198
|
+
status: 'success',
|
|
199
|
+
fromCache: false,
|
|
200
|
+
definitionReuploaded: false,
|
|
201
|
+
durationMs: 10,
|
|
202
|
+
response: {}
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
expect(entry.solveMeta.seq).toBe(1);
|
|
206
|
+
expect(entry.solveMeta.last).toEqual({ fromCache: false, definitionReuploaded: false });
|
|
207
|
+
|
|
208
|
+
// An error settle must still count toward seq (the attribution guard in
|
|
209
|
+
// the pipeline needs to see ALL concurrent activity) without clobbering last.
|
|
210
|
+
onSettle({ key: 'k' }, { status: 'error', error: new Error('x'), durationMs: 5 });
|
|
211
|
+
expect(entry.solveMeta.seq).toBe(2);
|
|
212
|
+
expect(entry.solveMeta.last).toEqual({ fromCache: false, definitionReuploaded: false });
|
|
213
|
+
|
|
214
|
+
onSettle({ key: 'k' }, { status: 'success', fromCache: true, durationMs: 0, response: {} });
|
|
215
|
+
expect(entry.solveMeta.seq).toBe(3);
|
|
216
|
+
expect(entry.solveMeta.last).toEqual({ fromCache: true, definitionReuploaded: undefined });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
160
220
|
describe('serverIdentity', () => {
|
|
161
221
|
it('derives identity from the id', () => {
|
|
162
222
|
expect(serverIdentity({ id: 'abc' })).toBe('abc');
|
|
@@ -66,4 +66,41 @@ describe('createComputeRateLimiter', () => {
|
|
|
66
66
|
// A different limiter instance is unaffected.
|
|
67
67
|
expect(other.check('user:alice').allowed).toBe(true);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
// peek + clear support failure-counting flows (login limiting): peek gates
|
|
71
|
+
// the attempt without spending budget, check records a failure, clear
|
|
72
|
+
// forgives on success.
|
|
73
|
+
describe('peek / clear', () => {
|
|
74
|
+
it('peek never increments the counter', () => {
|
|
75
|
+
for (let i = 0; i < 1000; i++) expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
|
|
76
|
+
// Full budget still available after all those peeks.
|
|
77
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) {
|
|
78
|
+
expect(limiter.check('ip:1.2.3.4').allowed).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('peek reports a full bucket with retryAfter, still without mutating it', () => {
|
|
83
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('ip:1.2.3.4');
|
|
84
|
+
const result = limiter.peek('ip:1.2.3.4');
|
|
85
|
+
expect(result.allowed).toBe(false);
|
|
86
|
+
expect(result.retryAfter).toBeGreaterThan(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('peek allows again once the window expires', () => {
|
|
90
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) limiter.check('ip:1.2.3.4');
|
|
91
|
+
expect(limiter.peek('ip:1.2.3.4').allowed).toBe(false);
|
|
92
|
+
vi.advanceTimersByTime(WINDOW_MS + 1);
|
|
93
|
+
expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('clear drops only the given key', () => {
|
|
97
|
+
for (let i = 0; i < MAX_PER_WINDOW; i++) {
|
|
98
|
+
limiter.check('ip:1.2.3.4');
|
|
99
|
+
limiter.check('ip:5.6.7.8');
|
|
100
|
+
}
|
|
101
|
+
limiter.clear('ip:1.2.3.4');
|
|
102
|
+
expect(limiter.peek('ip:1.2.3.4').allowed).toBe(true);
|
|
103
|
+
expect(limiter.peek('ip:5.6.7.8').allowed).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
69
106
|
});
|
|
@@ -49,8 +49,8 @@ function fakeClient(solve: SolveStub): CachedClient {
|
|
|
49
49
|
return {
|
|
50
50
|
client: {} as GrasshopperClient,
|
|
51
51
|
scheduler: { solve } as unknown as SolveScheduler,
|
|
52
|
-
rhinoTiming: { last: null },
|
|
53
|
-
solveMeta: { last: null }
|
|
52
|
+
rhinoTiming: { last: null, seq: 0 },
|
|
53
|
+
solveMeta: { last: null, seq: 0 }
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -246,11 +246,15 @@ describe('runSolvePipeline — Server-Timing', () => {
|
|
|
246
246
|
});
|
|
247
247
|
|
|
248
248
|
it('splits solve into rhino_* + compute_link when the client reported server timing', async () => {
|
|
249
|
-
// The real
|
|
250
|
-
//
|
|
251
|
-
//
|
|
249
|
+
// The real callbacks fire DURING the solve: onServerTiming per compute
|
|
250
|
+
// call, onSettle per settle — each bumps its seq. Mimic both so the
|
|
251
|
+
// pipeline's attribution guard (exactly one write since the snapshot,
|
|
252
|
+
// settle not fromCache) sees this request's own telemetry.
|
|
252
253
|
const client: CachedClient = fakeClient(async () => {
|
|
254
|
+
client.rhinoTiming.seq += 1;
|
|
253
255
|
client.rhinoTiming.last = { decode: 5, solve: 20, encode: 3 };
|
|
256
|
+
client.solveMeta.seq += 1;
|
|
257
|
+
client.solveMeta.last = { fromCache: false, definitionReuploaded: false };
|
|
254
258
|
return { errors: [], warnings: [] };
|
|
255
259
|
});
|
|
256
260
|
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
@@ -264,9 +268,8 @@ describe('runSolvePipeline — Server-Timing', () => {
|
|
|
264
268
|
});
|
|
265
269
|
|
|
266
270
|
it('surfaces selva_cache + def_reupload verdicts as 0/1 flags', async () => {
|
|
267
|
-
// Populate during the solve (the onSettle callback's real timing), not
|
|
268
|
-
// before — the pipeline resets solveMeta before it runs.
|
|
269
271
|
const client: CachedClient = fakeClient(async () => {
|
|
272
|
+
client.solveMeta.seq += 1;
|
|
270
273
|
client.solveMeta.last = { fromCache: true, definitionReuploaded: false };
|
|
271
274
|
return { errors: [], warnings: [] };
|
|
272
275
|
});
|
|
@@ -278,17 +281,61 @@ describe('runSolvePipeline — Server-Timing', () => {
|
|
|
278
281
|
expect(st).toContain('def_reupload;dur=0');
|
|
279
282
|
});
|
|
280
283
|
|
|
281
|
-
it('
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
+
it('does not attribute stale telemetry a previous request left on the warm client', async () => {
|
|
285
|
+
// The holders are shared per-server. Data written before this solve's
|
|
286
|
+
// snapshot (seq unchanged during the solve) must not leak into this
|
|
287
|
+
// request's Server-Timing.
|
|
284
288
|
const client = fakeClient(async () => ({ errors: [], warnings: [] }));
|
|
285
|
-
client.rhinoTiming
|
|
286
|
-
client.solveMeta
|
|
289
|
+
client.rhinoTiming = { last: { decode: 99, solve: 99, encode: 99 }, seq: 3 };
|
|
290
|
+
client.solveMeta = { last: { fromCache: true }, seq: 5 };
|
|
287
291
|
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
288
292
|
expect(outcome.kind).toBe('ok');
|
|
289
293
|
if (outcome.kind !== 'ok') return;
|
|
290
|
-
// Holders were reset to null before solve; the stub never repopulated them.
|
|
291
294
|
expect(outcome.envelope.headers['Server-Timing']).not.toContain('rhino_decode');
|
|
292
295
|
expect(outcome.envelope.headers['Server-Timing']).not.toContain('selva_cache');
|
|
293
296
|
});
|
|
297
|
+
|
|
298
|
+
it('drops telemetry when a concurrent request also settled in the window (audit B6)', async () => {
|
|
299
|
+
// With maxConcurrentSolves > 1 two solves can interleave. More than one
|
|
300
|
+
// settle since this request's snapshot makes the shared slots ambiguous —
|
|
301
|
+
// the pipeline must omit the segments rather than report another
|
|
302
|
+
// request's cache verdict / rhino timing as ours.
|
|
303
|
+
const client: CachedClient = fakeClient(async () => {
|
|
304
|
+
// Our own solve's callbacks…
|
|
305
|
+
client.rhinoTiming.seq += 1;
|
|
306
|
+
client.rhinoTiming.last = { decode: 5, solve: 20, encode: 3 };
|
|
307
|
+
client.solveMeta.seq += 1;
|
|
308
|
+
client.solveMeta.last = { fromCache: false, definitionReuploaded: false };
|
|
309
|
+
// …then a concurrent request settles before we read.
|
|
310
|
+
client.rhinoTiming.seq += 1;
|
|
311
|
+
client.rhinoTiming.last = { decode: 1, solve: 1, encode: 1 };
|
|
312
|
+
client.solveMeta.seq += 1;
|
|
313
|
+
client.solveMeta.last = { fromCache: true };
|
|
314
|
+
return { errors: [], warnings: [] };
|
|
315
|
+
});
|
|
316
|
+
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
317
|
+
expect(outcome.kind).toBe('ok');
|
|
318
|
+
if (outcome.kind !== 'ok') return;
|
|
319
|
+
const st = outcome.envelope.headers['Server-Timing'];
|
|
320
|
+
expect(st).not.toContain('selva_cache');
|
|
321
|
+
expect(st).not.toContain('rhino_decode');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('never attributes rhino timing to a Selva-cache hit (no compute call of ours)', async () => {
|
|
325
|
+
// Our request was served from the in-process cache while a concurrent
|
|
326
|
+
// request's compute call produced the only rhino timing in the window.
|
|
327
|
+
const client: CachedClient = fakeClient(async () => {
|
|
328
|
+
client.rhinoTiming.seq += 1; // the OTHER request's compute call
|
|
329
|
+
client.rhinoTiming.last = { decode: 7, solve: 70, encode: 7 };
|
|
330
|
+
client.solveMeta.seq += 1; // our own settle — a cache hit
|
|
331
|
+
client.solveMeta.last = { fromCache: true };
|
|
332
|
+
return { errors: [], warnings: [] };
|
|
333
|
+
});
|
|
334
|
+
const outcome = await runSolvePipeline(baseArgs({ client }));
|
|
335
|
+
expect(outcome.kind).toBe('ok');
|
|
336
|
+
if (outcome.kind !== 'ok') return;
|
|
337
|
+
const st = outcome.envelope.headers['Server-Timing'];
|
|
338
|
+
expect(st).toContain('selva_cache;dur=1');
|
|
339
|
+
expect(st).not.toContain('rhino_decode');
|
|
340
|
+
});
|
|
294
341
|
});
|
|
@@ -59,11 +59,18 @@ describe('transformInputParameter — number / integer', () => {
|
|
|
59
59
|
expect(out.default).toBe(3);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('
|
|
62
|
+
it('honors a schema-authored stepSize verbatim for integers (compute ≥3.1.0-beta.5)', () => {
|
|
63
|
+
// The package used to force integer stepSize to 1; since beta.5 a
|
|
64
|
+
// server-authored stepSize wins and the 1-heuristic is only the fallback.
|
|
63
65
|
const out = transformInputParameter(
|
|
64
66
|
input({ paramType: 'integer', stepSize: 0.5, default: 2 }),
|
|
65
67
|
4
|
|
66
68
|
);
|
|
69
|
+
expect(out).toMatchObject({ paramType: 'Integer', stepSize: 0.5, default: 4 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('falls back to stepSize 1 for integers without an authored stepSize', () => {
|
|
73
|
+
const out = transformInputParameter(input({ paramType: 'integer', default: 2 }), 4);
|
|
67
74
|
expect(out).toMatchObject({ paramType: 'Integer', stepSize: 1, default: 4 });
|
|
68
75
|
});
|
|
69
76
|
});
|
|
@@ -59,21 +59,28 @@ export interface CachedClient {
|
|
|
59
59
|
client: GrasshopperClient;
|
|
60
60
|
scheduler: SolveScheduler;
|
|
61
61
|
/**
|
|
62
|
-
* Last decode/solve/encode reported by the compute server (Server-Timing)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* Last decode/solve/encode reported by the compute server (Server-Timing),
|
|
63
|
+
* written by onServerTiming. `seq` increments on every write. The scheduler
|
|
64
|
+
* runs up to `maxConcurrentSolves` solves at once, so `last` alone can't be
|
|
65
|
+
* trusted per-request: callers snapshot `seq` before their solve and
|
|
66
|
+
* attribute `last` only when exactly one write happened since (necessarily
|
|
67
|
+
* theirs — see the guard in `runSolvePipeline`), dropping the segment
|
|
68
|
+
* otherwise instead of misattributing another request's timing.
|
|
68
69
|
*/
|
|
69
|
-
rhinoTiming: { last: { decode: number; solve: number; encode: number } | null };
|
|
70
|
+
rhinoTiming: { last: { decode: number; solve: number; encode: number } | null; seq: number };
|
|
70
71
|
/**
|
|
71
72
|
* Last solve's cache verdicts from the scheduler's onSettle: whether the
|
|
72
73
|
* in-process response cache served it (no compute call) and whether the
|
|
73
|
-
* definition had to be re-uploaded.
|
|
74
|
-
*
|
|
74
|
+
* definition had to be re-uploaded. `seq` increments on EVERY settle
|
|
75
|
+
* (success or error); `last` is only written on success. Same
|
|
76
|
+
* snapshot-and-attribute pattern as rhinoTiming — onSettle always fires
|
|
77
|
+
* before the corresponding `scheduler.solve()` promise resolves, so at the
|
|
78
|
+
* caller's read point its own settle is included in `seq`.
|
|
75
79
|
*/
|
|
76
|
-
solveMeta: {
|
|
80
|
+
solveMeta: {
|
|
81
|
+
last: { fromCache: boolean; definitionReuploaded?: boolean } | null;
|
|
82
|
+
seq: number;
|
|
83
|
+
};
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
/**
|
|
@@ -90,6 +97,12 @@ export interface ClientCacheConfig {
|
|
|
90
97
|
cacheerroredsolves: boolean;
|
|
91
98
|
/** Reference large definitions by server cache key (pointer) instead of re-uploading. */
|
|
92
99
|
reuseServerDefinitionCache: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Max in-flight solves per compute server (scheduler `maxConcurrent`; excess
|
|
102
|
+
* FIFO-queues). Size to the server's `compute.geometry` child count
|
|
103
|
+
* (`ComputeLimits.computeMaxConcurrentSolves`).
|
|
104
|
+
*/
|
|
105
|
+
maxConcurrentSolves: number;
|
|
93
106
|
/** Concise cache/timing logs. When false, `onDebugLog` is never invoked. */
|
|
94
107
|
debug: boolean;
|
|
95
108
|
/** VERBOSE lib-level logging (full solve request/response incl. geometry). */
|
|
@@ -136,9 +149,10 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
136
149
|
|
|
137
150
|
async function build(server: ResolvedServer, definitionGuid?: string): Promise<CachedClient> {
|
|
138
151
|
// Holders for per-request results from the client/scheduler callbacks; the
|
|
139
|
-
// caller reads them right after each solve resolves to build Server-Timing
|
|
140
|
-
|
|
141
|
-
const
|
|
152
|
+
// caller reads them right after each solve resolves to build Server-Timing,
|
|
153
|
+
// using the seq counters to detect (and drop) ambiguous concurrent writes.
|
|
154
|
+
const rhinoTiming: CachedClient['rhinoTiming'] = { last: null, seq: 0 };
|
|
155
|
+
const solveMeta: CachedClient['solveMeta'] = { last: null, seq: 0 };
|
|
142
156
|
|
|
143
157
|
const clientConfig: GrasshopperComputeConfig = {
|
|
144
158
|
serverUrl: server.serverUrl,
|
|
@@ -160,6 +174,7 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
160
174
|
const solve = t.solve ?? 0;
|
|
161
175
|
const encode = t.encode ?? 0;
|
|
162
176
|
// Always recorded (cheap): the caller reads this after scheduler.solve.
|
|
177
|
+
rhinoTiming.seq += 1;
|
|
163
178
|
rhinoTiming.last = { decode, solve, encode };
|
|
164
179
|
if (!config.debug) return;
|
|
165
180
|
// Heuristic thresholds: a genuine parse/solve is many ms; a cache hit
|
|
@@ -189,6 +204,10 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
189
204
|
const client = await GrasshopperClient.create(clientConfig);
|
|
190
205
|
const scheduler = client.createScheduler({
|
|
191
206
|
mode: 'queue',
|
|
207
|
+
// Queue mode defaults maxConcurrent to 1, which would serialize every
|
|
208
|
+
// solve on this server through a single slot while the compute VM's
|
|
209
|
+
// other compute.geometry children idle (audit B6) — always pass it.
|
|
210
|
+
maxConcurrent: config.maxConcurrentSolves,
|
|
192
211
|
timeoutMs: config.maxSolveDurationMs,
|
|
193
212
|
cache: { maxEntries: 20, ttlMs: 5 * 60_000 },
|
|
194
213
|
// Solve large definitions by server cache-key (pointer) instead of
|
|
@@ -199,6 +218,9 @@ export function createClientCache(config: ClientCacheConfig): ClientCache {
|
|
|
199
218
|
// this solve was served from the app's own cache — Rhino.Compute was never
|
|
200
219
|
// called. `false` means it went to the compute server.
|
|
201
220
|
onSettle: (_ctx, result) => {
|
|
221
|
+
// Count EVERY settle (success or error) so the caller's attribution
|
|
222
|
+
// guard sees all concurrent activity, not just successful writes.
|
|
223
|
+
solveMeta.seq += 1;
|
|
202
224
|
if (result.status !== 'success') return;
|
|
203
225
|
// Always recorded (cheap): the caller surfaces these on Server-Timing.
|
|
204
226
|
solveMeta.last = {
|
package/src/compute/limits.ts
CHANGED
|
@@ -115,6 +115,16 @@ export interface ComputeLimits {
|
|
|
115
115
|
* honoring the flag. Conservative default off.
|
|
116
116
|
*/
|
|
117
117
|
computeCacheErroredSolves: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Max in-flight solves this app instance sends to ONE compute server
|
|
120
|
+
* (forwarded as the SolveScheduler's `maxConcurrent`; excess solves FIFO-queue).
|
|
121
|
+
* Size it to the server's `compute.geometry` child count — Rhino.Compute
|
|
122
|
+
* spawns N children per VM precisely to run N solves concurrently, and a
|
|
123
|
+
* lower value idles that capacity while users queue (audit B6: the scheduler's
|
|
124
|
+
* queue-mode default is 1, which serialized ALL solves per server). Multiple
|
|
125
|
+
* app instances each apply their own cap.
|
|
126
|
+
*/
|
|
127
|
+
computeMaxConcurrentSolves: number;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
/**
|
|
@@ -142,6 +152,8 @@ export function resolveComputeLimits(env: EnvRecord): ComputeLimits {
|
|
|
142
152
|
definitionCacheTtlMs: readPositiveInt(env, 'DEFINITION_CACHE_TTL_MS', 5 * 60 * 1000),
|
|
143
153
|
computeReuseDefinitionCache: readBool(env, 'COMPUTE_REUSE_DEFINITION_CACHE', true),
|
|
144
154
|
computeServerCachesolve: readBool(env, 'COMPUTE_SERVER_CACHESOLVE', true),
|
|
145
|
-
computeCacheErroredSolves: readBool(env, 'COMPUTE_CACHE_ERRORED_SOLVES', false)
|
|
155
|
+
computeCacheErroredSolves: readBool(env, 'COMPUTE_CACHE_ERRORED_SOLVES', false),
|
|
156
|
+
// 4 matches rhino.compute's default --childcount; tune to the actual VM.
|
|
157
|
+
computeMaxConcurrentSolves: readPositiveInt(env, 'COMPUTE_MAX_CONCURRENT', 4)
|
|
146
158
|
};
|
|
147
159
|
}
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
*
|
|
19
19
|
* The window + cap are injected (they live in `ComputeLimits`), so this module
|
|
20
20
|
* reads no env of its own.
|
|
21
|
+
*
|
|
22
|
+
* Not compute-only despite the name: `peek` + `clear` make it usable for
|
|
23
|
+
* failure-counting flows too (login limiting keys by IP, records only failed
|
|
24
|
+
* attempts via `check`, and forgives on success via `clear`).
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
export interface RateLimitResult {
|
|
@@ -39,6 +43,14 @@ export interface ComputeRateLimiter {
|
|
|
39
43
|
* Returns `{ allowed: false, retryAfter }` once the bucket is full.
|
|
40
44
|
*/
|
|
41
45
|
check(key: string): RateLimitResult;
|
|
46
|
+
/**
|
|
47
|
+
* Check WITHOUT recording. For flows that only count selected outcomes —
|
|
48
|
+
* e.g. login limiting, where `peek` gates the attempt and `check` records
|
|
49
|
+
* only failures (a success calls `clear` instead).
|
|
50
|
+
*/
|
|
51
|
+
peek(key: string): RateLimitResult;
|
|
52
|
+
/** Drop `key`'s bucket — e.g. a successful login forgives prior failures. */
|
|
53
|
+
clear(key: string): void;
|
|
42
54
|
/** Test seam — wipes in-memory state. Production code never calls this. */
|
|
43
55
|
reset(): void;
|
|
44
56
|
/** The resolved config, exposed for time-based test assertions. */
|
|
@@ -76,6 +88,21 @@ export function createComputeRateLimiter(config: ComputeRateLimiterConfig): Comp
|
|
|
76
88
|
entry.count += 1;
|
|
77
89
|
return { allowed: true };
|
|
78
90
|
},
|
|
91
|
+
peek(key: string): RateLimitResult {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const entry = buckets.get(key);
|
|
94
|
+
|
|
95
|
+
if (!entry || now > entry.resetAt) {
|
|
96
|
+
return { allowed: true };
|
|
97
|
+
}
|
|
98
|
+
if (entry.count >= maxPerWindow) {
|
|
99
|
+
return { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
|
100
|
+
}
|
|
101
|
+
return { allowed: true };
|
|
102
|
+
},
|
|
103
|
+
clear(key: string): void {
|
|
104
|
+
buckets.delete(key);
|
|
105
|
+
},
|
|
79
106
|
reset(): void {
|
|
80
107
|
buckets.clear();
|
|
81
108
|
}
|
|
@@ -156,11 +156,15 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
156
156
|
const treeBuildMs = performance.now() - treeBuildStart;
|
|
157
157
|
|
|
158
158
|
// --- solve --------------------------------------------------------------
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
// Snapshot the shared telemetry sequence counters. The holders live on the
|
|
160
|
+
// warm client and are written by every request on this server; with
|
|
161
|
+
// maxConcurrentSolves > 1 another request can write them mid-flight. After
|
|
162
|
+
// the solve, a slot is attributed to THIS request only when exactly one
|
|
163
|
+
// write happened since this snapshot — for solveMeta that single settle is
|
|
164
|
+
// necessarily ours (onSettle fires before scheduler.solve() resolves).
|
|
165
|
+
// Anything ambiguous is dropped from Server-Timing, never misattributed.
|
|
166
|
+
const settleSeqBefore = client.solveMeta.seq;
|
|
167
|
+
const rhinoSeqBefore = client.rhinoTiming.seq;
|
|
164
168
|
|
|
165
169
|
let result: GrasshopperComputeResponse;
|
|
166
170
|
const solveStart = performance.now();
|
|
@@ -213,6 +217,17 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
213
217
|
|
|
214
218
|
const serverTotalMs = performance.now() - args.loadStartMs;
|
|
215
219
|
|
|
220
|
+
// --- per-request telemetry attribution (see snapshot above) -------------
|
|
221
|
+
// settle: ours iff exactly one settle happened since the snapshot. rhino:
|
|
222
|
+
// only meaningful when the settle is ours AND it was a real compute call
|
|
223
|
+
// (a Selva-cache hit never reaches the server), and only unambiguous when
|
|
224
|
+
// exactly one Server-Timing callback fired in the window.
|
|
225
|
+
const settle = client.solveMeta.seq === settleSeqBefore + 1 ? client.solveMeta.last : null;
|
|
226
|
+
const rhino =
|
|
227
|
+
settle && !settle.fromCache && client.rhinoTiming.seq === rhinoSeqBefore + 1
|
|
228
|
+
? client.rhinoTiming.last
|
|
229
|
+
: null;
|
|
230
|
+
|
|
216
231
|
// --- Server-Timing envelope (the versioned wire contract) ---------------
|
|
217
232
|
const serverTiming = buildServerTiming({
|
|
218
233
|
defLoadMs: args.defLoadMs,
|
|
@@ -221,8 +236,8 @@ export async function runSolvePipeline(args: SolvePipelineArgs): Promise<SolveOu
|
|
|
221
236
|
serializeMs,
|
|
222
237
|
gzipMs,
|
|
223
238
|
serverTotalMs,
|
|
224
|
-
rhino
|
|
225
|
-
settle
|
|
239
|
+
rhino,
|
|
240
|
+
settle,
|
|
226
241
|
prepMarks: args.prepMarks ?? []
|
|
227
242
|
});
|
|
228
243
|
|