@wildo-ai/wildo-module-client 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/esm/index.d.ts.map +1 -1
  2. package/dist/esm/services/_http-client.d.ts +32 -3
  3. package/dist/esm/services/_http-client.d.ts.map +1 -1
  4. package/dist/esm/services/_http-client.js +100 -6
  5. package/dist/esm/services/_http-client.js.map +1 -1
  6. package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -1
  7. package/dist/esm/services/index.d.ts.map +1 -1
  8. package/dist/esm/services/lockfile-repair.service.d.ts +8 -23
  9. package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -1
  10. package/dist/esm/services/lockfile-repair.service.js +10 -28
  11. package/dist/esm/services/lockfile-repair.service.js.map +1 -1
  12. package/dist/esm/services/module-adopter.service.d.ts +1 -1
  13. package/dist/esm/services/module-adopter.service.d.ts.map +1 -1
  14. package/dist/esm/services/module-adopter.service.js +7 -8
  15. package/dist/esm/services/module-adopter.service.js.map +1 -1
  16. package/dist/esm/services/module-packager.service.d.ts.map +1 -1
  17. package/dist/esm/services/module-packager.service.js +16 -19
  18. package/dist/esm/services/module-packager.service.js.map +1 -1
  19. package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -1
  20. package/dist/esm/services/provider-sync-runner.d.ts +2 -3
  21. package/dist/esm/services/provider-sync-runner.d.ts.map +1 -1
  22. package/dist/esm/services/provider-sync-runner.js +14 -2
  23. package/dist/esm/services/provider-sync-runner.js.map +1 -1
  24. package/dist/esm/services/registry-companion-client.d.ts +10 -25
  25. package/dist/esm/services/registry-companion-client.d.ts.map +1 -1
  26. package/dist/esm/services/registry-companion-client.js +9 -83
  27. package/dist/esm/services/registry-companion-client.js.map +1 -1
  28. package/dist/esm/services/registry-lockfile.service.d.ts +7 -3
  29. package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -1
  30. package/dist/esm/services/registry-lockfile.service.js +8 -3
  31. package/dist/esm/services/registry-lockfile.service.js.map +1 -1
  32. package/dist/esm/services/registry-server-client.d.ts +35 -9
  33. package/dist/esm/services/registry-server-client.d.ts.map +1 -1
  34. package/dist/esm/services/registry-server-client.js +115 -58
  35. package/dist/esm/services/registry-server-client.js.map +1 -1
  36. package/dist/tsconfig.build.tsbuildinfo +1 -1
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/lockfile-repair.service.test.ts +2 -2
  40. package/src/__tests__/module-adopter.service.test.ts +6 -4
  41. package/src/__tests__/module-packager.service.test.ts +36 -34
  42. package/src/__tests__/provider-sync-runner.test.ts +12 -0
  43. package/src/__tests__/registry-lockfile.service.test.ts +3 -3
  44. package/src/__tests__/registry-server-client.test.ts +129 -0
  45. package/src/__tests__/registry-transfer-timeouts.test.ts +94 -0
  46. package/src/services/_http-client.ts +100 -6
  47. package/src/services/lockfile-repair.service.ts +9 -34
  48. package/src/services/module-adopter.service.ts +6 -9
  49. package/src/services/module-packager.service.ts +19 -16
  50. package/src/services/provider-sync-runner.ts +16 -5
  51. package/src/services/registry-companion-client.ts +10 -132
  52. package/src/services/registry-lockfile.service.ts +9 -3
  53. package/src/services/registry-server-client.ts +147 -57
@@ -12,7 +12,8 @@
12
12
  * Auth (KD-11): if `authToken` is supplied, every request adds
13
13
  * `Authorization: Bearer <token>`. Public read paths work without
14
14
  * a token; restricted paths return 401/403, surfaced as
