@apitap/core 1.0.17 → 1.0.19

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.
@@ -6,6 +6,8 @@ import { parseJwtClaims } from '../capture/entropy.js';
6
6
  import { refreshTokens } from '../auth/refresh.js';
7
7
  import { truncateResponse } from './truncate.js';
8
8
  import { resolveAndValidateUrl } from '../skill/ssrf.js';
9
+ import { snapshotSchema } from '../contract/schema.js';
10
+ import { diffSchema, type ContractWarning } from '../contract/diff.js';
9
11
 
10
12
  // Header security: block dangerous headers from skill files (blocklist approach).
11
13
  // All other headers — including custom API headers like Client-ID — pass through.
@@ -66,6 +68,8 @@ export interface ReplayResult {
66
68
  refreshed?: boolean;
67
69
  /** Whether the response was truncated to fit maxBytes */
68
70
  truncated?: boolean;
71
+ /** Contract warnings from schema drift detection */
72
+ contractWarnings?: ContractWarning[];
69
73
  }
70
74
 
71
75
  /**
@@ -228,7 +232,9 @@ export async function replayEndpoint(
228
232
 
229
233
  // Inject auth header from auth manager (if available)
230
234
  if (authManager && domain) {
231
- const auth = await authManager.retrieve(domain);
235
+ const auth = endpoint.isolatedAuth
236
+ ? await authManager.retrieve(domain)
237
+ : await authManager.retrieveWithFallback(domain);
232
238
  if (auth && auth.header && auth.value) {
233
239
  headers[auth.header] = auth.value;
234
240
  }
@@ -282,15 +288,36 @@ export async function replayEndpoint(
282
288
  if (refreshResult.success) {
283
289
  refreshed = true;
284
290
  // Re-inject fresh auth header
285
- const freshAuth = await authManager.retrieve(domain);
291
+ const freshAuth = endpoint.isolatedAuth
292
+ ? await authManager.retrieve(domain)
293
+ : await authManager.retrieveWithFallback(domain);
286
294
  if (freshAuth) {
287
295
  headers[freshAuth.header] = freshAuth.value;
288
296
  }
289
297
  }
290
298
  } else {
291
- // Proactive: check if JWT is expired (30s buffer for clock skew)
292
- const currentAuth = await authManager.retrieve(domain);
293
- if (currentAuth?.value) {
299
+ // Proactive expiry check (30s buffer for clock skew)
300
+ const currentAuth = endpoint.isolatedAuth
301
+ ? await authManager.retrieve(domain)
302
+ : await authManager.retrieveWithFallback(domain);
303
+
304
+ if (currentAuth?.expiresAt) {
305
+ // Check 1: expiresAt from OAuth/stored TTL (handles opaque tokens)
306
+ const expiresAtMs = new Date(currentAuth.expiresAt).getTime();
307
+ if (expiresAtMs < Date.now() + 30_000) {
308
+ const refreshResult = await refreshTokens(skill, authManager, { domain, _skipSsrfCheck: options._skipSsrfCheck });
309
+ if (refreshResult.success) {
310
+ refreshed = true;
311
+ const freshAuth = endpoint.isolatedAuth
312
+ ? await authManager.retrieve(domain)
313
+ : await authManager.retrieveWithFallback(domain);
314
+ if (freshAuth) {
315
+ headers[freshAuth.header] = freshAuth.value;
316
+ }
317
+ }
318
+ }
319
+ } else if (currentAuth?.value) {
320
+ // Check 2: JWT exp claim (existing logic)
294
321
  const raw = currentAuth.value.startsWith('Bearer ')
295
322
  ? currentAuth.value.slice(7)
296
323
  : currentAuth.value;
@@ -299,7 +326,9 @@ export async function replayEndpoint(
299
326
  const refreshResult = await refreshTokens(skill, authManager, { domain, _skipSsrfCheck: options._skipSsrfCheck });
300
327
  if (refreshResult.success) {
301
328
  refreshed = true;
302
- const freshAuth = await authManager.retrieve(domain);
329
+ const freshAuth = endpoint.isolatedAuth
330
+ ? await authManager.retrieve(domain)
331
+ : await authManager.retrieveWithFallback(domain);
303
332
  if (freshAuth) {
304
333
  headers[freshAuth.header] = freshAuth.value;
305
334
  }
@@ -350,7 +379,9 @@ export async function replayEndpoint(
350
379
  if (refreshResult.success) {
351
380
  refreshed = true;
352
381
  // Re-inject fresh auth
353
- const freshAuth = await authManager.retrieve(domain);
382
+ const freshAuth = endpoint.isolatedAuth
383
+ ? await authManager.retrieve(domain)
384
+ : await authManager.retrieveWithFallback(domain);
354
385
  if (freshAuth) {
355
386
  headers[freshAuth.header] = freshAuth.value;
356
387
  }
@@ -441,6 +472,16 @@ export async function replayEndpoint(
441
472
  ? wrapAuthError(response.status, data, skill.domain)
442
473
  : data;
443
474
 
475
+ // Contract validation: diff response schema against captured baseline
476
+ let contractWarnings: ContractWarning[] | undefined;
477
+ if (endpoint.responseSchema && typeof data === 'object' && data !== null) {
478
+ const actualSchema = snapshotSchema(data);
479
+ const warnings = diffSchema(endpoint.responseSchema, actualSchema);
480
+ if (warnings.length > 0) {
481
+ contractWarnings = warnings;
482
+ }
483
+ }
484
+
444
485
  // Apply truncation if maxBytes is set
445
486
  if (options.maxBytes) {
446
487
  const truncated = truncateResponse(finalData, { maxBytes: options.maxBytes });
@@ -450,10 +491,11 @@ export async function replayEndpoint(
450
491
  data: truncated.data,
451
492
  ...(refreshed ? { refreshed } : {}),
452
493
  ...(truncated.truncated ? { truncated: true } : {}),
494
+ ...(contractWarnings ? { contractWarnings } : {}),
453
495
  };
454
496
  }
455
497
 
456
- return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}) };
498
+ return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}) };
457
499
  }
458
500
 
459
501
  // --- Batch replay ---
@@ -9,6 +9,7 @@ import { detectRefreshableTokens } from '../capture/token-detector.js';
9
9
  import { isLikelyToken } from '../capture/entropy.js';
10
10
  import { isOAuthTokenRequest, type OAuthInfo } from '../capture/oauth-detector.js';
11
11
  import { diffBodies } from '../capture/body-diff.js';
12
+ import { snapshotSchema } from '../contract/schema.js';
12
13
 
13
14
  /** Headers to strip (connection control, forwarding, browser-internal, encoding) */
