capman 0.5.2 → 0.5.3

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 (46) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CODEBASE.md +2 -1
  3. package/dist/cjs/cache.d.ts +2 -1
  4. package/dist/cjs/cache.d.ts.map +1 -1
  5. package/dist/cjs/cache.js +11 -6
  6. package/dist/cjs/cache.js.map +1 -1
  7. package/dist/cjs/engine.d.ts.map +1 -1
  8. package/dist/cjs/engine.js +35 -17
  9. package/dist/cjs/engine.js.map +1 -1
  10. package/dist/cjs/generator.d.ts.map +1 -1
  11. package/dist/cjs/generator.js +16 -1
  12. package/dist/cjs/generator.js.map +1 -1
  13. package/dist/cjs/learning.d.ts +20 -10
  14. package/dist/cjs/learning.d.ts.map +1 -1
  15. package/dist/cjs/learning.js +146 -129
  16. package/dist/cjs/learning.js.map +1 -1
  17. package/dist/cjs/matcher.d.ts.map +1 -1
  18. package/dist/cjs/matcher.js +2 -1
  19. package/dist/cjs/matcher.js.map +1 -1
  20. package/dist/cjs/parser.js +8 -2
  21. package/dist/cjs/parser.js.map +1 -1
  22. package/dist/cjs/resolver.d.ts +7 -0
  23. package/dist/cjs/resolver.d.ts.map +1 -1
  24. package/dist/cjs/resolver.js +47 -23
  25. package/dist/cjs/resolver.js.map +1 -1
  26. package/dist/cjs/schema.d.ts +93 -1
  27. package/dist/cjs/schema.d.ts.map +1 -1
  28. package/dist/cjs/schema.js +5 -2
  29. package/dist/cjs/schema.js.map +1 -1
  30. package/dist/cjs/version.d.ts +1 -1
  31. package/dist/cjs/version.js +1 -1
  32. package/dist/esm/cache.d.ts +2 -1
  33. package/dist/esm/cache.js +11 -6
  34. package/dist/esm/engine.js +35 -17
  35. package/dist/esm/generator.js +16 -1
  36. package/dist/esm/learning.d.ts +20 -10
  37. package/dist/esm/learning.js +146 -129
  38. package/dist/esm/matcher.js +2 -1
  39. package/dist/esm/parser.js +8 -2
  40. package/dist/esm/resolver.d.ts +7 -0
  41. package/dist/esm/resolver.js +47 -23
  42. package/dist/esm/schema.d.ts +93 -1
  43. package/dist/esm/schema.js +5 -2
  44. package/dist/esm/version.d.ts +1 -1
  45. package/dist/esm/version.js +1 -1
  46. package/package.json +1 -1
@@ -3,35 +3,16 @@ import * as path from 'path';
3
3
  import { logger } from './logger';
4
4
  const MAX_LEARNING_ENTRIES = 10_000;
5
5
  import { STOPWORDS } from './matcher';