15
- * `RegistryAuthRequiredError` / `RegistryAuthForbiddenError`.
15
+ * `RegistryAuthRequiredError` (no key sent), `RegistryCredentialRejectedError` (a key sent and refused)
16
+ * or `RegistryAuthForbiddenError`; a 429 is `RegistryRateLimitedError`.
16
17
  */
17
18
 
18
19
  import { z } from 'zod';
@@ -23,16 +24,31 @@ import {
23
24
  RegistryArtifactIntegrityMismatchError,
24
25
  RegistryAuthForbiddenError,
25
26
  RegistryAuthRequiredError,
27
+ RegistryCredentialRejectedError,
26
28
  RegistryError,
29
+ RegistryRateLimitedError,
30
+ isRegistryError,
31
+ isRegistryReleaseStateConflictBody,
27
32
  RegistryMalformedPayloadError,
28
33
  RegistryModuleNotFoundError,
29
34
  RegistryUnexpectedStatusError,
30
35
  RegistryVersionNotFoundError,
36
+ RegistryYankNoticeSchema,
37
+ RegistryYankStateConflictError,
38
+ RegistryYankVerb,
39
+ REGISTRY_YANK_REASON_HEADER,
40
+ REGISTRY_YANKED_AT_HEADER,
31
41
  Sha256DigestSchema,
42
+ ModuleSearchHitSchema,
43
+ moduleSearchFilterQueryPairs,
44
+ moduleSearchHitMatchesFilter,
32
45
  type ModuleManifest,
46
+ type ModuleSearchFilter,
47
+ type ModuleSearchHit,
48
+ type RegistryYankNotice,
33
49
  type ModuleVersionRecord,
34
50
  } from '@wildo-ai/platform-config-lib';
35
- import { fetchJsonOk, fetchWithTimeout, joinUrl } from './_http-client';
51
+ import { fetchJsonOk, fetchTransfer, fetchWithTimeout, joinUrl, readJsonBody } from './_http-client';
36
52
 
37
53
  /**
38
54
  * Zod schema for the `info()` response payload — mirrors
@@ -48,13 +64,7 @@ const RegistryInfoResponseSchema = z.object({
48
64
  });
49
65
 
50
66
  const RegistrySearchResponseSchema = z.object({
51
- hits: z.array(
52
- z.object({
53
- moduleId: z.string().min(1),
54
- description: z.string().optional(),
55
- latestVersion: z.string(),
56
- }),
57
- ),
67
+ hits: z.array(ModuleSearchHitSchema),
58
68
  });
59
69
 
60
70
  const DIAGNOSTIC_LABEL = 'Registry';
@@ -72,10 +82,32 @@ function authHeaders(options: RegistryServerClientOptions): Record<string, strin
72
82
  return options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {};
73
83
  }
74
84
 
75
- async function classifyAuthError(res: Response, registryUrl: string, action: string): Promise<RegistryError | null> {
76
- if (res.status === 401) return new RegistryAuthRequiredError(registryUrl);
77
- if (res.status === 403) return new RegistryAuthForbiddenError(action);
78
- return null;
85
+ /**
86
+ * Throw the typed error for a non-2xx response, after releasing its body (#1763 review: an unread body
87
+ * holds the connection until garbage collection). A 401 is told apart by whether a key was SENT — "log
88
+ * in" and "your key no longer works" are different remedies (#1759 review).
89
+ */
90
+ async function refuseUnlessOk(
91
+ res: Response,
92
+ options: RegistryServerClientOptions,
93
+ action: string,
94
+ notFound?: () => RegistryError,
95
+ ): Promise<void> {
96
+ if (res.ok) return;
97
+ let error: RegistryError;
98
+ if (res.status === 404 && notFound) error = notFound();
99
+ else if (res.status === 401) error = options.authToken ? new RegistryCredentialRejectedError(options.baseUrl) : new RegistryAuthRequiredError(options.baseUrl);
100
+ else if (res.status === 403) error = new RegistryAuthForbiddenError(action);
101
+ else if (res.status === 429) error = new RegistryRateLimitedError(options.baseUrl, action, parseRetryAfterSeconds(res.headers.get('retry-after')));
102
+ else error = new RegistryUnexpectedStatusError(options.baseUrl, res.status, res.statusText, action);
103
+ await res.body?.cancel().catch(() => undefined);
104
+ throw error;
105
+ }
106
+
107
+ /** `Retry-After` in its delta-seconds form; the HTTP-date form is not produced by a Wildo backend. */
108
+ function parseRetryAfterSeconds(value: string | null): number | undefined {
109
+ if (!value || !/^\d+$/.test(value.trim())) return undefined;
110
+ return Number(value.trim());
79
111
  }
