@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.
Files changed (141) hide show
  1. package/dist/access/__tests__/project-access-input.test.d.ts +2 -0
  2. package/dist/access/__tests__/project-access-input.test.d.ts.map +1 -0
  3. package/dist/access/__tests__/project-access-input.test.js +146 -0
  4. package/dist/access/__tests__/project-access-input.test.js.map +1 -0
  5. package/dist/access/index.d.ts +2 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/index.js +4 -0
  8. package/dist/access/index.js.map +1 -0
  9. package/dist/access/project-access-input.d.ts +52 -0
  10. package/dist/access/project-access-input.d.ts.map +1 -0
  11. package/dist/access/project-access-input.js +56 -0
  12. package/dist/access/project-access-input.js.map +1 -0
  13. package/dist/compute/__tests__/client-cache.test.js +52 -1
  14. package/dist/compute/__tests__/client-cache.test.js.map +1 -1
  15. package/dist/compute/__tests__/rate-limit.test.js +36 -0
  16. package/dist/compute/__tests__/rate-limit.test.js.map +1 -1
  17. package/dist/compute/__tests__/solve-pipeline.test.js +60 -13
  18. package/dist/compute/__tests__/solve-pipeline.test.js.map +1 -1
  19. package/dist/compute/__tests__/transform-input.test.js +7 -1
  20. package/dist/compute/__tests__/transform-input.test.js.map +1 -1
  21. package/dist/compute/client-cache.d.ts +20 -8
  22. package/dist/compute/client-cache.d.ts.map +1 -1
  23. package/dist/compute/client-cache.js +12 -3
  24. package/dist/compute/client-cache.js.map +1 -1
  25. package/dist/compute/limits.d.ts +10 -0
  26. package/dist/compute/limits.d.ts.map +1 -1
  27. package/dist/compute/limits.js +3 -1
  28. package/dist/compute/limits.js.map +1 -1
  29. package/dist/compute/rate-limit.d.ts +12 -0
  30. package/dist/compute/rate-limit.d.ts.map +1 -1
  31. package/dist/compute/rate-limit.js +18 -0
  32. package/dist/compute/rate-limit.js.map +1 -1
  33. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  34. package/dist/compute/solve-pipeline.js +20 -7
  35. package/dist/compute/solve-pipeline.js.map +1 -1
  36. package/dist/compute/transform-input.d.ts.map +1 -1
  37. package/dist/compute/transform-input.js +21 -6
  38. package/dist/compute/transform-input.js.map +1 -1
  39. package/dist/errors/SentryErrorReporter.d.ts +17 -0
  40. package/dist/errors/SentryErrorReporter.d.ts.map +1 -0
  41. package/dist/errors/SentryErrorReporter.js +60 -0
  42. package/dist/errors/SentryErrorReporter.js.map +1 -0
  43. package/dist/errors/index.d.ts +2 -0
  44. package/dist/errors/index.d.ts.map +1 -0
  45. package/dist/errors/index.js +3 -0
  46. package/dist/errors/index.js.map +1 -0
  47. package/dist/http/__tests__/body-size.test.d.ts +2 -0
  48. package/dist/http/__tests__/body-size.test.d.ts.map +1 -0
  49. package/dist/http/__tests__/body-size.test.js +24 -0
  50. package/dist/http/__tests__/body-size.test.js.map +1 -0
  51. package/dist/http/__tests__/route-classifier.test.d.ts +2 -0
  52. package/dist/http/__tests__/route-classifier.test.d.ts.map +1 -0
  53. package/dist/http/__tests__/route-classifier.test.js +54 -0
  54. package/dist/http/__tests__/route-classifier.test.js.map +1 -0
  55. package/dist/http/__tests__/safe-redirect.test.d.ts +2 -0
  56. package/dist/http/__tests__/safe-redirect.test.d.ts.map +1 -0
  57. package/dist/http/__tests__/safe-redirect.test.js +44 -0
  58. package/dist/http/__tests__/safe-redirect.test.js.map +1 -0
  59. package/dist/http/__tests__/security-headers.test.d.ts +2 -0
  60. package/dist/http/__tests__/security-headers.test.d.ts.map +1 -0
  61. package/dist/http/__tests__/security-headers.test.js +24 -0
  62. package/dist/http/__tests__/security-headers.test.js.map +1 -0
  63. package/dist/http/body-size.d.ts +21 -0
  64. package/dist/http/body-size.d.ts.map +1 -0
  65. package/dist/http/body-size.js +20 -0
  66. package/dist/http/body-size.js.map +1 -0
  67. package/dist/http/index.d.ts +5 -0
  68. package/dist/http/index.d.ts.map +1 -0
  69. package/dist/http/index.js +6 -0
  70. package/dist/http/index.js.map +1 -0
  71. package/dist/http/redirect.d.ts +11 -0
  72. package/dist/http/redirect.d.ts.map +1 -0
  73. package/dist/http/redirect.js +20 -0
  74. package/dist/http/redirect.js.map +1 -0
  75. package/dist/http/route-classifier.d.ts +47 -0
  76. package/dist/http/route-classifier.d.ts.map +1 -0
  77. package/dist/http/route-classifier.js +37 -0
  78. package/dist/http/route-classifier.js.map +1 -0
  79. package/dist/http/security-headers.d.ts +31 -0
  80. package/dist/http/security-headers.d.ts.map +1 -0
  81. package/dist/http/security-headers.js +31 -0
  82. package/dist/http/security-headers.js.map +1 -0
  83. package/dist/index.d.ts +11 -2
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +11 -2
  86. package/dist/index.js.map +1 -1
  87. package/dist/ops/__tests__/semver.test.d.ts +2 -0
  88. package/dist/ops/__tests__/semver.test.d.ts.map +1 -0
  89. package/dist/ops/__tests__/semver.test.js +38 -0
  90. package/dist/ops/__tests__/semver.test.js.map +1 -0
  91. package/dist/ops/index.d.ts +2 -0
  92. package/dist/ops/index.d.ts.map +1 -0
  93. package/dist/ops/index.js +3 -0
  94. package/dist/ops/index.js.map +1 -0
  95. package/dist/ops/semver.d.ts +35 -0
  96. package/dist/ops/semver.d.ts.map +1 -0
  97. package/dist/ops/semver.js +55 -0
  98. package/dist/ops/semver.js.map +1 -0
  99. package/dist/tokens/__tests__/token-codec.test.d.ts +2 -0
  100. package/dist/tokens/__tests__/token-codec.test.d.ts.map +1 -0
  101. package/dist/tokens/__tests__/token-codec.test.js +50 -0
  102. package/dist/tokens/__tests__/token-codec.test.js.map +1 -0
  103. package/dist/tokens/index.d.ts +2 -0
  104. package/dist/tokens/index.d.ts.map +1 -0
  105. package/dist/tokens/index.js +3 -0
  106. package/dist/tokens/index.js.map +1 -0
  107. package/dist/tokens/token-codec.d.ts +54 -0
  108. package/dist/tokens/token-codec.d.ts.map +1 -0
  109. package/dist/tokens/token-codec.js +62 -0
  110. package/dist/tokens/token-codec.js.map +1 -0
  111. package/package.json +35 -2
  112. package/src/access/__tests__/project-access-input.test.ts +175 -0
  113. package/src/access/index.ts +9 -0
  114. package/src/access/project-access-input.ts +145 -0
  115. package/src/compute/__tests__/client-cache.test.ts +61 -1
  116. package/src/compute/__tests__/rate-limit.test.ts +37 -0
  117. package/src/compute/__tests__/solve-pipeline.test.ts +60 -13
  118. package/src/compute/__tests__/transform-input.test.ts +8 -1
  119. package/src/compute/client-cache.ts +35 -13
  120. package/src/compute/limits.ts +13 -1
  121. package/src/compute/rate-limit.ts +27 -0
  122. package/src/compute/solve-pipeline.ts +22 -7
  123. package/src/compute/transform-input.ts +20 -6
  124. package/src/errors/SentryErrorReporter.ts +88 -0
  125. package/src/errors/index.ts +3 -0
  126. package/src/http/__tests__/body-size.test.ts +27 -0
  127. package/src/http/__tests__/route-classifier.test.ts +60 -0
  128. package/src/http/__tests__/safe-redirect.test.ts +47 -0
  129. package/src/http/__tests__/security-headers.test.ts +29 -0
  130. package/src/http/body-size.ts +24 -0
  131. package/src/http/index.ts +10 -0
  132. package/src/http/redirect.ts +16 -0
  133. package/src/http/route-classifier.ts +74 -0
  134. package/src/http/security-headers.ts +42 -0
  135. package/src/index.ts +11 -2
  136. package/src/ops/__tests__/semver.test.ts +42 -0
  137. package/src/ops/index.ts +3 -0
  138. package/src/ops/semver.ts +60 -0
  139. package/src/tokens/__tests__/token-codec.test.ts +63 -0
  140. package/src/tokens/index.ts +8 -0
  141. 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 onServerTiming callback fires DURING the solve, after the
