capman 0.5.2 → 0.5.4

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 (53) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CODEBASE.md +15 -9
  3. package/bin/lib/cmd-explain.js +2 -2
  4. package/bin/lib/cmd-run.js +2 -2
  5. package/bin/lib/shared.js +8 -2
  6. package/dist/cjs/cache.d.ts +2 -1
  7. package/dist/cjs/cache.d.ts.map +1 -1
  8. package/dist/cjs/cache.js +11 -6
  9. package/dist/cjs/cache.js.map +1 -1
  10. package/dist/cjs/engine.d.ts +30 -0
  11. package/dist/cjs/engine.d.ts.map +1 -1
  12. package/dist/cjs/engine.js +69 -25
  13. package/dist/cjs/engine.js.map +1 -1
  14. package/dist/cjs/generator.d.ts.map +1 -1
  15. package/dist/cjs/generator.js +16 -1
  16. package/dist/cjs/generator.js.map +1 -1
  17. package/dist/cjs/learning.d.ts +20 -10
  18. package/dist/cjs/learning.d.ts.map +1 -1
  19. package/dist/cjs/learning.js +146 -129
  20. package/dist/cjs/learning.js.map +1 -1
  21. package/dist/cjs/matcher.d.ts +5 -2
  22. package/dist/cjs/matcher.d.ts.map +1 -1
  23. package/dist/cjs/matcher.js +73 -10
  24. package/dist/cjs/matcher.js.map +1 -1
  25. package/dist/cjs/parser.js +8 -2
  26. package/dist/cjs/parser.js.map +1 -1
  27. package/dist/cjs/resolver.d.ts +7 -0
  28. package/dist/cjs/resolver.d.ts.map +1 -1
  29. package/dist/cjs/resolver.js +47 -23
  30. package/dist/cjs/resolver.js.map +1 -1
  31. package/dist/cjs/schema.d.ts +93 -1
  32. package/dist/cjs/schema.d.ts.map +1 -1
  33. package/dist/cjs/schema.js +5 -2
  34. package/dist/cjs/schema.js.map +1 -1
  35. package/dist/cjs/version.d.ts +1 -1
  36. package/dist/cjs/version.js +1 -1
  37. package/dist/esm/cache.d.ts +2 -1
  38. package/dist/esm/cache.js +11 -6
  39. package/dist/esm/engine.d.ts +30 -0
  40. package/dist/esm/engine.js +69 -25
  41. package/dist/esm/generator.js +16 -1
  42. package/dist/esm/learning.d.ts +20 -10
  43. package/dist/esm/learning.js +146 -129
  44. package/dist/esm/matcher.d.ts +5 -2
  45. package/dist/esm/matcher.js +70 -10
  46. package/dist/esm/parser.js +8 -2
  47. package/dist/esm/resolver.d.ts +7 -0
  48. package/dist/esm/resolver.js +47 -23
  49. package/dist/esm/schema.d.ts +93 -1
  50. package/dist/esm/schema.js +5 -2
  51. package/dist/esm/version.d.ts +1 -1
  52. package/dist/esm/version.js +1 -1
  53. package/package.json +11 -10
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- export declare const CapmanConfigSchema: z.ZodObject<{
2
+ export declare const CapmanConfigSchema: z.ZodEffects<z.ZodObject<{
3
3
  app: z.ZodString;
4
4
  baseUrl: z.ZodOptional<z.ZodString>;
5
5
  capabilities: z.ZodEffects<z.ZodArray<z.ZodObject<{
@@ -405,6 +405,98 @@ export declare const CapmanConfigSchema: z.ZodObject<{
405
405
  examples?: string[] | undefined;
406
406
  }[];
407
407
  baseUrl?: string | undefined;
408
+ }>, {
409
+ app: string;
410
+ capabilities: {
411
+ name: string;
412
+ id: string;
413
+ params: {
414
+ name: string;
415
+ required: boolean;
416
+ description: string;
417
+ source: "user_query" | "session" | "context" | "static";
418
+ default?: string | number | boolean | undefined;
419
+ }[];
420
+ description: string;
421
+ returns: string[];
422
+ resolver: {
423
+ type: "api";
424
+ endpoints: {
425
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
426
+ path: string;
427
+ params?: string[] | undefined;
428
+ }[];
429
+ } | {
430
+ type: "nav";
431
+ destination: string;
432
+ hint?: string | undefined;
433
+ } | {
434
+ type: "hybrid";
435
+ api: {
436
+ endpoints: {
437
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
438
+ path: string;
439
+ params?: string[] | undefined;
440
+ }[];
441
+ };
442
+ nav: {
443
+ destination: string;
444
+ hint?: string | undefined;
445
+ };
446
+ };
447
+ privacy: {
448
+ level: "public" | "user_owned" | "admin";
449
+ note?: string | undefined;
450
+ };
451
+ examples?: string[] | undefined;
452
+ }[];
453
+ baseUrl?: string | undefined;
454
+ }, {
455
+ app: string;
456
+ capabilities: {
457
+ name: string;
458
+ id: string;
459
+ params: {
460
+ name: string;
461
+ required: boolean;
462
+ description: string;
463
+ source: "user_query" | "session" | "context" | "static";
464
+ default?: string | number | boolean | undefined;
465
+ }[];
466
+ description: string;
467
+ returns: string[];
468
+ resolver: {
469
+ type: "api";
470
+ endpoints: {
471
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
472
+ path: string;
473
+ params?: string[] | undefined;
474
+ }[];
475
+ } | {
476
+ type: "nav";
477
+ destination: string;
478
+ hint?: string | undefined;
479
+ } | {
480
+ type: "hybrid";
481
+ api: {
482
+ endpoints: {
483
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
484
+ path: string;
485
+ params?: string[] | undefined;
486
+ }[];
487
+ };
488
+ nav: {
489
+ destination: string;
490
+ hint?: string | undefined;
491
+ };
492
+ };
493
+ privacy: {
494
+ level: "public" | "user_owned" | "admin";
495
+ note?: string | undefined;
496
+ };
497
+ examples?: string[] | undefined;
498
+ }[];
499
+ baseUrl?: string | undefined;
408
500
  }>;
409
501
  export declare const ManifestSchema: z.ZodObject<{
410
502
  version: z.ZodString;
@@ -62,11 +62,14 @@ const CapabilitySchema = z.object({
62
62
  // ─── Config Schema ────────────────────────────────────────────────────────────
63
63
  export const CapmanConfigSchema = z.object({
64
64
  app: z.string().min(1, 'app name is required'),
65
- baseUrl: z.string().url('baseUrl must be a valid URL').optional(),
65
+ baseUrl: z.string().url().optional(),
66
66
  capabilities: z.array(CapabilitySchema)
67
67
  .min(1, 'at least one capability is required')
68
68
  .refine(caps => new Set(caps.map(c => c.id)).size === caps.length, 'capability ids must be unique'),
69
- });
69
+ }).refine(cfg => {
70
+ const needsBaseUrl = cfg.capabilities.some(c => c.resolver.type === 'api' || c.resolver.type === 'hybrid');
71
+ return !needsBaseUrl || !!cfg.baseUrl;
72
+ }, { message: 'baseUrl is required when any capability uses an api or hybrid resolver' });
70
73
  // ─── Manifest Schema ──────────────────────────────────────────────────────────
71
74
  export const ManifestSchema = z.object({
72
75
  version: z.string(),
@@ -1 +1 @@
1
- export declare const VERSION = "0.5.2";
1
+ export declare const VERSION = "0.5.3";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/version.js — do not edit manually
2
- export const VERSION = '0.5.2';
2
+ export const VERSION = '0.5.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capman",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Capability Manifest Engine — let AI agents interact with your app without navigating the UI",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -29,15 +29,15 @@
29
29
  "CODEBASE.md"
30
30
  ],
31
31
  "scripts": {
32
- "prebuild": "node scripts/version.js",
33
- "build:cjs": "tsc --project tsconfig.json",
34
- "build:esm": "tsc --project tsconfig.esm.json",
35
- "build": "pnpm run build:cjs && pnpm run build:esm",
36
- "dev": "tsc --watch",
37
- "example": "tsx examples/basic.ts",
38
- "validate": "node bin/capman.js validate",
39
- "inspect": "node bin/capman.js inspect",
40
- "test": "vitest run"
32
+ "prebuild": "node scripts/version.js",
33
+ "build:cjs": "tsc --project tsconfig.json",
34
+ "build:esm": "tsc --project tsconfig.esm.json",
35
+ "build": "pnpm run build:cjs && pnpm run build:esm",
36
+ "dev": "tsc --watch",
37
+ "example": "tsx examples/basic.ts",
38
+ "validate": "node bin/capman.js validate",
39
+ "inspect": "node bin/capman.js inspect",
40
+ "test": "vitest run"
41
41
  },
42
42
  "keywords": [
43
43
  "ai",
@@ -56,6 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "dotenv": "^17.3.1",
59
+ "fuse.js": "^7.3.0",
59
60
  "zod": "^3.23.0"
60
61
  }
61
62
  }