@vibe-agent-toolkit/resources 0.1.39-rc.9 → 0.1.39
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/content-cache.d.ts +67 -0
- package/dist/content-cache.d.ts.map +1 -0
- package/dist/content-cache.js +160 -0
- package/dist/content-cache.js.map +1 -0
- package/dist/external-link-cache.d.ts +16 -2
- package/dist/external-link-cache.d.ts.map +1 -1
- package/dist/external-link-cache.js +43 -13
- package/dist/external-link-cache.js.map +1 -1
- package/dist/external-link-validator.d.ts +75 -2
- package/dist/external-link-validator.d.ts.map +1 -1
- package/dist/external-link-validator.js +184 -4
- package/dist/external-link-validator.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/link-auth-classify.d.ts +34 -0
- package/dist/link-auth-classify.d.ts.map +1 -0
- package/dist/link-auth-classify.js +42 -0
- package/dist/link-auth-classify.js.map +1 -0
- package/dist/link-auth-config-build.d.ts +47 -0
- package/dist/link-auth-config-build.d.ts.map +1 -0
- package/dist/link-auth-config-build.js +86 -0
- package/dist/link-auth-config-build.js.map +1 -0
- package/dist/link-auth-content-fetch.d.ts +75 -0
- package/dist/link-auth-content-fetch.d.ts.map +1 -0
- package/dist/link-auth-content-fetch.js +101 -0
- package/dist/link-auth-content-fetch.js.map +1 -0
- package/dist/link-auth-deps-memo.d.ts +40 -0
- package/dist/link-auth-deps-memo.d.ts.map +1 -0
- package/dist/link-auth-deps-memo.js +47 -0
- package/dist/link-auth-deps-memo.js.map +1 -0
- package/dist/link-auth-transport.d.ts +44 -0
- package/dist/link-auth-transport.d.ts.map +1 -0
- package/dist/link-auth-transport.js +151 -0
- package/dist/link-auth-transport.js.map +1 -0
- package/dist/link-parser.d.ts.map +1 -1
- package/dist/link-parser.js +8 -1
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +8 -8
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +51 -18
- package/dist/link-validator.js.map +1 -1
- package/dist/resource-registry.d.ts +2 -0
- package/dist/resource-registry.d.ts.map +1 -1
- package/dist/resource-registry.js +19 -4
- package/dist/resource-registry.js.map +1 -1
- package/dist/schemas/link-auth.d.ts +1049 -303
- package/dist/schemas/link-auth.d.ts.map +1 -1
- package/dist/schemas/link-auth.js +39 -20
- package/dist/schemas/link-auth.js.map +1 -1
- package/dist/schemas/project-config.d.ts +2860 -759
- package/dist/schemas/project-config.d.ts.map +1 -1
- package/dist/schemas/project-config.js +18 -1
- package/dist/schemas/project-config.js.map +1 -1
- package/dist/schemas/resource-metadata.d.ts +10 -9
- package/dist/schemas/resource-metadata.d.ts.map +1 -1
- package/dist/schemas/resource-metadata.js +2 -0
- package/dist/schemas/resource-metadata.js.map +1 -1
- package/package.json +4 -4
- package/src/content-cache.ts +190 -0
- package/src/external-link-cache.ts +50 -13
- package/src/external-link-validator.ts +268 -5
- package/src/index.ts +23 -0
- package/src/link-auth-classify.ts +61 -0
- package/src/link-auth-config-build.ts +130 -0
- package/src/link-auth-content-fetch.ts +146 -0
- package/src/link-auth-deps-memo.ts +56 -0
- package/src/link-auth-transport.ts +179 -0
- package/src/link-parser.ts +8 -1
- package/src/link-validator.ts +65 -17
- package/src/resource-registry.ts +25 -5
- package/src/schemas/link-auth.ts +47 -21
- package/src/schemas/project-config.ts +22 -1
- package/src/schemas/resource-metadata.ts +2 -0
|
@@ -1,6 +1,99 @@
|
|
|
1
|
+
import { userInfo } from 'node:os';
|
|
2
|
+
|
|
3
|
+
import type { IssueCode } from '@vibe-agent-toolkit/agent-schema';
|
|
4
|
+
import {
|
|
5
|
+
resolveAuthenticatedUrl,
|
|
6
|
+
safePath,
|
|
7
|
+
type LinkAuthConfig,
|
|
8
|
+
type ResolveOutcome,
|
|
9
|
+
} from '@vibe-agent-toolkit/utils';
|
|
1
10
|
import markdownLinkCheck from 'markdown-link-check';
|
|
2
11
|
|
|
3
12
|
import { ExternalLinkCache } from './external-link-cache.js';
|
|
13
|
+
import { classifyAuthenticatedResponse } from './link-auth-classify.js';
|
|
14
|
+
import { type LinkAuthDeps, wrapLinkAuthDepsWithMemo } from './link-auth-deps-memo.js';
|
|
15
|
+
import { authTransport } from './link-auth-transport.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the OS user for cache scoping. Falls back through several layers
|
|
19
|
+
* because `os.userInfo()` throws when the running user has no /etc/passwd
|
|
20
|
+
* entry (common in container environments) — that's recoverable, not fatal.
|
|
21
|
+
*/
|
|
22
|
+
function resolveOsUser(): string {
|
|
23
|
+
try {
|
|
24
|
+
const u = userInfo().username;
|
|
25
|
+
if (u !== '') return u;
|
|
26
|
+
} catch {
|
|
27
|
+
// userInfo() throws when the running user has no /etc/passwd entry —
|
|
28
|
+
// recover via env vars below.
|
|
29
|
+
}
|
|
30
|
+
// Use `||` (not `??`) so an empty-string USER/USERNAME also falls through.
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
32
|
+
const fromEnv = process.env['USER'] || process.env['USERNAME'];
|
|
33
|
+
if (fromEnv) return fromEnv;
|
|
34
|
+
// Last resort: two distinct OS users on the same host both end up here
|
|
35
|
+
// would share the auth cache (cross-user leak). Warn once so the
|
|
36
|
+
// collision is at least observable. Adopters whose container clears
|
|
37
|
+
// these env vars deliberately can set `USER=container-name` to scope.
|
|
38
|
+
warnDefaultUserFallbackOnce();
|
|
39
|
+
return 'default';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let defaultUserWarned = false;
|
|
43
|
+
function warnDefaultUserFallbackOnce(): void {
|
|
44
|
+
if (defaultUserWarned) return;
|
|
45
|
+
defaultUserWarned = true;
|
|
46
|
+
console.warn(
|
|
47
|
+
"[vat] linkAuth: could not determine an OS user via os.userInfo() or USER/USERNAME env; " +
|
|
48
|
+
"scoping auth cache to 'default'. Two users on this host would share auth-cached results — " +
|
|
49
|
+
'set the USER env var explicitly to scope per-user.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Make an OS username safe for use as a directory component. Replaces path
|
|
55
|
+
* separators (`/`, `\`), the parent-directory shorthand (`..`), and other
|
|
56
|
+
* characters that could escape the cacheDir. Windows can produce
|
|
57
|
+
* `DOMAIN\user` forms; tests inject pathological values like `../escaped`.
|
|
58
|
+
*/
|
|
59
|
+
function sanitizeOsUser(user: string): string {
|
|
60
|
+
// Replace any character outside `[A-Za-z0-9._-]` with `_`. Then collapse any
|
|
61
|
+
// remaining `..` so a name like `..foo` cannot recombine into a traversal.
|
|
62
|
+
const replaced = user.replaceAll(/[^A-Za-z0-9._-]/g, '_').replaceAll('..', '__');
|
|
63
|
+
return replaced.length > 0 ? replaced : 'default';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type VerifiedPlan = Extract<ResolveOutcome, { fetchUrl: string }>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a LinkValidationResult from a status code by re-classifying it under
|
|
70
|
+
* the current run's provider `check` block. Used by both the cache-hit and
|
|
71
|
+
* cache-miss paths so the two produce identical results for the same
|
|
72
|
+
* `(url, provider)` pair — the cache only persists `statusCode`, not the
|
|
73
|
+
* derived `code`, so the code must be re-derived on every read.
|
|
74
|
+
*/
|
|
75
|
+
function buildAuthResult(
|
|
76
|
+
originalUrl: string,
|
|
77
|
+
statusCode: number,
|
|
78
|
+
plan: VerifiedPlan,
|
|
79
|
+
cached: boolean,
|
|
80
|
+
cachedStatusMessage?: string,
|
|
81
|
+
): LinkValidationResult {
|
|
82
|
+
const classified = classifyAuthenticatedResponse(statusCode, plan.check);
|
|
83
|
+
const result: LinkValidationResult = {
|
|
84
|
+
url: originalUrl,
|
|
85
|
+
status: classified?.outcome === 'alive' ? 'ok' : 'error',
|
|
86
|
+
statusCode,
|
|
87
|
+
cached,
|
|
88
|
+
};
|
|
89
|
+
if (classified?.code != null) {
|
|
90
|
+
result.code = classified.code;
|
|
91
|
+
}
|
|
92
|
+
if (result.status === 'error') {
|
|
93
|
+
result.error = cachedStatusMessage ?? `HTTP ${statusCode}`;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
4
97
|
|
|
5
98
|
/**
|
|
6
99
|
* Safely serialize an error to a string, preventing [object Object] issues.
|
|
@@ -51,6 +144,38 @@ export interface ExternalLinkValidatorOptions {
|
|
|
51
144
|
retries?: number;
|
|
52
145
|
/** User agent string for requests (default: generic) */
|
|
53
146
|
userAgent?: string;
|
|
147
|
+
/**
|
|
148
|
+
* Optional linkAuth config (per issue #113). When set, URLs whose host is
|
|
149
|
+
* claimed by a provider in this config bypass the anonymous markdown-link-check
|
|
150
|
+
* path and use an authenticated direct fetch instead. URLs no provider claims
|
|
151
|
+
* continue to use markdown-link-check.
|
|
152
|
+
*/
|
|
153
|
+
linkAuthConfig?: LinkAuthConfig;
|
|
154
|
+
/**
|
|
155
|
+
* Override the `fetch` implementation used by the authenticated branch.
|
|
156
|
+
* Defaults to `globalThis.fetch`. Tests inject a stub; advanced adopters
|
|
157
|
+
* may inject a wrapper for corporate proxies, custom TLS, or telemetry.
|
|
158
|
+
*/
|
|
159
|
+
fetchImpl?: typeof fetch;
|
|
160
|
+
/**
|
|
161
|
+
* Override the linkAuth engine's token-resolution dependencies (env map +
|
|
162
|
+
* runCommand). Defaults to reading `process.env` and running real commands
|
|
163
|
+
* via `safeExecSync`. Tests inject a deterministic env; advanced adopters
|
|
164
|
+
* usually leave unset.
|
|
165
|
+
*/
|
|
166
|
+
linkAuthDeps?: LinkAuthDeps;
|
|
167
|
+
/**
|
|
168
|
+
* Override the sleep used between 429 retries. Defaults to `setTimeout`.
|
|
169
|
+
* Test-only — production benefits from real wall-clock delay so Retry-After
|
|
170
|
+
* hints are honored.
|
|
171
|
+
*/
|
|
172
|
+
sleep?: (ms: number) => Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* OS user to scope the authenticated cache by (#113 §6.3). Defaults to
|
|
175
|
+
* `os.userInfo().username` with env-var fallbacks. Tests inject a fixed
|
|
176
|
+
* value; production callers omit.
|
|
177
|
+
*/
|
|
178
|
+
osUser?: string;
|
|
54
179
|
}
|
|
55
180
|
|
|
56
181
|
/**
|
|
@@ -67,6 +192,21 @@ export interface LinkValidationResult {
|
|
|
67
192
|
error?: string;
|
|
68
193
|
/** Whether result came from cache */
|
|
69
194
|
cached: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* Set when the authenticated branch classified the response (issue #113 §7).
|
|
197
|
+
* Consumers use this directly instead of mapping `statusCode` to a code, so
|
|
198
|
+
* `notFoundMeaning`-dependent routing (404 → `LINK_AUTH_DEAD` vs
|
|
199
|
+
* `LINK_AUTH_DEAD_OR_UNAUTHORIZED`) reflects the matched provider's config.
|
|
200
|
+
*
|
|
201
|
+
* Invariant: `code` is set iff the URL hit the authenticated branch AND
|
|
202
|
+
* the outcome maps to a `LINK_AUTH_*` code per §7 (`unverified`, or any
|
|
203
|
+
* classified status: `unauthorized`, `forbidden`, `dead`,
|
|
204
|
+
* `dead_or_unauthorized`). Unset on the anonymous markdown-link-check path,
|
|
205
|
+
* on `unsupported` (no provider claimed the host), and on classifier-`null`
|
|
206
|
+
* statuses (5xx, unclassified) where the consumer's status-code mapping
|
|
207
|
+
* (`EXTERNAL_URL_*`) is the correct fallback.
|
|
208
|
+
*/
|
|
209
|
+
code?: IssueCode;
|
|
70
210
|
}
|
|
71
211
|
|
|
72
212
|
/**
|
|
@@ -90,7 +230,23 @@ export interface LinkValidationResult {
|
|
|
90
230
|
*/
|
|
91
231
|
export class ExternalLinkValidator {
|
|
92
232
|
private readonly cache: ExternalLinkCache;
|
|
93
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Auth-branch cache — scoped to a per-OS-user subdirectory of `cacheDir`
|
|
235
|
+
* (#113 §6.3) so two users on a shared machine never read each other's
|
|
236
|
+
* authenticated results. Distinct from `cache` (the anonymous cache, which
|
|
237
|
+
* stays shared across users for the markdown-link-check path).
|
|
238
|
+
*/
|
|
239
|
+
private readonly authCache: ExternalLinkCache;
|
|
240
|
+
private readonly options: {
|
|
241
|
+
cacheTtlHours: number;
|
|
242
|
+
timeout: number;
|
|
243
|
+
retries: number;
|
|
244
|
+
userAgent: string;
|
|
245
|
+
};
|
|
246
|
+
private readonly linkAuthConfig: LinkAuthConfig | undefined;
|
|
247
|
+
private readonly fetchImpl: typeof fetch;
|
|
248
|
+
private readonly linkAuthDeps: LinkAuthDeps;
|
|
249
|
+
private readonly sleep: ((ms: number) => Promise<void>) | undefined;
|
|
94
250
|
|
|
95
251
|
/**
|
|
96
252
|
* Create a new external link validator
|
|
@@ -108,7 +264,22 @@ export class ExternalLinkValidator {
|
|
|
108
264
|
'Mozilla/5.0 (compatible; VAT-LinkChecker/1.0; +https://github.com/jdutton/vibe-agent-toolkit)',
|
|
109
265
|
};
|
|
110
266
|
|
|
267
|
+
this.linkAuthConfig = options.linkAuthConfig;
|
|
268
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
269
|
+
// Wrap the configured runCommand (or the engine's default) with a memo
|
|
270
|
+
// keyed by stringified argv so each unique token-resolution command runs
|
|
271
|
+
// at most once per validator instance. Validating N links to the same
|
|
272
|
+
// host previously re-resolved the token N times — including subprocess
|
|
273
|
+
// spawns for `command` sources. Per #125 review: treat all token
|
|
274
|
+
// resolvers as expensive; the memo's lifetime equals one validate() run.
|
|
275
|
+
this.linkAuthDeps = wrapLinkAuthDepsWithMemo(options.linkAuthDeps);
|
|
276
|
+
this.sleep = options.sleep;
|
|
277
|
+
|
|
111
278
|
this.cache = new ExternalLinkCache(cacheDir, this.options.cacheTtlHours);
|
|
279
|
+
|
|
280
|
+
const osUser = sanitizeOsUser(options.osUser ?? resolveOsUser());
|
|
281
|
+
const authCacheDir = safePath.join(cacheDir, `auth-${osUser}`);
|
|
282
|
+
this.authCache = new ExternalLinkCache(authCacheDir, this.options.cacheTtlHours);
|
|
112
283
|
}
|
|
113
284
|
|
|
114
285
|
/**
|
|
@@ -118,6 +289,29 @@ export class ExternalLinkValidator {
|
|
|
118
289
|
* @returns Validation result
|
|
119
290
|
*/
|
|
120
291
|
async validateLink(url: string): Promise<LinkValidationResult> {
|
|
292
|
+
// Authenticated branch (issue #113): if a provider in linkAuthConfig
|
|
293
|
+
// claims this URL's host, bypass markdown-link-check and do a direct
|
|
294
|
+
// authenticated fetch + per-§7 classify.
|
|
295
|
+
if (this.linkAuthConfig) {
|
|
296
|
+
const plan = resolveAuthenticatedUrl(url, this.linkAuthConfig, this.linkAuthDeps);
|
|
297
|
+
if ('fetchUrl' in plan) {
|
|
298
|
+
return this.validateAuthenticatedLink(url, plan);
|
|
299
|
+
}
|
|
300
|
+
if (plan.outcome === 'unverified') {
|
|
301
|
+
// Per §6.3: never cache unverified outcomes — the result flips the
|
|
302
|
+
// moment a token appears, so caching a "no token" result is wrong.
|
|
303
|
+
return {
|
|
304
|
+
url,
|
|
305
|
+
status: 'error',
|
|
306
|
+
statusCode: 0,
|
|
307
|
+
error: plan.reason,
|
|
308
|
+
cached: false,
|
|
309
|
+
code: 'LINK_AUTH_UNVERIFIED',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// 'unsupported' → fall through to anonymous markdown-link-check path
|
|
313
|
+
}
|
|
314
|
+
|
|
121
315
|
// Check cache first
|
|
122
316
|
const cached = await this.cache.get(url);
|
|
123
317
|
if (cached) {
|
|
@@ -165,6 +359,70 @@ export class ExternalLinkValidator {
|
|
|
165
359
|
return Promise.all(urls.map((url) => this.validateLink(url)));
|
|
166
360
|
}
|
|
167
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Issue an authenticated fetch for `originalUrl` using the engine's plan
|
|
364
|
+
* (rewritten URL + auth headers + provider's check config), classify the
|
|
365
|
+
* response per #113 §7, and write a status-cache entry. Cache is keyed by
|
|
366
|
+
* the *rewritten* URL (§6.3) — the original `blob/` URL 404s, so caching
|
|
367
|
+
* by original would poison results.
|
|
368
|
+
*/
|
|
369
|
+
private async validateAuthenticatedLink(
|
|
370
|
+
originalUrl: string,
|
|
371
|
+
plan: VerifiedPlan,
|
|
372
|
+
): Promise<LinkValidationResult> {
|
|
373
|
+
// Cache check, keyed by rewritten URL. IMPORTANT: re-classify the cached
|
|
374
|
+
// statusCode under the *current* run's provider `check` block, rather
|
|
375
|
+
// than treating cache-hit as a status-only short-circuit. The cache
|
|
376
|
+
// only persists `statusCode` — without re-classifying we'd drop the
|
|
377
|
+
// LINK_AUTH_* `code`, and the consumer would fall back to
|
|
378
|
+
// `EXTERNAL_URL_DEAD` (error) instead of `LINK_AUTH_DEAD_OR_UNAUTHORIZED`
|
|
379
|
+
// (warning). Cache-hit semantics must match cache-miss semantics for
|
|
380
|
+
// the same (url, provider) pair.
|
|
381
|
+
const cached = await this.authCache.get(plan.fetchUrl);
|
|
382
|
+
if (cached) {
|
|
383
|
+
return buildAuthResult(originalUrl, cached.statusCode, plan, true, cached.statusMessage);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Fresh fetch via the auth-aware transport (handles cross-origin redirect
|
|
387
|
+
// Authorization stripping + 429/Retry-After per §5.2 §8). The conditional
|
|
388
|
+
// spread builds the object in one pass — AuthTransportOptions.sleep is
|
|
389
|
+
// readonly so post-construction assignment would be a TS error.
|
|
390
|
+
const transportOptions: Parameters<typeof authTransport>[3] = {
|
|
391
|
+
signal: AbortSignal.timeout(this.options.timeout),
|
|
392
|
+
...(this.sleep === undefined ? {} : { sleep: this.sleep }),
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
let response: Response;
|
|
396
|
+
try {
|
|
397
|
+
response = await authTransport(
|
|
398
|
+
plan.fetchUrl,
|
|
399
|
+
plan.headers,
|
|
400
|
+
this.fetchImpl,
|
|
401
|
+
transportOptions,
|
|
402
|
+
);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
// Network-level failure (DNS/connect/TLS/timeout) — return error with
|
|
405
|
+
// no code so the consumer's existing statusCode→IssueCode mapping
|
|
406
|
+
// (EXTERNAL_URL_ERROR / EXTERNAL_URL_TIMEOUT) applies.
|
|
407
|
+
const message = safeSerializeError(err) ?? 'Authenticated fetch failed';
|
|
408
|
+
return {
|
|
409
|
+
url: originalUrl,
|
|
410
|
+
status: 'error',
|
|
411
|
+
statusCode: 0,
|
|
412
|
+
error: message,
|
|
413
|
+
cached: false,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const result = buildAuthResult(originalUrl, response.status, plan, false);
|
|
418
|
+
// Persist by rewritten URL so subsequent runs hit the cache. The cache
|
|
419
|
+
// only stores statusCode + statusMessage; the `code` is re-derived on
|
|
420
|
+
// read by re-running classifyAuthenticatedResponse against the current
|
|
421
|
+
// provider (see the authCache.get branch above).
|
|
422
|
+
await this.authCache.set(plan.fetchUrl, result.statusCode, result.error ?? 'OK');
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
|
|
168
426
|
/**
|
|
169
427
|
* Check a link using markdown-link-check
|
|
170
428
|
*/
|
|
@@ -235,16 +493,21 @@ export class ExternalLinkValidator {
|
|
|
235
493
|
}
|
|
236
494
|
|
|
237
495
|
/**
|
|
238
|
-
* Clear
|
|
496
|
+
* Clear both validation caches (anonymous + authenticated).
|
|
497
|
+
*
|
|
498
|
+
* Adopters call this after rotating a token or invalidating link data —
|
|
499
|
+
* the operation must wipe both surfaces or stale auth results survive
|
|
500
|
+
* (a real bug: a 401 cached under the old token would haunt the new one).
|
|
239
501
|
*/
|
|
240
502
|
async clearCache(): Promise<void> {
|
|
241
|
-
await this.cache.clear();
|
|
503
|
+
await Promise.all([this.cache.clear(), this.authCache.clear()]);
|
|
242
504
|
}
|
|
243
505
|
|
|
244
506
|
/**
|
|
245
|
-
* Get cache statistics
|
|
507
|
+
* Get combined cache statistics across both caches.
|
|
246
508
|
*/
|
|
247
509
|
async getCacheStats(): Promise<{ total: number; expired: number }> {
|
|
248
|
-
|
|
510
|
+
const [anon, auth] = await Promise.all([this.cache.getStats(), this.authCache.getStats()]);
|
|
511
|
+
return { total: anon.total + auth.total, expired: anon.expired + auth.expired };
|
|
249
512
|
}
|
|
250
513
|
}
|
package/src/index.ts
CHANGED
|
@@ -144,3 +144,26 @@ export {
|
|
|
144
144
|
type SkillSourceDescriptor,
|
|
145
145
|
type TestConfig,
|
|
146
146
|
} from './schemas/project-config.js';
|
|
147
|
+
|
|
148
|
+
// linkAuth content-fetch primitive (issue #113 slice 3). Ships standalone;
|
|
149
|
+
// callers wire it into asset-reference / bundling consumers as they need.
|
|
150
|
+
export {
|
|
151
|
+
fetchAuthenticated,
|
|
152
|
+
type ContentFetchResult,
|
|
153
|
+
type FetchAuthenticatedOptions,
|
|
154
|
+
} from './link-auth-content-fetch.js';
|
|
155
|
+
|
|
156
|
+
export { ContentCache, type ContentMetadata } from './content-cache.js';
|
|
157
|
+
|
|
158
|
+
// Token-resolution memo wrapper. High-volume callers iterating many URLs
|
|
159
|
+
// from the same provider wrap their deps once and reuse the result, so
|
|
160
|
+
// expensive token sources (`gh auth token` etc.) run at most once.
|
|
161
|
+
export {
|
|
162
|
+
wrapLinkAuthDepsWithMemo,
|
|
163
|
+
type LinkAuthDeps,
|
|
164
|
+
} from './link-auth-deps-memo.js';
|
|
165
|
+
|
|
166
|
+
// Bridge from adopter `resources.linkAuth` (Zod-validated) to the engine's
|
|
167
|
+
// `LinkAuthConfig` (fully-expanded providers). Adopters carrying a parsed
|
|
168
|
+
// config can hand it directly to `fetchAuthenticated` via this bridge.
|
|
169
|
+
export { buildLinkAuthEngineConfig } from './link-auth-config-build.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a response from an authenticated external-link fetch into one of
|
|
3
|
+
* the outcomes defined in design issue #113 §7.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: takes the HTTP status from a completed fetch plus the
|
|
6
|
+
* matched provider's `check` block, returns the outcome name and the matching
|
|
7
|
+
* `LINK_AUTH_*` code from `CODE_REGISTRY`. Pre-fetch outcomes (`unsupported`,
|
|
8
|
+
* `unverified`) are produced upstream by `resolveAuthenticatedUrl` and never
|
|
9
|
+
* reach this classifier.
|
|
10
|
+
*
|
|
11
|
+
* Status codes that don't match any classified outcome return `null` — the
|
|
12
|
+
* caller falls through to the existing anonymous `EXTERNAL_URL_DEAD` path
|
|
13
|
+
* (per §7 "unsupported"), since the authenticated fetch produced no signal
|
|
14
|
+
* the per-provider table can speak to.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ProviderCheck } from '@vibe-agent-toolkit/utils';
|
|
18
|
+
|
|
19
|
+
export type LinkAuthOutcome =
|
|
20
|
+
| { readonly outcome: 'alive'; readonly code: null }
|
|
21
|
+
| { readonly outcome: 'dead'; readonly code: 'LINK_AUTH_DEAD' }
|
|
22
|
+
| {
|
|
23
|
+
readonly outcome: 'dead_or_unauthorized';
|
|
24
|
+
readonly code: 'LINK_AUTH_DEAD_OR_UNAUTHORIZED';
|
|
25
|
+
}
|
|
26
|
+
| { readonly outcome: 'forbidden'; readonly code: 'LINK_AUTH_FORBIDDEN' }
|
|
27
|
+
| { readonly outcome: 'unauthorized'; readonly code: 'LINK_AUTH_UNAUTHORIZED' };
|
|
28
|
+
|
|
29
|
+
export function classifyAuthenticatedResponse(
|
|
30
|
+
status: number,
|
|
31
|
+
check: ProviderCheck,
|
|
32
|
+
): LinkAuthOutcome | null {
|
|
33
|
+
// aliveStatus wins first: a provider can in principle declare any code as
|
|
34
|
+
// alive (e.g. a future host where 204 means alive), so the membership test
|
|
35
|
+
// runs before the well-known-status branches.
|
|
36
|
+
if (check.aliveStatus.includes(status)) {
|
|
37
|
+
return { outcome: 'alive', code: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (status === 401) {
|
|
41
|
+
return { outcome: 'unauthorized', code: 'LINK_AUTH_UNAUTHORIZED' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (status === 403) {
|
|
45
|
+
return { outcome: 'forbidden', code: 'LINK_AUTH_FORBIDDEN' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (status === 404 || status === 410) {
|
|
49
|
+
if (check.notFoundMeaning === 'dead') {
|
|
50
|
+
return { outcome: 'dead', code: 'LINK_AUTH_DEAD' };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
outcome: 'dead_or_unauthorized',
|
|
54
|
+
code: 'LINK_AUTH_DEAD_OR_UNAUTHORIZED',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2xx not in aliveStatus, 3xx redirects, 429 rate-limit, 5xx server errors
|
|
59
|
+
// — none of these are classified per §7. Caller decides what to do.
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the adopter-facing linkAuth config (Zod-validated, with
|
|
3
|
+
* macro references) and the engine-facing config (fully-expanded providers).
|
|
4
|
+
*
|
|
5
|
+
* Adopter config providers can be either:
|
|
6
|
+
* - `{ use: <macro>, ...overrides }` — reference a shipped macro; deep-merge
|
|
7
|
+
* `overrides` on top of the macro's defaults
|
|
8
|
+
* - A full inline `{ match, rewrite, auth, token, check }` — used as-is
|
|
9
|
+
*
|
|
10
|
+
* This function walks each provider entry, runs macro expansion when needed,
|
|
11
|
+
* and re-validates each fully-expanded provider against `InlineProviderSchema`.
|
|
12
|
+
* Since the schemas are passthrough (per the repo Postel's Law rule for
|
|
13
|
+
* adopter input), unknown extra keys survive expansion silently — the engine
|
|
14
|
+
* ignores them at runtime, and a separate lint/warn pass is the right home
|
|
15
|
+
* for typo-catching DX. Post-expansion validation still catches the cases
|
|
16
|
+
* passthrough doesn't relax: missing required fields, and wrong types on
|
|
17
|
+
* declared fields (e.g. `notFoundMeaning: 'totally-invalid'`).
|
|
18
|
+
*
|
|
19
|
+
* Per design issue #113 §5 (macros are config, not a privileged code path)
|
|
20
|
+
* and §4 (engine vocabulary).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
expandMacro,
|
|
25
|
+
type LinkAuthConfig,
|
|
26
|
+
type Provider,
|
|
27
|
+
} from '@vibe-agent-toolkit/utils';
|
|
28
|
+
|
|
29
|
+
import { InlineProviderSchema, type LinkAuthProjectConfig } from './schemas/link-auth.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compile-time drift defense: top-level field sets must match between the
|
|
33
|
+
* Zod schema and the engine's `Provider` interface. A strict structural
|
|
34
|
+
* check would also catch sub-type drift, but TS variance (readonly arrays in
|
|
35
|
+
* engine vs mutable in Zod inference) makes that noisy — the realistic
|
|
36
|
+
* drift mode is adding/renaming a top-level field on one side and forgetting
|
|
37
|
+
* the other, which this top-level key comparison catches at `tsc` time.
|
|
38
|
+
*
|
|
39
|
+
* The schema and engine type are kept in sync by review (per slice 1 design
|
|
40
|
+
* decision); this guard makes that review easier.
|
|
41
|
+
*/
|
|
42
|
+
// Use `.shape` (Zod's declared-key record) rather than `keyof z.infer<...>` —
|
|
43
|
+
// passthrough() injects `string | number` into the inferred key set, which
|
|
44
|
+
// defeats the drift comparison. `.shape` is unaffected: it's the set of
|
|
45
|
+
// declared fields, exactly what we want to compare against the engine type.
|
|
46
|
+
type _SchemaKeys = keyof typeof InlineProviderSchema.shape;
|
|
47
|
+
type _EngineKeys = keyof Provider;
|
|
48
|
+
type _KeysAgree = [_SchemaKeys] extends [_EngineKeys]
|
|
49
|
+
? [_EngineKeys] extends [_SchemaKeys]
|
|
50
|
+
? true
|
|
51
|
+
: { error: 'engine Provider has a field that InlineProviderSchema lacks'; missing: Exclude<_EngineKeys, _SchemaKeys> }
|
|
52
|
+
: { error: 'InlineProviderSchema has a field that engine Provider lacks'; missing: Exclude<_SchemaKeys, _EngineKeys> };
|
|
53
|
+
// Type-level assert: the declaration must compile to `true` (i.e. both
|
|
54
|
+
// directions of the key-set check pass). The exported function holds a
|
|
55
|
+
// reference to the type so noUnusedLocals doesn't fire.
|
|
56
|
+
export const _assertSchemaKeysAgreeWithEngine: _KeysAgree = true;
|
|
57
|
+
|
|
58
|
+
export function buildLinkAuthEngineConfig(adopter: LinkAuthProjectConfig): LinkAuthConfig {
|
|
59
|
+
const providers: Provider[] = adopter.providers.map((entry, index) =>
|
|
60
|
+
expandProviderEntry(entry, index),
|
|
61
|
+
);
|
|
62
|
+
// Pass the cache block through to the engine config so the slice-3
|
|
63
|
+
// content-fetch primitive can read `ttlMinutes` from the same source of
|
|
64
|
+
// truth as the rest of the engine config. The engine itself doesn't consume
|
|
65
|
+
// this field — it stays stateless — but riding along on the config keeps
|
|
66
|
+
// callers from having to pass two objects to the primitive.
|
|
67
|
+
if (adopter.cache !== undefined) {
|
|
68
|
+
return { providers, cache: buildEngineCache(adopter.cache) };
|
|
69
|
+
}
|
|
70
|
+
return { providers };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildEngineCache(
|
|
74
|
+
adopterCache: NonNullable<LinkAuthProjectConfig['cache']>,
|
|
75
|
+
): NonNullable<LinkAuthConfig['cache']> {
|
|
76
|
+
// Schema is passthrough so adopterCache may carry forward-compatible extras
|
|
77
|
+
// we don't know about — copy only the fields the engine type declares.
|
|
78
|
+
return adopterCache.ttlMinutes === undefined ? {} : { ttlMinutes: adopterCache.ttlMinutes };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function expandProviderEntry(entry: unknown, index: number): Provider {
|
|
82
|
+
// Discriminate via Object.hasOwn (not `'use' in entry`) so a prototype-
|
|
83
|
+
// injected `use` cannot reroute an inline entry into macro expansion. The
|
|
84
|
+
// Zod parser already produces plain JSON-shaped objects, but defending in
|
|
85
|
+
// depth costs nothing.
|
|
86
|
+
const isMacroRef =
|
|
87
|
+
typeof entry === 'object' &&
|
|
88
|
+
entry !== null &&
|
|
89
|
+
Object.hasOwn(entry, 'use');
|
|
90
|
+
|
|
91
|
+
if (!isMacroRef) {
|
|
92
|
+
return entry as Provider;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { use, overrides } = splitMacroEntry(entry as Record<string, unknown>);
|
|
96
|
+
const expanded = expandMacro(use, overrides);
|
|
97
|
+
|
|
98
|
+
// Re-validate the fully-expanded shape. Unknown extra keys pass through
|
|
99
|
+
// (the engine ignores them); what we still catch are missing required
|
|
100
|
+
// fields and wrong types on declared fields — e.g. a macro override that
|
|
101
|
+
// sets `check.notFoundMeaning: 'bogus'` is rejected here even though the
|
|
102
|
+
// upstream macro schema's passthrough accepted it.
|
|
103
|
+
const parsed = InlineProviderSchema.safeParse(expanded);
|
|
104
|
+
if (!parsed.success) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`linkAuth providers[${index}] (use: ${JSON.stringify(use)}) ` +
|
|
107
|
+
`produced an invalid provider after macro expansion: ${parsed.error.message}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return parsed.data as Provider;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function splitMacroEntry(entry: Record<string, unknown>): {
|
|
114
|
+
use: string;
|
|
115
|
+
overrides: Record<string, unknown>;
|
|
116
|
+
} {
|
|
117
|
+
// `use` value comes from a Zod-validated source so the type is string at
|
|
118
|
+
// this point — but defensively-check it anyway: `Object.hasOwn` upstream
|
|
119
|
+
// tells us the key exists, not what its value's type is.
|
|
120
|
+
const useValue = entry['use'];
|
|
121
|
+
if (typeof useValue !== 'string') {
|
|
122
|
+
throw new TypeError(`linkAuth provider \`use\` must be a string, got ${typeof useValue}`);
|
|
123
|
+
}
|
|
124
|
+
const overrides: Record<string, unknown> = {};
|
|
125
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
126
|
+
if (key === 'use') continue;
|
|
127
|
+
overrides[key] = value;
|
|
128
|
+
}
|
|
129
|
+
return { use: useValue, overrides };
|
|
130
|
+
}
|