80
112
 
81
113
  /**
@@ -90,10 +122,13 @@ async function parseJsonOrThrow(
90
122
  res: Response,
91
123
  registryUrl: string,
92
124
  action: string,
125
+ timeoutMs: number | undefined,
93
126
  ): Promise<unknown> {
94
127
  try {
95
- return await res.json();
128
+ return await readJsonBody(res, registryUrl, action, timeoutMs);
96
129
  } catch (err) {
130
+ // A body cut short by the request budget is a timeout (#1763), not a malformed payload.
131
+ if (isRegistryError(err)) throw err;
97
132
  throw new RegistryMalformedPayloadError(
98
133
  registryUrl,
99
134
  action,
@@ -106,6 +141,11 @@ export interface RegistryResolveResult {
106
141
  /** Resolved exact version (matches `Sha256DigestSchema` for digest). */
107
142
  version: string;
108
143
  artifactDigest: string;
144
+ /**
145
+ * Present when the resolved release is yanked. Only an EXACT-version request can resolve to one
146
+ * (#1758) — ranges and tags never do — so the caller is pinned to it and should warn, not refuse.
147
+ */
148
+ yanked?: RegistryYankNotice;
109
149
  }
110
150
 
111
151
  export interface RegistryInfoResult {
@@ -116,11 +156,8 @@ export interface RegistryInfoResult {
116
156
  tags: Readonly<Record<string, string>>;
117
157
  }
118
158
 
119
- export interface RegistrySearchHit {
120
- moduleId: string;
121
- description?: string;
122
- latestVersion: string;
123
- }
159
+ /** One search hit: the module, its latest version, and (#2014) its kind and provider facts. */
160
+ export type RegistrySearchHit = ModuleSearchHit;
124
161
 
125
162
  export const RegistryServerClient = {
126
163
  /**
@@ -162,19 +199,13 @@ export const RegistryServerClient = {
162
199
  timeoutMs: options.timeoutMs,
163
200
  diagnosticLabel: DIAGNOSTIC_LABEL,
164
201
  });
165
- if (res.status === 404) {
166
- throw new RegistryVersionNotFoundError(args.moduleId, args.range);
167
- }
168
- const authErr = await classifyAuthError(res, options.baseUrl, `resolve ${args.moduleId}`);
169
- if (authErr) throw authErr;
170
- if (!res.ok) {
171
- throw new RegistryUnexpectedStatusError(options.baseUrl, res.status, res.statusText, `resolve ${args.moduleId}`);
172
- }
202
+ await refuseUnlessOk(res, options, `resolve ${args.moduleId}`, () => new RegistryVersionNotFoundError(args.moduleId, args.range));
173
203
  // 6th-pass M3: typed JSON-parse (was bare `SyntaxError` for
174
204
  // malformed proxy responses).
175
- const json = (await parseJsonOrThrow(res, options.baseUrl, `resolve ${args.moduleId}`)) as {
205
+ const json = (await parseJsonOrThrow(res, options.baseUrl, `resolve ${args.moduleId}`, options.timeoutMs)) as {
176
206
  version?: unknown;
177
207
  artifactDigest?: unknown;
208
+ yanked?: unknown;
178
209
  };
179
210
  const digest = String(json.artifactDigest ?? '');
180
211
  // 6th-pass M4: typed digest validation (was bare `ZodError`).
@@ -196,7 +227,15 @@ export const RegistryServerClient = {
196
227
  'response missing required `version` string',
197
228
  );
198
229
  }
199
- return { version: json.version, artifactDigest: digest };
230
+ let yanked: RegistryYankNotice | undefined;
231
+ if (json.yanked !== undefined) {
232
+ const yankParse = RegistryYankNoticeSchema.safeParse(json.yanked);
233
+ if (!yankParse.success) {
234
+ throw new RegistryMalformedPayloadError(options.baseUrl, `resolve ${args.moduleId}`, `yanked: ${formatZodIssuesAsSingleLine(yankParse.error)}`);
235
+ }
236
+ yanked = yankParse.data;
237
+ }
238
+ return { version: json.version, artifactDigest: digest, ...(yanked ? { yanked } : {}) };
200
239
  },
201
240
 
202
241
  /**
@@ -217,14 +256,9 @@ export const RegistryServerClient = {
217
256
  timeoutMs: options.timeoutMs,
218
257
  diagnosticLabel: DIAGNOSTIC_LABEL,
219
258
  });
220
- if (res.status === 404) throw new RegistryModuleNotFoundError(args.moduleId);
221
- const authErr = await classifyAuthError(res, options.baseUrl, `info ${args.moduleId}`);
222
- if (authErr) throw authErr;
223
- if (!res.ok) {
224
- throw new RegistryUnexpectedStatusError(options.baseUrl, res.status, res.statusText, `info ${args.moduleId}`);
225
- }
259
+ await refuseUnlessOk(res, options, `info ${args.moduleId}`, () => new RegistryModuleNotFoundError(args.moduleId));
226
260
  // 6th-pass M3: typed JSON-parse.
227
- const json = await parseJsonOrThrow(res, options.baseUrl, `info ${args.moduleId}`);
261
+ const json = await parseJsonOrThrow(res, options.baseUrl, `info ${args.moduleId}`, options.timeoutMs);
228
262
  const parsed = RegistryInfoResponseSchema.safeParse(json);
229
263
  if (!parsed.success) {
230
264
  throw new RegistryMalformedPayloadError(
@@ -237,16 +271,17 @@ export const RegistryServerClient = {
237
271
  },
238
272
 
239
273
  /**
240
- * Search the registry's metadata index. The shape is
241
- * intentionally narrow until Phase 4 designs the search UX.
274
+ * Search the registry's metadata index: a module-id substring, and (#2014) the manifest filters —
275
+ * kind, engine capability and provider maturity. The server refuses an unknown filter value.
242
276
  */
243
277
  async search(
244
278
  options: RegistryServerClientOptions,
245
- args: { domain: string; query: string },
279
+ args: { domain: string; query: string; filter?: ModuleSearchFilter },
246
280
  ): Promise<readonly RegistrySearchHit[]> {
281
+ const params = new URLSearchParams([['q', args.query], ...moduleSearchFilterQueryPairs(args.filter ?? {})]);
247
282
  const url = joinUrl(
248
283
  options.baseUrl,
249
- `/v1/registry/${encodeURIComponent(args.domain)}/modules?q=${encodeURIComponent(args.query)}`,
284
+ `/v1/registry/${encodeURIComponent(args.domain)}/modules?${params.toString()}`,
250
285
  );
251
286
  const res = await fetchWithTimeout(url, {
252
287
  method: 'GET',
@@ -254,13 +289,9 @@ export const RegistryServerClient = {
254
289
  timeoutMs: options.timeoutMs,
255
290
  diagnosticLabel: DIAGNOSTIC_LABEL,
256
291
  });
257
- const authErr = await classifyAuthError(res, options.baseUrl, `search`);
258
- if (authErr) throw authErr;
259
- if (!res.ok) {
260
- throw new RegistryUnexpectedStatusError(options.baseUrl, res.status, res.statusText, 'search');
261
- }
292
+ await refuseUnlessOk(res, options, 'search');
262
293
  // 6th-pass M3: typed JSON-parse.
263
- const json = await parseJsonOrThrow(res, options.baseUrl, 'search');
294
+ const json = await parseJsonOrThrow(res, options.baseUrl, 'search', options.timeoutMs);
264
295
  const parsed = RegistrySearchResponseSchema.safeParse(json);
265
296
  if (!parsed.success) {
266
297
  throw new RegistryMalformedPayloadError(
@@ -269,7 +300,8 @@ export const RegistryServerClient = {
269
300
  formatZodIssuesAsSingleLine(parsed.error),
270
301
  );
271
302
  }
272
- return parsed.data.hits;
303
+ // Re-applied here: a server that predates the filters ignores them and answers every module (#2014 review F9).
304
+ return parsed.data.hits.filter((hit) => moduleSearchHitMatchesFilter(hit, args.filter ?? {}));
273
305
  },
274
306
 
275
307
  /**
@@ -286,7 +318,7 @@ export const RegistryServerClient = {
286
318
  version: string;
287
319
  expectedDigest: string;
288
320
  },
289
- ): Promise<{ bytes: Uint8Array; integrity: string }> {
321
+ ): Promise<{ bytes: Uint8Array; integrity: string; yanked?: RegistryYankNotice }> {
290
322
  // 7th-pass C-M1: caller-supplied digest is a developer-contract
291
323
  // violation if malformed, not a registry-runtime condition.
292
324
  // Throw `TypeError` (matching `C-L3` precedent for
@@ -306,19 +338,16 @@ export const RegistryServerClient = {
306
338
  options.baseUrl,
307
339
  `/v1/registry/${encodeURIComponent(args.domain)}/modules/${encodeURIComponent(args.moduleId)}/${encodeURIComponent(args.version)}/artifact`,
308
340
  );
309
- const res = await fetchWithTimeout(url, {
341
+ // A transfer, not a metadata call: the response is bounded by `timeoutMs`, the body by an IDLE budget
342
+ // reset on every chunk, so a large artifact over a slow link installs and a stalled one fails (#1763).
343
+ const { response: res, readAll } = await fetchTransfer(url, {
310
344
  method: 'GET',
311
345
  headers: authHeaders(options),
312
346
  timeoutMs: options.timeoutMs,
313
347
  diagnosticLabel: DIAGNOSTIC_LABEL,
314
348
  });
315
- if (res.status === 404) throw new RegistryVersionNotFoundError(args.moduleId, args.version);
316
- const authErr = await classifyAuthError(res, options.baseUrl, `download ${args.moduleId}@${args.version}`);
317
- if (authErr) throw authErr;
318
- if (!res.ok) {
319
- throw new RegistryUnexpectedStatusError(options.baseUrl, res.status, res.statusText, `download ${args.moduleId}@${args.version}`);
320
- }
321
- const buf = new Uint8Array(await res.arrayBuffer());
349
+ await refuseUnlessOk(res, options, `download ${args.moduleId}@${args.version}`, () => new RegistryVersionNotFoundError(args.moduleId, args.version));
350
+ const buf = await readAll();
322
351
  const { createHash } = await import('node:crypto');
323
352
  const observed = `sha256:${createHash('sha256').update(buf).digest('hex')}`;
324
353
  if (observed !== args.expectedDigest) {
@@ -329,6 +358,67 @@ export const RegistryServerClient = {
329
358
  observed,
330
359
  );
331
360
  }
332
- return { bytes: buf, integrity: observed };
361
+ // A yanked release is served by exact version (#1758) and says so in headers.
362
+ const yankedAt = res.headers.get(REGISTRY_YANKED_AT_HEADER);
363
+ const encodedReason = res.headers.get(REGISTRY_YANK_REASON_HEADER);
364
+ let reason: string | undefined;
365
+ if (encodedReason) {
366
+ try { reason = decodeURIComponent(encodedReason); } catch { reason = encodedReason; }
367
+ }
368
+ return { bytes: buf, integrity: observed, ...(yankedAt ? { yanked: { yankedAt, ...(reason ? { reason } : {}) } } : {}) };
369
+ },
370
+ /**
371
+ * Yank a release (#1758): ranges and `latest` stop selecting it, and `latest` moves to the highest
372
+ * live release; an exact pin still installs it, with a warning. Needs a key holding the version UPDATE
373
+ * role in the domain's owning organization. Returns where `latest` points afterwards.
374
+ */
375
+ async yank(
376
+ options: RegistryServerClientOptions,
377
+ args: { domain: string; moduleId: string; version: string; reason?: string },
378
+ ): Promise<{ latest: string | null }> {
379
+ return changeYankState(options, args, RegistryYankVerb.YANK);
380
+ },
381
+
382
+ /** Restore a yanked release (#1758); `latest` returns to it when it outranks the current target. */
383
+ async unyank(
384
+ options: RegistryServerClientOptions,
385
+ args: { domain: string; moduleId: string; version: string },
386
+ ): Promise<{ latest: string | null }> {
387
+ return changeYankState(options, args, RegistryYankVerb.UNYANK);
333
388
  },
334
389
  } as const;
390
+
391
+ const RegistryYankResponseSchema = z.object({ ok: z.literal(true), latest: z.string().nullable().optional() });
392
+
393
+ async function changeYankState(
394
+ options: RegistryServerClientOptions,
395
+ args: { domain: string; moduleId: string; version: string; reason?: string },
396
+ verb: RegistryYankVerb,
397
+ ): Promise<{ latest: string | null }> {
398
+ const url = joinUrl(
399
+ options.baseUrl,
400
+ `/v1/registry/${encodeURIComponent(args.domain)}/modules/${encodeURIComponent(args.moduleId)}/${verb}`,
401
+ );
402
+ const action = `${verb} ${args.moduleId}@${args.version}`;
403
+ const res = await fetchWithTimeout(url, {
404
+ method: 'POST',
405
+ headers: { ...authHeaders(options), 'content-type': 'application/json' },
406
+ body: JSON.stringify({ version: args.version, ...(args.reason !== undefined ? { reason: args.reason } : {}) }),
407
+ timeoutMs: options.timeoutMs,
408
+ diagnosticLabel: DIAGNOSTIC_LABEL,
409
+ });
410
+ if (res.status === 409) {
411
+ // Only the release-state refusal is the no-op; a 409 for anything else (the registry giving up after
412
+ // lost optimistic checks) is a FAILED yank and must not be reported as done (#1758 re-review R3).
413
+ const body = await res.json().catch(() => undefined);
414
+ if (isRegistryReleaseStateConflictBody(body)) throw new RegistryYankStateConflictError(args.moduleId, args.version, verb);
415
+ throw new RegistryUnexpectedStatusError(options.baseUrl, res.status, res.statusText, action);
416
+ }
417
+ // A 404 names the module or the version; the sanitized envelope does not say which.
418
+ await refuseUnlessOk(res, options, action, () => new RegistryVersionNotFoundError(args.moduleId, args.version));
419
+ const parsed = RegistryYankResponseSchema.safeParse(await parseJsonOrThrow(res, options.baseUrl, action, options.timeoutMs));
420
+ if (!parsed.success) {
421
+ throw new RegistryMalformedPayloadError(options.baseUrl, action, formatZodIssuesAsSingleLine(parsed.error));
422
+ }
423
+ return { latest: parsed.data.latest ?? null };
424
+ }