250
- // pipeline has reset the holder — so populate it inside the solve stub, not
251
- // before the call (which the pipeline's pre-solve reset would wipe).
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('resets stale telemetry holders before solving', async () => {
282
- // A warm client carrying a previous request's timing must not leak it into
283
- // this solve's Server-Timing when the current solve reports nothing.
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.last = { decode: 99, solve: 99, encode: 99 };
286
- client.solveMeta.last = { fromCache: true };
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('forces stepSize to 1 for integers', () => {
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
- * Written by onServerTiming, read right after scheduler.solve resolves to split
64
- * the solve wall time into on-compute-server work vs. the compute↔web-server
65
- * link. Queue-mode schedulers serialize solves, so this correlates with the
66
- * request that just resolved; a Selva-cache hit leaves it null (no compute
67
- * call). Debug aid — a concurrent burst may misattribute one request's timing.
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. Same read-after-solve pattern and
74
- * concurrency caveat as rhinoTiming.
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: { last: { fromCache: boolean; definitionReuploaded?: boolean } | null };
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
- const rhinoTiming: CachedClient['rhinoTiming'] = { last: null };
141
- const solveMeta: CachedClient['solveMeta'] = { last: null };
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 = {
@@ -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
- // Reset the per-request telemetry holders so a Selva-cache hit (no compute
160
- // call) isn't attributed a stale timing from an earlier request on this warm
161
- // client. See the rhinoTiming/solveMeta concurrency caveat in client-cache.
162
- client.rhinoTiming.last = null;
163
- client.solveMeta.last = null;
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: client.rhinoTiming.last,
225
- settle: client.solveMeta.last,
239
+ rhino,
240
+ settle,
226
241
  prepMarks: args.prepMarks ?? []
227
242
  });
228
243