@vibe-agent-toolkit/resources 0.1.39-rc.13 → 0.1.39-rc.14

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 (57) hide show
  1. package/dist/content-cache.d.ts +67 -0
  2. package/dist/content-cache.d.ts.map +1 -0
  3. package/dist/content-cache.js +160 -0
  4. package/dist/content-cache.js.map +1 -0
  5. package/dist/external-link-cache.d.ts +16 -2
  6. package/dist/external-link-cache.d.ts.map +1 -1
  7. package/dist/external-link-cache.js +43 -13
  8. package/dist/external-link-cache.js.map +1 -1
  9. package/dist/external-link-validator.d.ts +75 -2
  10. package/dist/external-link-validator.d.ts.map +1 -1
  11. package/dist/external-link-validator.js +184 -4
  12. package/dist/external-link-validator.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/link-auth-classify.d.ts +34 -0
  18. package/dist/link-auth-classify.d.ts.map +1 -0
  19. package/dist/link-auth-classify.js +42 -0
  20. package/dist/link-auth-classify.js.map +1 -0
  21. package/dist/link-auth-config-build.d.ts +47 -0
  22. package/dist/link-auth-config-build.d.ts.map +1 -0
  23. package/dist/link-auth-config-build.js +86 -0
  24. package/dist/link-auth-config-build.js.map +1 -0
  25. package/dist/link-auth-content-fetch.d.ts +75 -0
  26. package/dist/link-auth-content-fetch.d.ts.map +1 -0
  27. package/dist/link-auth-content-fetch.js +101 -0
  28. package/dist/link-auth-content-fetch.js.map +1 -0
  29. package/dist/link-auth-deps-memo.d.ts +40 -0
  30. package/dist/link-auth-deps-memo.d.ts.map +1 -0
  31. package/dist/link-auth-deps-memo.js +47 -0
  32. package/dist/link-auth-deps-memo.js.map +1 -0
  33. package/dist/link-auth-transport.d.ts +44 -0
  34. package/dist/link-auth-transport.d.ts.map +1 -0
  35. package/dist/link-auth-transport.js +151 -0
  36. package/dist/link-auth-transport.js.map +1 -0
  37. package/dist/resource-registry.d.ts.map +1 -1
  38. package/dist/resource-registry.js +15 -1
  39. package/dist/resource-registry.js.map +1 -1
  40. package/dist/schemas/link-auth.d.ts +1049 -303
  41. package/dist/schemas/link-auth.d.ts.map +1 -1
  42. package/dist/schemas/link-auth.js +39 -20
  43. package/dist/schemas/link-auth.js.map +1 -1
  44. package/dist/schemas/project-config.d.ts +2697 -749
  45. package/dist/schemas/project-config.d.ts.map +1 -1
  46. package/package.json +4 -4
  47. package/src/content-cache.ts +190 -0
  48. package/src/external-link-cache.ts +50 -13
  49. package/src/external-link-validator.ts +268 -5
  50. package/src/index.ts +23 -0
  51. package/src/link-auth-classify.ts +61 -0
  52. package/src/link-auth-config-build.ts +130 -0
  53. package/src/link-auth-content-fetch.ts +146 -0
  54. package/src/link-auth-deps-memo.ts +56 -0
  55. package/src/link-auth-transport.ts +179 -0
  56. package/src/resource-registry.ts +17 -2
  57. package/src/schemas/link-auth.ts +47 -21
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Public content-fetch primitive for the linkAuth feature (design issue #113 §6.2).
3
+ *
4
+ * Ships as a standalone primitive — no consumer wiring (asset-references,
5
+ * bundling) lands in this slice. The shape and security disciplines are
6
+ * designed so future callers can adopt it without reworking the contract.
7
+ *
8
+ * Responsibility split:
9
+ * - `resolveAuthenticatedUrl` (engine): pick provider, rewrite URL, resolve
10
+ * token, expand auth.headers AND fetch.headers against the same context.
11
+ * - `ContentCache`: persist `(bytes, metadata)` keyed by rewritten URL with
12
+ * 30-min default TTL; whitelists fields so tokens cannot be persisted.
13
+ * - `authTransport`: cross-origin auth strip, 429/Retry-After handling.
14
+ * - **This primitive**: wire them together, decide cache vs fetch, build
15
+ * metadata, return a typed result.
16
+ *
17
+ * Behavior matrix:
18
+ * - Engine returns `unsupported` → return as-is, no fetch, no cache touch.
19
+ * - Engine returns `unverified` → return as-is, no fetch, **no cache touch**
20
+ * even if a cache was supplied (§6.3: result flips when a token appears,
21
+ * so caching the no-token answer would poison future runs).
22
+ * - Engine returns success + cache present + not forceRefresh + cache hit →
23
+ * return `{ bytes, metadata, cached: true }`, no fetch.
24
+ * - Engine returns success otherwise → fetch via `authTransport` (using
25
+ * `fetch.headers` merged over `auth.headers` when present), write to
26
+ * cache if supplied, return `{ bytes, metadata, cached: false }`.
27
+ *
28
+ * **Throws** on network-level failures from the transport: DNS resolution
29
+ * failure, TLS handshake failure, connection refused, `AbortError` from the
30
+ * caller's `signal`, or any underlying `fetchImpl` rejection. The cache is
31
+ * only touched after the response body is fully read, so a transport failure
32
+ * cannot land a partial entry on disk. Consumers that need degradation
33
+ * semantics (validators, batch tools) should wrap calls in a try/catch.
34
+ *
35
+ * **Token never persisted.** Tokens are interpolated into headers in-memory;
36
+ * the metadata interface excludes header fields. The cache additionally
37
+ * whitelists the metadata fields it accepts. See cycle-6 token-persistence
38
+ * test for the on-disk assertion.
39
+ *
40
+ * **High-volume callers**: token resolution can be expensive (`gh auth token`
41
+ * spawns a subprocess). Callers iterating many URLs should wrap their `deps`
42
+ * once with `wrapLinkAuthDepsWithMemo` from `./link-auth-deps-memo.js` and
43
+ * pass the wrapped object to every invocation, so the same argv resolves at
44
+ * most once across the iteration.
45
+ */
46
+
47
+ import { resolveAuthenticatedUrl, type LinkAuthConfig } from '@vibe-agent-toolkit/utils';
48
+
49
+ import { type ContentCache, type ContentMetadata } from './content-cache.js';
50
+ import { type LinkAuthDeps } from './link-auth-deps-memo.js';
51
+ import { authTransport, type AuthTransportOptions } from './link-auth-transport.js';
52
+
53
+ export interface FetchAuthenticatedOptions {
54
+ /** Content cache to read from and write to. Omit to skip caching entirely. */
55
+ readonly cache?: ContentCache;
56
+ /** Bypass cache reads (still writes through on success). Default: false. */
57
+ readonly forceRefresh?: boolean;
58
+ /** Test/DI hook. Default: `globalThis.fetch`. */
59
+ readonly fetchImpl?: typeof fetch;
60
+ /** Token-resolution dependencies (env map, runCommand). Default: engine defaults. */
61
+ readonly deps?: LinkAuthDeps;
62
+ /** Propagated to the transport — caller's per-request timeout budget. */
63
+ readonly signal?: AbortSignal;
64
+ /** Per-call overrides for the transport's redirect / retry caps. */
65
+ readonly transportOptions?: AuthTransportOptions;
66
+ }
67
+
68
+ export type ContentFetchResult =
69
+ | {
70
+ readonly bytes: Uint8Array;
71
+ readonly metadata: ContentMetadata;
72
+ readonly cached: boolean;
73
+ }
74
+ | { readonly outcome: 'unsupported' }
75
+ | { readonly outcome: 'unverified'; readonly reason: string };
76
+
77
+ export async function fetchAuthenticated(
78
+ url: string,
79
+ config: LinkAuthConfig,
80
+ options: FetchAuthenticatedOptions = {},
81
+ ): Promise<ContentFetchResult> {
82
+ const plan = resolveAuthenticatedUrl(url, config, options.deps);
83
+ // Discriminate via Object.hasOwn (not `'fetchUrl' in plan`) so a prototype-
84
+ // injected `fetchUrl` field cannot reroute a non-success outcome onto the
85
+ // cache/fetch path. The engine controls plan shape — defending in depth.
86
+ if (!Object.hasOwn(plan, 'fetchUrl')) {
87
+ // unsupported / unverified — both short-circuit without fetch or cache.
88
+ const failure = plan as Exclude<typeof plan, { fetchUrl: string }>;
89
+ return failure.outcome === 'unverified'
90
+ ? { outcome: 'unverified', reason: failure.reason }
91
+ : { outcome: 'unsupported' };
92
+ }
93
+ const success = plan as Extract<typeof plan, { fetchUrl: string }>;
94
+
95
+ const cache = options.cache;
96
+ if (cache !== undefined && options.forceRefresh !== true) {
97
+ const hit = await cache.get(success.fetchUrl);
98
+ if (hit !== null) {
99
+ return { bytes: hit.bytes, metadata: hit.metadata, cached: true };
100
+ }
101
+ }
102
+
103
+ // Merge headers: start from auth (covers Authorization + check-mode Accept),
104
+ // layer fetch.headers on top (overrides on conflict — e.g. swap the GitHub
105
+ // Accept from +json to .raw). Both header objects come from the engine, so
106
+ // both have the resolved token baked in (§6.2 dual-expand).
107
+ const requestHeaders: Record<string, string> = {
108
+ ...success.headers,
109
+ ...(success.fetchHeaders ?? {}),
110
+ };
111
+
112
+ const transportOptions: AuthTransportOptions = {
113
+ ...(options.transportOptions ?? {}),
114
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
115
+ };
116
+
117
+ const response = await authTransport(
118
+ success.fetchUrl,
119
+ requestHeaders,
120
+ options.fetchImpl ?? globalThis.fetch,
121
+ transportOptions,
122
+ );
123
+
124
+ // Read full body as bytes. We use arrayBuffer (binary-clean) and wrap into a
125
+ // fresh Uint8Array so the caller doesn't hold a view onto fetch's internal
126
+ // buffer.
127
+ const arrayBuffer = await response.arrayBuffer();
128
+ const bytes = new Uint8Array(arrayBuffer);
129
+
130
+ const metadata: ContentMetadata = {
131
+ status: response.status,
132
+ contentType: response.headers.get('content-type'),
133
+ etag: response.headers.get('etag'),
134
+ lastModified: response.headers.get('last-modified'),
135
+ fetchedAt: Date.now(),
136
+ rewrittenUrl: success.fetchUrl,
137
+ };
138
+
139
+ // Write-through. The ContentCache whitelists fields and fails soft on IO,
140
+ // so a write failure does not propagate.
141
+ if (cache !== undefined) {
142
+ await cache.set(success.fetchUrl, bytes, metadata);
143
+ }
144
+
145
+ return { bytes, metadata, cached: false };
146
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Memoization wrapper for linkAuth `runCommand`-shaped token resolvers.
3
+ *
4
+ * Token resolution can be expensive — `gh auth token` spawns a subprocess on
5
+ * every call, env-source reads are cheap but consistent treatment is simpler
6
+ * to reason about — so the validator and the content-fetch primitive both
7
+ * wrap their `runCommand` once per session. Identical argv tuples return the
8
+ * cached result; distinct argv tuples are run independently.
9
+ *
10
+ * Lifetime of the memo is the lifetime of the returned `LinkAuthDeps` —
11
+ * one-per-session, never module-global. Callers that want a fresh resolve
12
+ * (e.g. token rotation between runs) construct a fresh validator / primitive
13
+ * call and get a fresh memo.
14
+ *
15
+ * Lifted out of `external-link-validator.ts` (where it shipped first in #125)
16
+ * so the slice-3 content-fetch primitive shares the same implementation —
17
+ * see CLAUDE.md "code duplication policy" (jscpd would flag a clone).
18
+ *
19
+ * Per design issue #113 review on PR #125 (memoize all token sources, not
20
+ * just `command` sources).
21
+ */
22
+
23
+ import { defaultRunCommand, type resolveAuthenticatedUrl } from '@vibe-agent-toolkit/utils';
24
+
25
+ /**
26
+ * Re-exposed deps type — the third argument to `resolveAuthenticatedUrl`.
27
+ * Mirrors the engine's `Partial<TokenResolutionDeps>` without importing the
28
+ * private type name.
29
+ */
30
+ export type LinkAuthDeps = Parameters<typeof resolveAuthenticatedUrl>[2];
31
+
32
+ /**
33
+ * Wrap a `LinkAuthDeps` so its `runCommand` (used by the engine's
34
+ * `resolveToken`) caches results per unique argv. Without this, validating N
35
+ * URLs from the same host re-runs the token command N times.
36
+ *
37
+ * Caches by JSON-stringified argv so semantically-identical invocations
38
+ * share. The cache is per-call-site (one Map per wrapper); callers that share
39
+ * across multiple `resolveAuthenticatedUrl` invocations should call this
40
+ * once and reuse the wrapped object.
41
+ */
42
+ export function wrapLinkAuthDepsWithMemo(deps: LinkAuthDeps): NonNullable<LinkAuthDeps> {
43
+ type RunCommandFn = NonNullable<NonNullable<LinkAuthDeps>['runCommand']>;
44
+ type RunCommandResult = ReturnType<RunCommandFn>;
45
+ const memo = new Map<string, RunCommandResult>();
46
+ const baseRunCommand: RunCommandFn = deps?.runCommand ?? defaultRunCommand;
47
+ const runCommand: RunCommandFn = (argv) => {
48
+ const key = JSON.stringify(argv);
49
+ const cached = memo.get(key);
50
+ if (cached !== undefined) return cached;
51
+ const fresh = baseRunCommand(argv);
52
+ memo.set(key, fresh);
53
+ return fresh;
54
+ };
55
+ return { ...(deps ?? {}), runCommand };
56
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Auth-safe HTTP transport for the linkAuth feature (design issue #113 §5.2, §8).
3
+ *
4
+ * Lower-level than the public `fetchAuthenticated` primitive — this transport
5
+ * does not know about providers, token resolution, or caching. It takes a URL
6
+ * + already-built headers and adds two behaviors required by the design:
7
+ *
8
+ * §8 (cross-origin token leak defense) — `redirect: 'manual'`, then a
9
+ * bounded loop that follows Location headers. On any redirect to a
10
+ * different origin the `Authorization` header is stripped and stays
11
+ * stripped for subsequent hops (defeats token-laundering via a cross-origin
12
+ * bounce back to the original host).
13
+ *
14
+ * §5.2 (rate-limit handling) — on HTTP 429, parse `Retry-After` (seconds or
15
+ * HTTP-date), wait, retry. Bounded by `maxRetries` so a stuck host cannot
16
+ * hang validation; bounded by `maxRetryAfterMs` so a hostile or buggy host
17
+ * cannot pin a validation run for an hour with `Retry-After: 86400`.
18
+ *
19
+ * Pure-ish: `fetchImpl` and `sleep` are dependency-injected so tests do not
20
+ * touch the network or wall-clock. Production callers pass `globalThis.fetch`.
21
+ */
22
+
23
+ export interface AuthTransportOptions {
24
+ /** Maximum redirect hops to follow before returning the last 3xx response (default: 5). */
25
+ readonly maxRedirects?: number;
26
+ /** Maximum 429 retries before returning the final 429 response (default: 2). */
27
+ readonly maxRetries?: number;
28
+ /** Cap on Retry-After in ms; defends against hostile/buggy long values (default: 60_000). */
29
+ readonly maxRetryAfterMs?: number;
30
+ /** Abort signal propagated to every fetchImpl call (validator's per-request timeout budget). */
31
+ readonly signal?: AbortSignal;
32
+ /** Test-only sleep injection. Production callers omit. */
33
+ readonly sleep?: (ms: number) => Promise<void>;
34
+ }
35
+
36
+ const DEFAULT_MAX_REDIRECTS = 5;
37
+ const DEFAULT_MAX_RETRIES = 2;
38
+ const DEFAULT_MAX_RETRY_AFTER_MS = 60_000;
39
+ /**
40
+ * Minimum delay between 429 retries. A hostile host can send `Retry-After: 0`
41
+ * (or an HTTP-date in the past, parsing to 0); without a floor, we'd retry
42
+ * immediately, which is worse-than-useless for the host's rate-limiting and
43
+ * makes us a poor neighbor. Bounded by `maxRetries` regardless, but the floor
44
+ * gives the host's window a chance to slide.
45
+ */
46
+ const MIN_RETRY_AFTER_MS = 250;
47
+
48
+ const defaultSleep = (ms: number): Promise<void> =>
49
+ new Promise((resolve) => {
50
+ setTimeout(resolve, ms);
51
+ });
52
+
53
+ /**
54
+ * Parse a `Retry-After` header value into milliseconds-to-wait.
55
+ *
56
+ * Returns `null` when the value is missing, empty, negative, or unparseable —
57
+ * callers treat `null` as "don't retry" (we can't pick a delay without a hint).
58
+ * HTTP-date values in the past return `0` (the server's hint is "you can retry
59
+ * now"). RFC 7231 §7.1.3.
60
+ */
61
+ export function parseRetryAfter(value: string | null): number | null {
62
+ if (value === null) return null;
63
+ const trimmed = value.trim();
64
+ if (trimmed === '') return null;
65
+
66
+ // delta-seconds form: positive integer count of seconds.
67
+ if (/^\d+$/.test(trimmed)) {
68
+ return Number(trimmed) * 1000;
69
+ }
70
+
71
+ // HTTP-date form per RFC 7231 §7.1.1.1 — always contains weekday and month
72
+ // names. Guard with /[A-Za-z]/ because Date.parse() is permissive: '-5'
73
+ // parses as year-5 BC, '0' as 1 BC, etc. Requiring at least one letter
74
+ // restricts us to real HTTP-date strings.
75
+ if (!/[A-Za-z]/.test(trimmed)) return null;
76
+ const parsedMs = Date.parse(trimmed);
77
+ if (!Number.isFinite(parsedMs)) return null;
78
+ return Math.max(0, parsedMs - Date.now());
79
+ }
80
+
81
+ export async function authTransport(
82
+ url: string,
83
+ headers: Record<string, string>,
84
+ fetchImpl: typeof fetch,
85
+ options: AuthTransportOptions = {},
86
+ ): Promise<Response> {
87
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
88
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
89
+ const maxRetryAfterMs = options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS;
90
+ const sleep = options.sleep ?? defaultSleep;
91
+
92
+ let currentUrl = url;
93
+ let currentHeaders: Record<string, string> = { ...headers };
94
+ let redirects = 0;
95
+ let retries = 0;
96
+
97
+ // Bounded loop: every iteration either returns or strictly advances one of
98
+ // (retries, redirects), both capped, so termination is guaranteed within
99
+ // `maxRetries + maxRedirects + 1` fetchImpl calls.
100
+ while (redirects + retries < maxRedirects + maxRetries + 1) {
101
+ const init: RequestInit = {
102
+ headers: currentHeaders,
103
+ redirect: 'manual',
104
+ };
105
+ if (options.signal !== undefined) init.signal = options.signal;
106
+
107
+ const response = await fetchImpl(currentUrl, init);
108
+
109
+ const retryDelay =
110
+ retries < maxRetries ? computeRetryDelay(response, maxRetryAfterMs) : null;
111
+ if (retryDelay !== null) {
112
+ await sleep(retryDelay);
113
+ retries++;
114
+ continue;
115
+ }
116
+
117
+ if (redirects < maxRedirects) {
118
+ const next = computeRedirect(response, currentUrl, currentHeaders);
119
+ if (next !== null) {
120
+ currentUrl = next.url;
121
+ currentHeaders = next.headers;
122
+ redirects++;
123
+ continue;
124
+ }
125
+ }
126
+
127
+ return response;
128
+ }
129
+ // Unreachable: the loop returns or continues on every iteration, and the
130
+ // loop condition strictly bounds total iterations.
131
+ throw new Error('authTransport: unreachable iteration cap exceeded');
132
+ }
133
+
134
+ /**
135
+ * Decide the wait-time for a 429 response, or `null` if the response is not
136
+ * a 429 OR has no parseable Retry-After hint (no hint → caller returns the 429
137
+ * rather than retrying blindly).
138
+ */
139
+ function computeRetryDelay(response: Response, maxRetryAfterMs: number): number | null {
140
+ if (response.status !== 429) return null;
141
+ const retryAfter = parseRetryAfter(response.headers.get('retry-after'));
142
+ if (retryAfter === null) return null;
143
+ // Two-sided clamp: never longer than the DoS cap, never shorter than the
144
+ // good-neighbor floor (defends against `Retry-After: 0` busy-loops).
145
+ return Math.min(Math.max(retryAfter, MIN_RETRY_AFTER_MS), maxRetryAfterMs);
146
+ }
147
+
148
+ /**
149
+ * Decide the next hop for a 3xx redirect, or `null` if the response is not a
150
+ * redirect / has no Location. Strips Authorization on cross-origin per §8.
151
+ */
152
+ function computeRedirect(
153
+ response: Response,
154
+ currentUrl: string,
155
+ currentHeaders: Record<string, string>,
156
+ ): { url: string; headers: Record<string, string> } | null {
157
+ if (response.status < 300 || response.status >= 400) return null;
158
+ const location = response.headers.get('location');
159
+ if (location === null) return null;
160
+ const nextUrl = new URL(location, currentUrl).toString();
161
+ const sameOrigin = new URL(nextUrl).origin === new URL(currentUrl).origin;
162
+ return {
163
+ url: nextUrl,
164
+ headers: sameOrigin ? currentHeaders : stripAuthorization(currentHeaders),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Remove any header whose name is `Authorization` (case-insensitive). Returns
170
+ * a new object — does not mutate the input.
171
+ */
172
+ function stripAuthorization(headers: Record<string, string>): Record<string, string> {
173
+ const result: Record<string, string> = {};
174
+ for (const [name, value] of Object.entries(headers)) {
175
+ if (name.toLowerCase() === 'authorization') continue;
176
+ result[name] = value;
177
+ }
178
+ return result;
179
+ }
@@ -23,6 +23,7 @@ import {
23
23
  } from './frontmatter-link-validator.js';
24
24
  import { validateFrontmatter } from './frontmatter-validator.js';
25
25
  import { parseHtml } from './html-link-parser.js';
26
+ import { buildLinkAuthEngineConfig } from './link-auth-config-build.js';
26
27
  import { parseMarkdown } from './link-parser.js';
27
28
  import { fragmentIndexEntry, validateLink, type FragmentIndex, type ValidateLinkOptions } from './link-validator.js';
28
29
  import type { ResourceCollectionInterface } from './resource-collection-interface.js';
@@ -838,10 +839,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
838
839
  // Determine cache directory
839
840
  const cacheDir = this.getCacheDirectory();
840
841
 
842
+ // Expand any `resources.linkAuth` providers from macro refs into the engine
843
+ // shape (#113 §5). When the adopter has no linkAuth config, the validator
844
+ // skips the authenticated branch and uses the existing markdown-link-check
845
+ // path for every URL — identical to pre-#113 behavior.
846
+ const adopterLinkAuth = this.config?.resources?.linkAuth;
847
+ const linkAuthConfig = adopterLinkAuth
848
+ ? buildLinkAuthEngineConfig(adopterLinkAuth)
849
+ : undefined;
850
+
841
851
  // Create validator
842
852
  const validator = new ExternalLinkValidator(cacheDir, {
843
853
  timeout: 15000,
844
854
  cacheTtlHours: noCache ? 0 : 24,
855
+ ...(linkAuthConfig !== undefined && { linkAuthConfig }),
845
856
  });
846
857
 
847
858
  // Collect all external URLs from all resources
@@ -911,7 +922,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
911
922
  * @private
912
923
  */
913
924
  private convertValidationResultsToIssues(
914
- results: Array<{ url: string; status: 'ok' | 'error'; statusCode: number; error?: string }>,
925
+ results: Array<{ url: string; status: 'ok' | 'error'; statusCode: number; error?: string; code?: IssueCode }>,
915
926
  urlsToValidate: Map<string, Array<{ resourcePath: string; line?: number }>>,
916
927
  ): ValidationIssue[] {
917
928
  const issues: ValidationIssue[] = [];
@@ -926,7 +937,11 @@ export class ResourceRegistry implements ResourceCollectionInterface {
926
937
  continue;
927
938
  }
928
939
 
929
- const issueCode = this.determineExternalUrlIssueCode(result.statusCode, result.error);
940
+ // Prefer a code surfaced by the validator's authenticated branch (#113 §7)
941
+ // those LINK_AUTH_* codes encode per-provider notFoundMeaning routing that
942
+ // statusCode alone cannot express. Fall back to the statusCode mapping for
943
+ // the anonymous markdown-link-check path.
944
+ const issueCode = result.code ?? this.determineExternalUrlIssueCode(result.statusCode, result.error);
930
945
  const errorMessage = result.error ?? `HTTP ${result.statusCode}`;
931
946
 
932
947
  for (const location of locations) {
@@ -3,24 +3,26 @@
3
3
  *
4
4
  * Validates the shape of an adopter's linkAuth configuration: providers
5
5
  * (each either a `use: <macro>` reference or a full inline provider) plus
6
- * an optional cache config. Stricttypos in provider field names are
7
- * errors, per Postel's law ("writing our output → conservative").
6
+ * an optional cache config. **Passthrough**per the repo Postel's Law
7
+ * rule, schemas that parse external data (adopter `vibe-agent-toolkit.config.yaml`)
8
+ * are liberal: unknown fields pass through rather than abort the parse, so
9
+ * an adopter with a forward-compatible field or a typo gets `vat resources
10
+ * validate` degrading gracefully instead of crashing. Typo-catching DX is
11
+ * a separate lint/warn pass, not a hard parse failure.
8
12
  *
9
13
  * Provider entries accept EITHER:
10
14
  * - `{ use: <name>, ...overrides }` — reference a shipped macro by name;
11
- * override fields are deep-merged on top at expansion time. Overrides
12
- * are NOT strictly typed at this layer because they must match the
13
- * macro's shape, which we cannot see until expansion. A typo in an
14
- * override silently no-ops; the full provider shape is validated
15
- * after expansion (slice 2 wires that).
16
- * - A full inline `{ match, rewrite, auth, token, check }` — every field
17
- * required, every nested object strict.
15
+ * override fields are deep-merged on top at expansion time.
16
+ * - A full inline `{ match, rewrite, auth, token, check }` — every required
17
+ * field present, with declared-field types still enforced (passthrough
18
+ * only relaxes unknown-key handling, not type checking on known fields).
18
19
  *
19
20
  * Mirrors the runtime types in `@vibe-agent-toolkit/utils`'s `link-auth/`
20
21
  * module. Keep these aligned: a config that parses here must satisfy the
21
22
  * `Provider` / `LinkAuthConfig` interfaces over there.
22
23
  *
23
- * Per design issue #113 §4 (vocabulary) and §5 (macros).
24
+ * Per design issue #113 §4 (vocabulary) and §5 (macros), and the repo
25
+ * CLAUDE.md Postel's Law rule for adopter-facing configs.
24
26
  */
25
27
 
26
28
  import { z } from 'zod';
@@ -37,7 +39,7 @@ export const ProviderMatchSchema = z
37
39
  .optional()
38
40
  .describe('Globs that, if any match the hostname, exclude this provider'),
39
41
  })
40
- .strict()
42
+ .passthrough()
41
43
  .describe('Provider selection — match.host + optional excludeHost globs');
42
44
 
43
45
  export const RewriteRuleSchema = z
@@ -49,7 +51,7 @@ export const RewriteRuleSchema = z
49
51
  .describe('Computed variables, each a template that references captures from `when`'),
50
52
  to: z.string().min(1).describe('URL template — interpolates ${capture} and ${var}'),
51
53
  })
52
- .strict()
54
+ .passthrough()
53
55
  .describe('A single rewrite rule (one entry in a provider\'s ordered rewrite list)');
54
56
 
55
57
  export const ProviderAuthSchema = z
@@ -58,17 +60,32 @@ export const ProviderAuthSchema = z
58
60
  .record(z.string(), z.string())
59
61
  .describe('Header name → value template. Values interpolate ${token} and any capture/var.'),
60
62
  })
61
- .strict()
63
+ .passthrough()
62
64
  .describe('Auth headers attached to the authenticated fetch');
63
65
 
66
+ /**
67
+ * Optional fetch-mode header overrides (§6.2). Same shape as ProviderAuth,
68
+ * different role: `auth.headers` covers the health-check request; `fetch.headers`
69
+ * covers content retrieval (e.g. GitHub `application/vnd.github.raw` instead of
70
+ * `application/vnd.github+json`). Both templates expand against the same context.
71
+ */
72
+ export const ProviderFetchSchema = z
73
+ .object({
74
+ headers: z
75
+ .record(z.string(), z.string())
76
+ .describe('Header name → value template, same templating as auth.headers.'),
77
+ })
78
+ .passthrough()
79
+ .describe('Optional content-fetch header overrides — distinct from health-check headers');
80
+
64
81
  export const TokenSourceSchema = z
65
82
  .union([
66
- z.object({ env: z.string().min(1) }).strict(),
83
+ z.object({ env: z.string().min(1) }).passthrough(),
67
84
  z
68
85
  .object({
69
86
  command: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
70
87
  })
71
- .strict(),
88
+ .passthrough(),
72
89
  ])
73
90
  .describe('A single token source — env var, or argv command (preferred), or convenience string');
74
91
 
@@ -85,22 +102,25 @@ export const ProviderCheckSchema = z
85
102
  'Per-host: does 404 mean "dead" (honest 404 host like Graph) or "ambiguous" (masking host like GitHub)?',
86
103
  ),
87
104
  })