14
15
  const STRIP_HEADERS = new Set([
@@ -395,6 +396,12 @@ export class SkillGenerator {
395
396
  // Store response bytes on endpoint
396
397
  endpoint.responseBytes = exchange.response.body.length;
397
398
 
399
+ // Snapshot response schema for contract validation
400
+ try {
401
+ const parsed = JSON.parse(exchange.response.body);
402
+ endpoint.responseSchema = snapshotSchema(parsed);
403
+ } catch { /* non-JSON response, skip schema */ }
404
+
398
405
  this.endpoints.set(key, endpoint);
399
406
 
400
407
  // Store first body for cross-request diffing
package/src/types.ts CHANGED
@@ -29,6 +29,8 @@ export interface StoredAuth {
29
29
  // v0.9: OAuth credentials (stored encrypted, never in skill file)
30
30
  refreshToken?: string;
31
31
  clientSecret?: string;
32
+ // v1.1: computed expiry timestamp
33
+ expiresAt?: string;
32
34
  }
33
35
 
34
36
  /**
@@ -101,6 +103,14 @@ export interface RequestBody {
101
103
  refreshableTokens?: string[]; // v0.8: system-refreshed tokens
102
104
  }
103
105
 
106
+ /** Schema node for contract validation (recursive type tree) */
107
+ export interface SchemaNode {
108
+ type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
109
+ fields?: Record<string, SchemaNode>;
110
+ items?: SchemaNode;
111
+ nullable?: boolean;
112
+ }
113
+
104
114
  /** A single API endpoint in a skill file */
105
115
  export interface SkillEndpoint {
106
116
  id: string;
@@ -117,6 +127,8 @@ export interface SkillEndpoint {
117
127
  pagination?: PaginationInfo;
118
128
  requestBody?: RequestBody;
119
129
  responseBytes?: number; // v1.0: response body size in bytes
130
+ isolatedAuth?: boolean; // v1.1: opt out of cross-subdomain auth fallback
131
+ responseSchema?: SchemaNode; // v1.1: schema snapshot for contract validation
120
132
  }
121
133
 
122
134
  /** The full skill file written to disk */