@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.
Files changed (75) 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/link-parser.d.ts.map +1 -1
  38. package/dist/link-parser.js +8 -1
  39. package/dist/link-parser.js.map +1 -1
  40. package/dist/link-validator.d.ts +8 -8
  41. package/dist/link-validator.d.ts.map +1 -1
  42. package/dist/link-validator.js +51 -18
  43. package/dist/link-validator.js.map +1 -1
  44. package/dist/resource-registry.d.ts +2 -0
  45. package/dist/resource-registry.d.ts.map +1 -1
  46. package/dist/resource-registry.js +19 -4
  47. package/dist/resource-registry.js.map +1 -1
  48. package/dist/schemas/link-auth.d.ts +1049 -303
  49. package/dist/schemas/link-auth.d.ts.map +1 -1
  50. package/dist/schemas/link-auth.js +39 -20
  51. package/dist/schemas/link-auth.js.map +1 -1
  52. package/dist/schemas/project-config.d.ts +2860 -759
  53. package/dist/schemas/project-config.d.ts.map +1 -1
  54. package/dist/schemas/project-config.js +18 -1
  55. package/dist/schemas/project-config.js.map +1 -1
  56. package/dist/schemas/resource-metadata.d.ts +10 -9
  57. package/dist/schemas/resource-metadata.d.ts.map +1 -1
  58. package/dist/schemas/resource-metadata.js +2 -0
  59. package/dist/schemas/resource-metadata.js.map +1 -1
  60. package/package.json +4 -4
  61. package/src/content-cache.ts +190 -0
  62. package/src/external-link-cache.ts +50 -13
  63. package/src/external-link-validator.ts +268 -5
  64. package/src/index.ts +23 -0
  65. package/src/link-auth-classify.ts +61 -0
  66. package/src/link-auth-config-build.ts +130 -0
  67. package/src/link-auth-content-fetch.ts +146 -0
  68. package/src/link-auth-deps-memo.ts +56 -0
  69. package/src/link-auth-transport.ts +179 -0
  70. package/src/link-parser.ts +8 -1
  71. package/src/link-validator.ts +65 -17
  72. package/src/resource-registry.ts +25 -5
  73. package/src/schemas/link-auth.ts +47 -21
  74. package/src/schemas/project-config.ts +22 -1
  75. 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
- private readonly options: Required<ExternalLinkValidatorOptions>;
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 the validation cache
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
- return this.cache.getStats();
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
+ }