88
- .strict()
105
+ .passthrough()
89
106
  .describe('Status-to-outcome classifier (consumed by the resources layer, not the pure engine)');
90
107
 
91
108
  // ---------------------------------------------------------------------------
92
109
  // Provider entry (inline OR macro reference)
93
110
  // ---------------------------------------------------------------------------
94
111
 
95
- const InlineProviderSchema = z
112
+ export const InlineProviderSchema = z
96
113
  .object({
97
114
  match: ProviderMatchSchema,
98
115
  rewrite: z.array(RewriteRuleSchema).min(1).describe('Ordered rewrite rules — first matching wins'),
99
116
  auth: ProviderAuthSchema,
117
+ fetch: ProviderFetchSchema
118
+ .optional()
119
+ .describe('Optional fetch-mode header override (§6.2) — content retrieval, distinct from health-check'),
100
120
  token: z.array(TokenSourceSchema).describe('Ordered token sources — first non-empty wins'),
101
121
  check: ProviderCheckSchema,
102
122
  })
103
- .strict();
123
+ .passthrough();
104
124
 
105
125
  const MacroProviderSchema = z
106
126
  .object({
@@ -128,7 +148,7 @@ export const LinkAuthCacheSchema = z
128
148
  .optional()
129
149
  .describe('Content cache TTL in minutes (default 30 — used by slice 3 content-fetch)'),
130
150
  })