6
- // ─── Shared computation helpers ───────────────────────────────────────────────
7
- function computeStats(entries) {
8
- const index = {};
9
- let totalQueries = 0;
10
- let llmQueries = 0;
11
- let cacheHits = 0;
12
- let outOfScope = 0;
13
- for (const entry of entries) {
14
- totalQueries++;
15
- if (entry.resolvedVia === 'llm')
16
- llmQueries++;
17
- if (entry.resolvedVia === 'cache')
18
- cacheHits++;
19
- if (!entry.capabilityId)
20
- outOfScope++;
21
- if (entry.capabilityId) {
22
- const words = entry.query.toLowerCase()
23
- .split(/\W+/)
24
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
25
- for (const word of words) {
26
- if (!index[word])
27
- index[word] = {};
28
- index[word][entry.capabilityId] =
29
- (index[word][entry.capabilityId] ?? 0) + 1;
30
- }
31
- }
6
+ // Module-level registry tracks all active FileLearningStore instances
7
+ // for process exit flushing. Handlers registered once to avoid accumulation.
8
+ const activeStores = new Set();
9
+ let exitHandlersRegistered = false;
10
+ function flushAllStores() {
11
+ for (const store of activeStores) {
12
+ store.flushSync();
32
13
  }
33
- return { index, totalQueries, llmQueries, cacheHits, outOfScope };
34
14
  }
15
+ // ─── Shared computation helpers ───────────────────────────────────────────────
35
16
  function computeTopCapabilities(entries, limit) {
36
17
  const counts = {};
37
18
  for (const entry of entries) {
@@ -44,28 +25,18 @@ function computeTopCapabilities(entries, limit) {
44
25
  .slice(0, limit)
45
26
  .map(([id, hits]) => ({ id, hits }));
46
27
  }
47
- // ─── File Learning Store ──────────────────────────────────────────────────────
48
- export class FileLearningStore {
49
- constructor(filePath = '.capman/learning.json') {
50
- this.entries = [];
51
- this.loaded = false;
52
- this.saveQueue = Promise.resolve();
53
- // ── Incremental index — updated in record(), not rebuilt in getStats() ────
28
+ // ─── Shared Learning Index ────────────────────────────────────────────────────
29
+ // Encapsulates keyword index and stats counters.
30
+ // Both FileLearningStore and MemoryLearningStore compose this instead of
31
+ // duplicating the same ~80 lines of index management logic.
32
+ class LearningIndex {
33
+ constructor() {
54
34
  this.index = {};
55
35
  this.statsCounter = {
56
36
  totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
57
37
  };
58
- const cwd = process.cwd();
59
- const resolved = path.resolve(cwd, filePath);
60
- const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
61
- if (!resolved.startsWith(allowedPrefix)) {
62
- throw new Error(`FileCache path "${filePath}" resolves outside the working directory.\n` +
63
- `Resolved: ${resolved}\nAllowed: ${cwd}`);
64
- }
65
- this.filePath = resolved;
66
- logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
67
38
  }
68
- updateIndex(entry) {
39
+ update(entry) {
69
40
  this.statsCounter.totalQueries++;
70
41
  if (entry.resolvedVia === 'llm')
71
42
  this.statsCounter.llmQueries++;
@@ -84,21 +55,18 @@ export class FileLearningStore {
84
55
  }
85
56
  }
86
57
  }
87
- subtractFromIndex(entry) {
88
- if (!entry.capabilityId) {
89
- this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
90
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
91
- if (entry.resolvedVia === 'llm')
92
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
93
- if (entry.resolvedVia === 'cache')
94
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
95
- return;
96
- }
58
+ subtract(entry) {
59
+ // Shared counter decrements regardless of capabilityId
97
60
  this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
98
61
  if (entry.resolvedVia === 'llm')
99
62
  this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
100
63
  if (entry.resolvedVia === 'cache')
101
64
  this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
65
+ if (!entry.capabilityId) {
66
+ this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
67
+ return;
68
+ }
69
+ // Keyword index cleanup
102
70
  const words = entry.query.toLowerCase()
103
71
  .split(/\W+/)
104
72
  .filter(w => w.length > 2 && !STOPWORDS.has(w));
@@ -115,22 +83,102 @@ export class FileLearningStore {
115
83
  }
116
84
  }
117
85
  }
118
- rebuildIndex() {
86
+ rebuild(entries) {
87
+ this.index = {};
88
+ this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
89
+ for (const entry of entries) {
90
+ this.update(entry);
91
+ }
92
+ }
93
+ reset() {
119
94
  this.index = {};
120
95
  this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
121
- for (const entry of this.entries) {
122
- this.updateIndex(entry);
96
+ }
97
+ getStats() {
98
+ return { ...this.statsCounter, index: structuredClone(this.index) };
99
+ }
100
+ getIndex() {
101
+ return structuredClone(this.index);
102
+ }
103
+ }
104
+ // ─── File Learning Store ──────────────────────────────────────────────────────
105
+ export class FileLearningStore {
106
+ constructor(filePath = '.capman/learning.json') {
107
+ this.entries = [];
108
+ this.loadPromise = null;
109
+ this.saveQueue = Promise.resolve();
110
+ this.learningIndex = new LearningIndex();
111
+ this.dirty = false;
112
+ this.saveTimer = null;
113
+ const cwd = process.cwd();
114
+ const resolved = path.resolve(cwd, filePath);
115
+ const allowedPrefix = cwd === '/' ? '/' : cwd + path.sep;
116
+ if (!resolved.startsWith(allowedPrefix)) {
117
+ throw new Error(`FileLearningStore path "${filePath}" resolves outside the working directory.\n` +
118
+ `Resolved: ${resolved}\nAllowed: ${cwd}`);
119
+ }
120
+ this.filePath = resolved;
121
+ logger.info(`FileLearningStore initialized — writing to: ${this.filePath}`);
122
+ activeStores.add(this);
123
+ if (!exitHandlersRegistered) {
124
+ exitHandlersRegistered = true;
125
+ process.on('exit', flushAllStores);
126
+ process.on('SIGTERM', () => { flushAllStores(); process.exit(0); });
127
+ process.on('SIGINT', () => { flushAllStores(); process.exit(0); });
123
128
  }
124
129
  }
125
- async load() {
126
- if (this.loaded)
130
+ flushSync() {
131
+ // Cancel pending timer — prevents scheduleSave firing after sync write
132
+ if (this.saveTimer) {
133
+ clearTimeout(this.saveTimer);
134
+ this.saveTimer = null;
135
+ }
136
+ if (!this.dirty)
127
137
  return;
138
+ this.dirty = false;
139
+ try {
140
+ const dir = path.dirname(this.filePath);
141
+ fs.mkdirSync(dir, { recursive: true });
142
+ const tmp = `${this.filePath}.tmp`;
143
+ const payload = JSON.stringify({ entries: this.entries, updatedAt: new Date().toISOString() }, null, 2);
144
+ // Write to .tmp then rename — matches _doSave() pattern so they can't interleave
145
+ fs.writeFileSync(tmp, payload);
146
+ fs.renameSync(tmp, this.filePath);
147
+ }
148
+ catch {
149
+ // Best-effort in exit handler
150
+ }
151
+ }
152
+ /**
153
+ * Removes this store from the exit flush registry and cancels any pending save timer.
154
+ * Call when the store is no longer needed to prevent memory leaks in long-running servers.
155
+ */
156
+ async destroy() {
157
+ if (this.saveTimer) {
158
+ clearTimeout(this.saveTimer);
159
+ this.saveTimer = null;
160
+ }
161
+ if (this.dirty) {
162
+ this.dirty = false;
163
+ // Await final flush before removing from registry —
164
+ // ensures data is written before the store becomes unreachable
165
+ await this.save();
166
+ }
167
+ activeStores.delete(this);
168
+ }
169
+ load() {
170
+ if (!this.loadPromise) {
171
+ this.loadPromise = this._doLoad();
172
+ }
173
+ return this.loadPromise;
174
+ }
175
+ async _doLoad() {
128
176
  try {
129
177
  const raw = await fs.promises.readFile(this.filePath, 'utf-8');
130
178
  const parsed = JSON.parse(raw);
131
179
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.entries)) {
132
180
  this.entries = parsed.entries;
133
- this.rebuildIndex();
181
+ this.learningIndex.rebuild(this.entries);
134
182
  logger.debug(`Learning store loaded: ${this.entries.length} entries`);
135
183
  }
136
184
  else {
@@ -140,7 +188,19 @@ export class FileLearningStore {
140
188
  catch {
141
189
  // File doesn't exist yet — start fresh
142
190
  }
143
- this.loaded = true;
191
+ }
192
+ scheduleSave(urgencyMs = 5_000) {
193
+ this.dirty = true;
194
+ if (!this.saveTimer) {
195
+ this.saveTimer = setTimeout(() => {
196
+ this.saveTimer = null;
197
+ if (this.dirty) {
198
+ this.dirty = false;
199
+ // Route through saveQueue — serializes with all other saves
200
+ this.save();
201
+ }
202
+ }, urgencyMs);
203
+ }
144
204
  }
145
205
  save() {
146
206
  this.saveQueue = this.saveQueue.then(() => this._doSave());
@@ -150,10 +210,12 @@ export class FileLearningStore {
150
210
  try {
151
211
  const dir = path.dirname(this.filePath);
152
212
  await fs.promises.mkdir(dir, { recursive: true });
153
- await fs.promises.writeFile(this.filePath, JSON.stringify({
213
+ const tmp = `${this.filePath}.tmp`;
214
+ await fs.promises.writeFile(tmp, JSON.stringify({
154
215
  entries: this.entries,
155
216
  updatedAt: new Date().toISOString(),
156
217
  }, null, 2));
218
+ await fs.promises.rename(tmp, this.filePath);
157
219
  }
158
220
  catch (err) {
159
221
  logger.warn(`Failed to save learning store to ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`);
@@ -173,34 +235,40 @@ export class FileLearningStore {
173
235
  .join(' '),
174
236
  };
175
237
  this.entries.push(sanitized);
176
- this.updateIndex(sanitized);
238
+ this.learningIndex.update(sanitized);
177
239
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
178
240
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
179
241
  const pruned = this.entries.splice(0, excess);
180
242
  // Subtract pruned entries from index — O(pruned × w) instead of O(n × w) full rebuild
181
243
  for (const entry of pruned) {
182
- this.subtractFromIndex(entry);
244
+ this.learningIndex.subtract(entry);
183
245
  }
184
246
  logger.debug(`Learning store pruned ${excess} oldest entries (cap: ${MAX_LEARNING_ENTRIES})`);
185
247
  }
186
- await this.save();
248
+ this.scheduleSave();
187
249
  }
188
250
  async getStats() {
189
251
  await this.load();
190
- return { ...this.statsCounter, index: structuredClone(this.index) };
252
+ return this.learningIndex.getStats();
191
253
  }
192
254
  async getIndex() {
193
255
  await this.load();
194
- return structuredClone(this.index);
256
+ return this.learningIndex.getIndex();
195
257
  }
196
258
  async getTopCapabilities(limit = 5) {
197
259
  await this.load();
198
260
  return computeTopCapabilities(this.entries, limit);
199
261
  }
200
262
  async clear() {
263
+ // Cancel any pending debounced save — prevents stale data being written
264
+ // after clear() resets state
265
+ if (this.saveTimer) {
266
+ clearTimeout(this.saveTimer);
267
+ this.saveTimer = null;
268
+ }
269
+ this.dirty = false;
201
270
  this.entries = [];
202
- this.index = {};
203
- this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
271
+ this.learningIndex.reset();
204
272
  await this.save();
205
273
  }
206
274
  }
@@ -208,10 +276,7 @@ export class FileLearningStore {
208
276
  export class MemoryLearningStore {
209
277
  constructor() {
210
278
  this.entries = [];
211
- this.index = {};
212
- this.statsCounter = {
213
- totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0,
214
- };
279
+ this.learningIndex = new LearningIndex();
215
280
  }
216
281
  async record(entry) {
217
282
  const sanitized = {
@@ -223,77 +288,29 @@ export class MemoryLearningStore {
223
288
  .join(' '),
224
289
  };
225
290
  this.entries.push(sanitized);
226
- this.updateIndex(sanitized);
291
+ this.learningIndex.update(sanitized);
227
292
  if (this.entries.length > MAX_LEARNING_ENTRIES) {
228
293
  const excess = this.entries.length - MAX_LEARNING_ENTRIES;
229
294
  const pruned = this.entries.splice(0, excess);
230
295
  for (const entry of pruned) {
231
- this.subtractFromIndex(entry);
296
+ this.learningIndex.subtract(entry);
232
297
  }
233
298
  }
234
299
  }
235
300
  async getStats() {
236
- return { ...this.statsCounter, index: structuredClone(this.index) };
301
+ return this.learningIndex.getStats();
237
302
  }
238
303
  async getIndex() {
239
- return structuredClone(this.index);
240
- }
241
- updateIndex(entry) {
242
- this.statsCounter.totalQueries++;
243
- if (entry.resolvedVia === 'llm')
244
- this.statsCounter.llmQueries++;
245
- if (entry.resolvedVia === 'cache')
246
- this.statsCounter.cacheHits++;
247
- if (!entry.capabilityId)
248
- this.statsCounter.outOfScope++;
249
- if (entry.capabilityId) {
250
- const words = entry.query.toLowerCase()
251
- .split(/\W+/)
252
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
253
- for (const word of words) {
254
- this.index[word] ??= {};
255
- this.index[word][entry.capabilityId] =
256
- (this.index[word][entry.capabilityId] ?? 0) + 1;
257
- }
258
- }
259
- }
260
- subtractFromIndex(entry) {
261
- if (!entry.capabilityId) {
262
- this.statsCounter.outOfScope = Math.max(0, this.statsCounter.outOfScope - 1);
263
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
264
- if (entry.resolvedVia === 'llm')
265
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
266
- if (entry.resolvedVia === 'cache')
267
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
268
- return;
269
- }
270
- this.statsCounter.totalQueries = Math.max(0, this.statsCounter.totalQueries - 1);
271
- if (entry.resolvedVia === 'llm')
272
- this.statsCounter.llmQueries = Math.max(0, this.statsCounter.llmQueries - 1);
273
- if (entry.resolvedVia === 'cache')
274
- this.statsCounter.cacheHits = Math.max(0, this.statsCounter.cacheHits - 1);
275
- const words = entry.query.toLowerCase()
276
- .split(/\W+/)
277
- .filter(w => w.length > 2 && !STOPWORDS.has(w));
278
- for (const word of words) {
279
- if (!this.index[word])
280
- continue;
281
- this.index[word][entry.capabilityId] =
282
- (this.index[word][entry.capabilityId] ?? 1) - 1;
283
- if (this.index[word][entry.capabilityId] <= 0) {
284
- delete this.index[word][entry.capabilityId];
285
- }
286
- if (Object.keys(this.index[word]).length === 0) {
287
- delete this.index[word];
288
- }
289
- }
304
+ return this.learningIndex.getIndex();
290
305
  }
291
306
  async getTopCapabilities(limit = 5) {
292
307
  return computeTopCapabilities(this.entries, limit);
293
308
  }
294
309
  async clear() {
295
310
  this.entries = [];
296
- this.index = {};
297
- this.statsCounter = { totalQueries: 0, llmQueries: 0, cacheHits: 0, outOfScope: 0 };
311
+ this.learningIndex.reset();
312
+ }
313
+ async destroy() {
314
+ // No-op for memory store — nothing to flush or deregister
298
315
  }
299
316
  }
@@ -148,7 +148,8 @@ export function match(query, manifest) {
148
148
  candidates: [],
149
149
  };
150
150
  }
151
- logger.info(`Matching query: "${query}"`);
151
+ logger.info(`Matching query (${query.length} chars)`);
152
+ logger.debug(`Full query: "${query}"`);
152
153
  logger.debug(`Manifest has ${manifest.capabilities.length} capabilities`);
153
154
  let best = null;
154
155
  let bestScore = 0;
@@ -50,8 +50,14 @@ function parseSpecText(text, source) {
50
50
  const yaml = require('js-yaml');
51
51
  return yaml.load(text);
52
52
  }
53
- catch {
54
- // js-yaml not installed try basic YAML detection
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ // Distinguish "module not found" from actual YAML parse errors
56
+ const code = err.code;
57
+ if (code !== 'MODULE_NOT_FOUND') {
58
+ throw new Error(`YAML parse error in "${source}": ${msg}`);
59
+ }
60
+ // js-yaml not installed — fall through to extension check
55
61
  if (source.endsWith('.yaml') || source.endsWith('.yml')) {
56
62
  throw new Error('YAML spec detected but js-yaml is not installed.\n' +
57
63
  'Install it: npm install js-yaml\n' +
@@ -17,5 +17,12 @@ export interface ResolveOptions {
17
17
  retries?: number;
18
18
  /** Timeout in milliseconds (default: 5000) */
19
19
  timeoutMs?: number;
20
+ /**
21
+ * When true, retries all HTTP methods including POST/PUT/PATCH/DELETE.
22
+ * Use only for idempotent write operations — retrying non-idempotent
23
+ * methods can cause duplicate side effects (duplicate orders, double charges).
24
+ * @default false
25
+ */
26
+ retryAllMethods?: boolean;
20
27
  }
21
28
  export declare function resolve(matchResult: MatchResult, params?: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
@@ -1,4 +1,6 @@
1
1
  import { logger } from './logger';
2
+ // ─── Constants ────────────────────────────────────────────────────────────────
3
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
2
4
  function redactParams(params) {
3
5
  return Object.fromEntries(Object.entries(params).map(([k, v]) => [k, v != null ? '[REDACTED]' : 'null']));
4
6
  }
@@ -49,12 +51,8 @@ export async function resolve(matchResult, params = {}, options = {}) {
49
51
  // they must never leak into the query string as ?user_id=xyz
50
52
  const enrichedParams = { ...params };
51
53
  if (options.auth?.userId !== undefined && options.auth.userId !== '') {
52
- const resolver = capability.resolver;
53
- const pathTemplate = resolver.type === 'api' ? resolver.endpoints.map(e => e.path).join('') :
54
- resolver.type === 'hybrid' ? resolver.api.endpoints.map(e => e.path).join('') :
55
- resolver.type === 'nav' ? resolver.destination : '';
56
54
  for (const param of capability.params) {
57
- if (param.source === 'session' && pathTemplate.includes(`{${param.name}}`)) {
55
+ if (param.source === 'session') {
58
56
  enrichedParams[param.name] = options.auth.userId;
59
57
  logger.debug(`Injected session param "${param.name}" (value redacted)`);
60
58
  }
@@ -65,15 +63,18 @@ export async function resolve(matchResult, params = {}, options = {}) {
65
63
  logger.debug(`Params: ${JSON.stringify(redactParams(params))}`);
66
64
  logger.debug(`Options: baseUrl=${options.baseUrl} dryRun=${options.dryRun}`);
67
65
  try {
66
+ const sessionParamNames = new Set(capability.params
67
+ .filter(p => p.source === 'session')
68
+ .map(p => p.name));
68
69
  switch (resolver.type) {
69
70
  case 'api':
70
- return await resolveApi(resolver, enrichedParams, options);
71
+ return await resolveApi(resolver, enrichedParams, options, sessionParamNames);
71
72
  case 'nav':
72
73
  return resolveNav(resolver, enrichedParams);
73
74
  case 'hybrid': {
74
75
  logger.debug('Hybrid resolver — running API and nav in parallel');
75
76
  const [apiResult, navResult] = await Promise.all([
76
- resolveApi(resolver.api, enrichedParams, options),
77
+ resolveApi(resolver.api, enrichedParams, options, sessionParamNames),
77
78
  Promise.resolve(resolveNav(resolver.nav, enrichedParams)),
78
79
  ]);
79
80
  return {
@@ -109,15 +110,26 @@ export async function resolve(matchResult, params = {}, options = {}) {
109
110
  * For capabilities where ordering or rollback matters, define separate capabilities
110
111
  * with single endpoints and orchestrate them at the application layer.
111
112
  */
112
- async function resolveApi(resolver, params, options) {
113
+ async function resolveApi(resolver, params, options, sessionParamNames = new Set()) {
113
114
  const startTime = Date.now();
114
115
  const retries = options.retries ?? 0;
115
116
  const timeoutMs = options.timeoutMs ?? 5000;
116
- const apiCalls = resolver.endpoints.map(endpoint => ({
117
- method: endpoint.method,
118
- url: buildUrl(options.baseUrl ?? '', endpoint.path, params),
119
- params: Object.fromEntries(Object.entries(params).filter(([, v]) => v !== null && v !== undefined)),
120
- }));
117
+ const apiCalls = resolver.endpoints.map(endpoint => {
118
+ // Build per-endpoint params — only inject session params if this
119
+ // specific endpoint has the placeholder. Prevents userId leaking
120
+ // as ?user_id=xyz on endpoints that don't use it in their path.
121
+ const endpointParams = { ...params };
122
+ for (const name of sessionParamNames) {
123
+ if (!endpoint.path.includes(`{${name}}`)) {
124
+ delete endpointParams[name]; // strip session param — not in this endpoint's path
125
+ }
126
+ }
127
+ return {
128
+ method: endpoint.method,
129
+ url: buildUrl(options.baseUrl ?? '', endpoint.path, endpointParams),
130
+ params: Object.fromEntries(Object.entries(endpointParams).filter(([, v]) => v !== null && v !== undefined)),
131
+ };
132
+ });
121
133
  if (options.dryRun) {
122
134
  return { success: true, resolverType: 'api', apiCalls, durationMs: Date.now() - startTime };
123
135
  }
@@ -130,9 +142,14 @@ async function resolveApi(resolver, params, options) {
130
142
  };
131
143
  }
132
144
  // ── Fetch with retry + timeout (iterative — no recursion) ────────────────
145
+ // Only retry safe/idempotent methods — retrying POST/PUT/PATCH/DELETE
146
+ // can cause duplicate side effects (e.g. duplicate orders, double charges).
133
147
  async function fetchWithRetry(call) {
148
+ const effectiveRetries = (options.retryAllMethods || SAFE_METHODS.has(call.method))
149
+ ? retries
150
+ : 0;
134
151
  let lastErr;
135
- for (let attempt = 0; attempt <= retries; attempt++) {
152
+ for (let attempt = 0; attempt <= effectiveRetries; attempt++) {
136
153
  const controller = new AbortController();
137
154
  const timer = setTimeout(() => controller.abort(), timeoutMs);
138
155
  try {
@@ -151,8 +168,8 @@ async function resolveApi(resolver, params, options) {
151
168
  clearTimeout(timer);
152
169
  lastErr = err;
153
170
  const isTimeout = err instanceof Error && err.name === 'AbortError';
154
- if (attempt < retries) {
155
- logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}) — retrying: ${isTimeout ? 'timeout' : err}`);
171
+ if (attempt < effectiveRetries) {
172
+ logger.warn(`Request failed (attempt ${attempt + 1}/${effectiveRetries + 1}) — retrying: ${isTimeout ? 'timeout' : err}`);
156
173
  }
157
174
  else {
158
175
  throw isTimeout ? new Error(`Request timed out after ${timeoutMs}ms`) : err;
@@ -209,12 +226,17 @@ function resolveNav(resolver, params) {
209
226
  }
210
227
  return { success: true, resolverType: 'nav', navTarget: destination };
211
228
  }
212
- // Note: buildUrl does not validate param values against an allowlist.
213
- // resolveNav() does validate via validateNavParam() because nav destinations
214
- // are used as deep links where path traversal is a real risk.
215
- // For API URLs, extractParams() strips most dangerous characters upstream,
216
- // so the practical risk is low — but any future caller bypassing extractParams
217
- // should add validation here too.
229
+ function validateApiPathParam(key, value) {
230
+ // Prevent path traversal via unencoded slashes encodeURIComponent does not
231
+ // encode '/' so a value like '../../admin' would traverse the path hierarchy.
232
+ // This mirrors the allowlist validation already applied in resolveNav().
233
+ if (!/^[a-zA-Z0-9_\-.:@]+$/.test(value)) {
234
+ throw new Error(`API path param "${key}" contains invalid characters: "${value}". ` +
235
+ `Only alphanumeric, hyphens, underscores, dots, colons, and @ are allowed.`);
236
+ }
237
+ }
238
+ // Both buildUrl (API) and resolveNav (nav) validate path param values against
239
+ // an allowlist before substitution — prevents path traversal via unencoded slashes.
218
240
  function buildUrl(baseUrl, urlPath, params) {
219
241
  let resolved = urlPath;
220
242
  const unused = {};
@@ -222,7 +244,9 @@ function buildUrl(baseUrl, urlPath, params) {
222
244
  if (value === null || value === undefined)
223
245
  continue; // never write null into URLs
224
246
  if (resolved.includes(`{${key}}`)) {
225
- resolved = resolved.replaceAll(`{${key}}`, encodeURIComponent(String(value)));
247
+ const str = String(value);
248
+ validateApiPathParam(key, str);
249
+ resolved = resolved.replaceAll(`{${key}}`, encodeURIComponent(str));
226
250
  }
227
251
  else {
228
252
  unused[key] = value;