131
- .strict();
151
+ .passthrough();
132
152
 
133
153
  export const LinkAuthConfigSchema = z
134
154
  .object({
@@ -137,10 +157,16 @@ export const LinkAuthConfigSchema = z
137
157
  .array(ProviderEntrySchema)
138
158
  .describe('Ordered list of providers — first claiming-by-host wins'),
139
159
  })
140
- .strict()
160
+ .passthrough()
141
161
  .describe('`resources.linkAuth` — authenticated external link resolution config');
142
162
 
143
- export type LinkAuthConfig = z.infer<typeof LinkAuthConfigSchema>;
163
+ /**
164
+ * Adopter-facing config shape — what an `vibe-agent-toolkit.config.yaml`
165
+ * parses to. Distinct from `@vibe-agent-toolkit/utils`'s `LinkAuthConfig`
166
+ * (the engine shape, with fully-expanded providers only). The bridge
167
+ * function `buildLinkAuthEngineConfig` converts between the two.
168
+ */
169
+ export type LinkAuthProjectConfig = z.infer<typeof LinkAuthConfigSchema>;
144
170
  export type ProviderEntry = z.infer<typeof ProviderEntrySchema>;
145
171
  export type RewriteRule = z.infer<typeof RewriteRuleSchema>;
146
172
  export type TokenSource = z.infer<typeof TokenSourceSchema>;