@agentique.io/readback 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  Read-only public readback helpers for Agentique resources.
4
4
 
5
- This package helps integrators consume public Agentique resource status from `agentique.io` when the platform exposes versioned public endpoints. It exposes status, list, detail, download metadata, readback projection, context bundle, and selection readback helpers only.
5
+ This package helps integrators consume public Agentique resource status from `agentique.io` when the platform exposes versioned public endpoints. It exposes status, list, detail, download metadata, readback projection, context bundle, selection readback, catalog normalization, and safe byte-download helpers only.
6
6
 
7
- `agentique.io` remains the source of truth for upload, scan, review, moderation, publication, distribution state, and readback. This package does not publish, edit, delete, moderate, or approve resources.
7
+ `agentique.io` remains the source of truth for upload, scan, review, moderation, publication, distribution state, and readback. This package does not publish, edit, delete, moderate, approve, certify, install, extract, open, or execute resources.
8
+
9
+ Catalog and direct-download helpers are included in the 0.2.0 package source. The helpers remain read-only or explicit-output only, and they do not make a live direct-download availability claim unless owner-approved disposable byte-transfer evidence is recorded.
8
10
 
9
11
  ## Install
10
12
 
@@ -15,13 +17,29 @@ npm install @agentique.io/readback
15
17
  ## Usage
16
18
 
17
19
  ```js
18
- import { createBadgeState, createReadbackClient } from "@agentique.io/readback";
20
+ import {
21
+ createBadgeState,
22
+ createReadbackClient,
23
+ downloadResourceArtifact,
24
+ normalizeDownloadMetadata,
25
+ normalizeParserVariantReadback,
26
+ normalizeResourceList,
27
+ normalizeTrustReadback
28
+ } from "@agentique.io/readback";
19
29
 
20
30
  const client = createReadbackClient();
31
+ const catalog = normalizeResourceList(await client.listResources({ limit: 10 }));
32
+ const metadata = normalizeDownloadMetadata(await client.getDownloadMetadata("resource-id"));
21
33
  const readback = await client.getReadback("resource-id");
34
+ const trust = normalizeTrustReadback(readback);
35
+ const parserVariant = normalizeParserVariantReadback(readback);
22
36
  const badge = createBadgeState(readback);
23
37
 
24
38
  console.log(badge.label);
39
+ console.log(trust.trustPanel?.state ?? trust.platformState);
40
+ console.log(parserVariant.parserEvidence?.parseStatus ?? "unavailable");
41
+ console.log(`${catalog.items.length} catalog entries`);
42
+ console.log(metadata.availability);
25
43
  ```
26
44
 
27
45
  ## Read-Only Client
@@ -44,19 +62,34 @@ Context bundle and selection readback helpers use narrow query allowlists for pu
44
62
 
45
63
  Returned payloads are normalized with a defense-in-depth projection pass that removes explicitly private fields while preserving public schema fields such as `internalId`, `storageUsage`, `deploymentDate`, `tokenCount`, `objectType`, and `storageMode`. The platform API remains responsible for the authoritative public projection; client-side normalization is not a privacy boundary.
46
64
 
65
+ `normalizeTrustReadback()` projects public desired-state, scanner-policy, trust-panel, review-eligibility, report-action, and version-history fields into a stable readback summary when those fields are present.
66
+
67
+ `normalizeResourceList()` projects public catalog list payloads into stable item and page-info fields. `normalizeDownloadMetadata()` projects public download metadata into availability, filename, media type, size, digest, and expiry fields while filtering private projection fields.
68
+
69
+ `normalizeParserVariantReadback()` projects public parser evidence and platform variant fields into a bounded summary when those fields are present. It reports digest presence instead of raw digests and keeps parser/variant state descriptive. Source-only variant metadata remains preparation evidence and is not treated as platform download readiness.
70
+
71
+ `downloadResourceArtifact()` can write available artifact bytes to an explicit output path. It enforces HTTPS outside loopback development, manual redirect handling, no-overwrite by default, safe filename/path checks, temp-file cleanup, size limits, and digest verification. It does not install, extract, open, execute, approve, certify, publish, host, or moderate downloaded content. Treat downloaded bytes as untrusted until separately reviewed.
72
+
47
73
  ## Badge States
48
74
 
49
75
  Badge helpers return explicit states:
50
76
 
51
77
  - `published`
78
+ - `parsed`
79
+ - `partial`
80
+ - `unsupported`
81
+ - `variant-available`
52
82
  - `review-required`
83
+ - `rescan-required`
53
84
  - `blocked`
54
85
  - `stale`
55
86
  - `unavailable`
56
87
  - `rate-limited`
57
88
 
89
+ Parser and variant badge states are public readback summaries. They do not prove runtime compatibility, create platform downloads, or replace platform review.
90
+
58
91
  Badge output is a public readback summary, not a safety guarantee.
59
92
 
60
93
  ## Status
61
94
 
62
- Local implementation exists for review. The package has not been published yet.
95
+ Published as `@agentique.io/readback`. Badge output is a public readback summary, not a platform approval or safety guarantee.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentique.io/readback",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Read-only public readback helpers for Agentique resources.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/badge.mjs CHANGED
@@ -1,6 +1,30 @@
1
1
  const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
2
2
 
3
3
  const BADGE_STATES = Object.freeze({
4
+ parsed: {
5
+ state: "parsed",
6
+ label: "Parsed",
7
+ color: "0969da",
8
+ description: "Public readback includes parsed parser metadata."
9
+ },
10
+ partial: {
11
+ state: "partial",
12
+ label: "Parser partial",
13
+ color: "bf8700",
14
+ description: "Public readback shows parser metadata that needs review."
15
+ },
16
+ unsupported: {
17
+ state: "unsupported",
18
+ label: "Parser unsupported",
19
+ color: "6e7781",
20
+ description: "Public readback marks the parser or variant target as unsupported."
21
+ },
22
+ "variant-available": {
23
+ state: "variant-available",
24
+ label: "Variant available",
25
+ color: "0969da",
26
+ description: "Public readback includes a platform variant projection."
27
+ },
4
28
  published: {
5
29
  state: "published",
6
30
  label: "Published",
@@ -13,6 +37,12 @@ const BADGE_STATES = Object.freeze({
13
37
  color: "bf8700",
14
38
  description: "The platform readback requires review before normal public use."
15
39
  },
40
+ "rescan-required": {
41
+ state: "rescan-required",
42
+ label: "Rescan required",
43
+ color: "9a6700",
44
+ description: "The platform readback indicates local content should be scanned again before normal public use."
45
+ },
16
46
  blocked: {
17
47
  state: "blocked",
18
48
  label: "Blocked",
@@ -56,6 +86,16 @@ export function createBadgeState(readback, options = {}) {
56
86
  return badge("stale", { observedAt });
57
87
  }
58
88
 
89
+ const parserVariantState = parserVariantBadgeState(readback);
90
+ if (parserVariantState) {
91
+ return badge(parserVariantState, { platformUrl: readback.platformUrl ?? readback.url ?? null });
92
+ }
93
+
94
+ const trustState = trustBadgeState(readback);
95
+ if (trustState) {
96
+ return badge(trustState, { platformUrl: readback.platformUrl ?? readback.url ?? null });
97
+ }
98
+
59
99
  const status = normalizeStatus(readback.status ?? readback.publicationStatus ?? readback.state);
60
100
 
61
101
  if (status === "published") {
@@ -94,6 +134,88 @@ function badge(state, extras = {}) {
94
134
  });
95
135
  }
96
136
 
137
+ function trustBadgeState(readback) {
138
+ const desiredState = normalizeStatus(readback.desiredState?.readbackState);
139
+ const scannerFreshness = normalizeStatus(readback.scannerPolicy?.freshness);
140
+ const trustPanelState = normalizeStatus(readback.trustPanel?.state);
141
+ const reviewState = normalizeStatus(readback.reviewEligibility?.state);
142
+ const platformState = normalizeStatus(readback.platformProjection?.publicationState);
143
+
144
+ if ([trustPanelState, platformState].some((state) => ["blocked", "quarantined", "rejected"].includes(state))) {
145
+ return "blocked";
146
+ }
147
+
148
+ if ([desiredState, scannerFreshness, trustPanelState].includes("rescan-required")) {
149
+ return "rescan-required";
150
+ }
151
+
152
+ if (
153
+ [desiredState, trustPanelState, platformState].includes("review-required") ||
154
+ reviewState === "needs-evidence" ||
155
+ reviewState === "creator-blocked"
156
+ ) {
157
+ return "review-required";
158
+ }
159
+
160
+ if (platformState === "published" || trustPanelState === "current") {
161
+ return "published";
162
+ }
163
+
164
+ if ([desiredState, scannerFreshness, trustPanelState, platformState].includes("stale")) {
165
+ return "stale";
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ function parserVariantBadgeState(readback) {
172
+ const parserVariant = readback?.parserVariant;
173
+ if (!parserVariant || typeof parserVariant !== "object" || Array.isArray(parserVariant)) {
174
+ return null;
175
+ }
176
+
177
+ const parserStatus = normalizeStatus(parserVariant.parserEvidence?.parseStatus);
178
+ const compatibilityStatus = normalizeStatus(parserVariant.compatibility?.status);
179
+ const platformVariants = Array.isArray(parserVariant.platformVariants) ? parserVariant.platformVariants : [];
180
+ const variantStates = platformVariants.map((variant) => normalizeStatus(variant?.state));
181
+ const validationStates = platformVariants.map((variant) => normalizeStatus(variant?.validationState));
182
+ const downloadStates = platformVariants.map((variant) => normalizeStatus(variant?.download?.availability));
183
+
184
+ if (
185
+ ["blocked", "failed"].includes(parserStatus) ||
186
+ compatibilityStatus === "blocked" ||
187
+ variantStates.includes("blocked")
188
+ ) {
189
+ return "blocked";
190
+ }
191
+
192
+ if (variantStates.includes("stale") || validationStates.includes("stale")) {
193
+ return "stale";
194
+ }
195
+
196
+ if (parserStatus === "unsupported" || compatibilityStatus === "unsupported" || variantStates.includes("unsupported")) {
197
+ return "unsupported";
198
+ }
199
+
200
+ if (parserStatus === "partial" || compatibilityStatus === "partial" || variantStates.includes("review-required")) {
201
+ return "partial";
202
+ }
203
+
204
+ if (
205
+ variantStates.includes("available") ||
206
+ downloadStates.includes("available") ||
207
+ downloadStates.includes("source-only")
208
+ ) {
209
+ return "variant-available";
210
+ }
211
+
212
+ if (parserStatus === "parsed") {
213
+ return "parsed";
214
+ }
215
+
216
+ return null;
217
+ }
218
+
97
219
  function isStale(value, now, staleAfterSeconds) {
98
220
  const observedAt = toDate(value);
99
221
  const ageMs = now.getTime() - observedAt.getTime();
@@ -112,5 +234,6 @@ function normalizeStatus(value) {
112
234
  return String(value ?? "")
113
235
  .trim()
114
236
  .toLowerCase()
237
+ .replace(/_/g, "-")
115
238
  .replace(/\s+/g, "-");
116
239
  }
package/src/client.mjs CHANGED
@@ -89,6 +89,21 @@ export function createReadbackClient(options = {}) {
89
89
  });
90
90
  }
91
91
 
92
+ if (response.status === 404) {
93
+ throw new ReadbackError("Readback resource was not found.", {
94
+ code: "not-found",
95
+ status: 404
96
+ });
97
+ }
98
+
99
+ if (response.status >= 500) {
100
+ throw new ReadbackError("Readback endpoint is unavailable.", {
101
+ code: "unavailable",
102
+ status: response.status,
103
+ retryAfter: response.headers?.get?.("retry-after") ?? null
104
+ });
105
+ }
106
+
92
107
  if (!response.ok) {
93
108
  throw new ReadbackError("Readback request failed.", {
94
109
  code: "http-error",
@@ -183,6 +198,278 @@ export function normalizePublicReadback(value) {
183
198
  return Object.freeze(normalized);
184
199
  }
185
200
 
201
+ export function normalizeResourceList(value) {
202
+ const normalized = normalizePublicReadback(value);
203
+ if (Array.isArray(normalized)) {
204
+ return Object.freeze({
205
+ items: Object.freeze(normalized.filter(isRecord).map(projectResourceListItem)),
206
+ pageInfo: emptyPageInfo(),
207
+ observedAt: null
208
+ });
209
+ }
210
+
211
+ if (!isRecord(normalized)) {
212
+ return emptyResourceList();
213
+ }
214
+
215
+ const sourceItems = Array.isArray(normalized.items)
216
+ ? normalized.items
217
+ : Array.isArray(normalized.resources)
218
+ ? normalized.resources
219
+ : [];
220
+
221
+ return Object.freeze({
222
+ items: Object.freeze(sourceItems.filter(isRecord).map(projectResourceListItem)),
223
+ pageInfo: projectPageInfo(normalized.pageInfo),
224
+ observedAt: stringOrNull(normalized.observedAt ?? normalized.updatedAt)
225
+ });
226
+ }
227
+
228
+ export function normalizeDownloadMetadata(value) {
229
+ const normalized = normalizePublicReadback(value);
230
+ if (!isRecord(normalized)) {
231
+ return emptyDownloadMetadata();
232
+ }
233
+
234
+ const download = isRecord(normalized.download) ? normalized.download : {};
235
+ const digestValue = firstString(download.digest, normalized.digest, download.sha256, normalized.sha256);
236
+ const digest = projectDigest(digestValue);
237
+
238
+ return Object.freeze({
239
+ resourceId: stringOrNull(normalized.resourceId ?? normalized.id),
240
+ platformId: stringOrNull(download.platformId ?? normalized.platformId),
241
+ artifactKind: stringOrNull(download.artifactKind ?? normalized.artifactKind),
242
+ availability: normalizePublicState(download.availability ?? normalized.availability ?? normalized.status ?? normalized.state),
243
+ url: stringOrNull(download.url ?? normalized.downloadUrl ?? normalized.url),
244
+ filename: stringOrNull(download.filename ?? download.fileName ?? normalized.filename ?? normalized.fileName),
245
+ mediaType: stringOrNull(download.mediaType ?? normalized.mediaType ?? download.contentType ?? normalized.contentType),
246
+ sizeBytes: numberOrNull(download.sizeBytes ?? download.size ?? normalized.sizeBytes ?? normalized.size ?? normalized.contentLength),
247
+ digest,
248
+ digestPresent: typeof digestValue === "string",
249
+ digestValid: typeof digestValue !== "string" || digest !== null,
250
+ reasons: arrayOfStrings(download.reasons ?? normalized.reasons),
251
+ observedAt: stringOrNull(download.observedAt ?? normalized.observedAt ?? normalized.updatedAt),
252
+ expiresAt: stringOrNull(download.expiresAt ?? normalized.expiresAt)
253
+ });
254
+ }
255
+
256
+ export function normalizeTrustReadback(value) {
257
+ const normalized = normalizePublicReadback(value);
258
+ if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
259
+ return Object.freeze({
260
+ platformState: "unavailable",
261
+ desiredState: null,
262
+ scannerPolicy: null,
263
+ trustPanel: null,
264
+ reviewEligibility: null,
265
+ reportActionState: null,
266
+ versionHistory: []
267
+ });
268
+ }
269
+
270
+ const platformProjection = isRecord(normalized.platformProjection) ? normalized.platformProjection : {};
271
+ const desiredState = isRecord(normalized.desiredState) ? normalized.desiredState : null;
272
+ const scannerPolicy = isRecord(normalized.scannerPolicy) ? normalized.scannerPolicy : null;
273
+ const trustPanel = isRecord(normalized.trustPanel) ? normalized.trustPanel : null;
274
+ const reviewEligibility = isRecord(normalized.reviewEligibility) ? normalized.reviewEligibility : null;
275
+ const versionHistory = Array.isArray(normalized.versionHistory) ? normalized.versionHistory.filter(isRecord).map(projectVersion) : [];
276
+
277
+ return Object.freeze({
278
+ platformState: normalizePublicState(platformProjection.publicationState ?? normalized.status ?? normalized.state),
279
+ desiredState: desiredState
280
+ ? Object.freeze({
281
+ state: normalizePublicState(desiredState.readbackState),
282
+ fingerprintPresent: typeof desiredState.fingerprint === "string",
283
+ reasons: arrayOfStrings(desiredState.reasons)
284
+ })
285
+ : null,
286
+ scannerPolicy: scannerPolicy
287
+ ? Object.freeze({
288
+ policyVersion: stringOrNull(scannerPolicy.policyVersion),
289
+ freshness: normalizePublicState(scannerPolicy.freshness)
290
+ })
291
+ : null,
292
+ trustPanel: trustPanel
293
+ ? Object.freeze({
294
+ state: normalizePublicState(trustPanel.state),
295
+ messages: arrayOfStrings(trustPanel.messages),
296
+ versionHistoryUrl: stringOrNull(trustPanel.versionHistoryUrl)
297
+ })
298
+ : null,
299
+ reviewEligibility: reviewEligibility
300
+ ? Object.freeze({
301
+ state: normalizePublicState(reviewEligibility.state),
302
+ evidenceTypes: arrayOfStrings(reviewEligibility.evidenceTypes),
303
+ reasons: arrayOfStrings(reviewEligibility.reasons)
304
+ })
305
+ : null,
306
+ reportActionState: stringOrNull(normalized.reportActionState),
307
+ versionHistory: Object.freeze(versionHistory)
308
+ });
309
+ }
310
+
311
+ export function normalizeParserVariantReadback(value) {
312
+ const normalized = normalizePublicReadback(value);
313
+ const parserVariant = isRecord(normalized?.parserVariant) ? normalized.parserVariant : normalized;
314
+
315
+ if (!isRecord(parserVariant)) {
316
+ return emptyParserVariantSummary();
317
+ }
318
+
319
+ const parserEvidence = isRecord(parserVariant.parserEvidence) ? parserVariant.parserEvidence : null;
320
+ const resourceGraphSummary = isRecord(parserVariant.resourceGraphSummary) ? parserVariant.resourceGraphSummary : null;
321
+ const compatibility = isRecord(parserVariant.compatibility) ? parserVariant.compatibility : null;
322
+ const platformVariants = Array.isArray(parserVariant.platformVariants) ? parserVariant.platformVariants.filter(isRecord) : [];
323
+
324
+ return Object.freeze({
325
+ parserEvidence: parserEvidence
326
+ ? Object.freeze({
327
+ sourceEcosystem: stringOrNull(parserEvidence.sourceEcosystem),
328
+ sourceFormat: stringOrNull(parserEvidence.sourceFormat),
329
+ parseStatus: normalizePublicState(parserEvidence.parseStatus),
330
+ parseConfidence: normalizePublicState(parserEvidence.parseConfidence),
331
+ sanitizerStatus: normalizePublicState(parserEvidence.sanitizerStatus),
332
+ noExecution: parserEvidence.noExecution === true,
333
+ inputDigestPresent: typeof parserEvidence.inputDigest === "string",
334
+ outputDigestPresent: typeof parserEvidence.outputDigest === "string",
335
+ issueCount: Array.isArray(parserEvidence.issues) ? parserEvidence.issues.filter(isRecord).length : 0
336
+ })
337
+ : null,
338
+ resourceGraphSummary: resourceGraphSummary
339
+ ? Object.freeze({
340
+ sanitized: resourceGraphSummary.sanitized === true,
341
+ nodeCount: numberOrNull(resourceGraphSummary.nodeCount),
342
+ edgeCount: numberOrNull(resourceGraphSummary.edgeCount),
343
+ capabilityCount: numberOrNull(resourceGraphSummary.capabilityCount),
344
+ sourceFileCount: numberOrNull(resourceGraphSummary.sourceFileCount),
345
+ summaryDigestPresent: typeof resourceGraphSummary.summaryDigest === "string"
346
+ })
347
+ : null,
348
+ compatibility: compatibility
349
+ ? Object.freeze({
350
+ status: normalizePublicState(compatibility.status),
351
+ reasons: arrayOfStrings(compatibility.reasons)
352
+ })
353
+ : null,
354
+ platformVariants: Object.freeze(
355
+ platformVariants.map((variant) => {
356
+ const download = isRecord(variant.download) ? variant.download : {};
357
+ return Object.freeze({
358
+ platformId: stringOrNull(variant.platformId),
359
+ artifactKind: stringOrNull(variant.artifactKind),
360
+ state: normalizePublicState(variant.state),
361
+ validationState: normalizePublicState(variant.validationState),
362
+ downloadAvailability: normalizePublicState(download.availability),
363
+ downloadUrl: stringOrNull(download.url),
364
+ variantDigestPresent: typeof variant.variantDigest === "string",
365
+ downloadDigestPresent: typeof download.digest === "string",
366
+ reasons: arrayOfStrings(variant.reasons),
367
+ observedAt: stringOrNull(variant.observedAt)
368
+ });
369
+ })
370
+ ),
371
+ observedAt: stringOrNull(parserVariant.observedAt ?? normalized?.observedAt ?? normalized?.updatedAt)
372
+ });
373
+ }
374
+
375
+ function emptyParserVariantSummary() {
376
+ return Object.freeze({
377
+ parserEvidence: null,
378
+ resourceGraphSummary: null,
379
+ compatibility: null,
380
+ platformVariants: Object.freeze([]),
381
+ observedAt: null
382
+ });
383
+ }
384
+
385
+ function emptyResourceList() {
386
+ return Object.freeze({
387
+ items: Object.freeze([]),
388
+ pageInfo: emptyPageInfo(),
389
+ observedAt: null
390
+ });
391
+ }
392
+
393
+ function emptyPageInfo() {
394
+ return Object.freeze({
395
+ page: null,
396
+ pageSize: null,
397
+ total: null,
398
+ cursor: null,
399
+ nextCursor: null,
400
+ hasNextPage: false
401
+ });
402
+ }
403
+
404
+ function emptyDownloadMetadata() {
405
+ return Object.freeze({
406
+ resourceId: null,
407
+ platformId: null,
408
+ artifactKind: null,
409
+ availability: "unavailable",
410
+ url: null,
411
+ filename: null,
412
+ mediaType: null,
413
+ sizeBytes: null,
414
+ digest: null,
415
+ digestPresent: false,
416
+ digestValid: true,
417
+ reasons: Object.freeze([]),
418
+ observedAt: null,
419
+ expiresAt: null
420
+ });
421
+ }
422
+
423
+ function projectResourceListItem(item) {
424
+ return Object.freeze({
425
+ resourceId: stringOrNull(item.resourceId ?? item.id),
426
+ slug: stringOrNull(item.slug),
427
+ title: stringOrNull(item.title ?? item.name),
428
+ summary: stringOrNull(item.summary ?? item.description),
429
+ type: stringOrNull(item.type ?? item.resourceType),
430
+ status: normalizePublicState(item.status ?? item.state ?? item.publicationState),
431
+ platformUrl: stringOrNull(item.platformUrl ?? item.resourceUrl ?? item.url),
432
+ downloadAvailability: normalizePublicState(item.downloadAvailability ?? item.download?.availability),
433
+ updatedAt: stringOrNull(item.updatedAt ?? item.observedAt)
434
+ });
435
+ }
436
+
437
+ function projectPageInfo(value) {
438
+ if (!isRecord(value)) {
439
+ return emptyPageInfo();
440
+ }
441
+
442
+ return Object.freeze({
443
+ page: numberOrNull(value.page),
444
+ pageSize: numberOrNull(value.pageSize ?? value.limit),
445
+ total: numberOrNull(value.total),
446
+ cursor: stringOrNull(value.cursor),
447
+ nextCursor: stringOrNull(value.nextCursor ?? value.endCursor),
448
+ hasNextPage: value.hasNextPage === true
449
+ });
450
+ }
451
+
452
+ function projectDigest(value) {
453
+ if (typeof value !== "string") {
454
+ return null;
455
+ }
456
+
457
+ const trimmed = value.trim();
458
+ const prefixed = /^([A-Za-z0-9-]+):([A-Fa-f0-9]+)$/.exec(trimmed);
459
+ if (!prefixed) {
460
+ return null;
461
+ }
462
+
463
+ return Object.freeze({
464
+ algorithm: prefixed[1].toLowerCase(),
465
+ value: prefixed[2].toLowerCase()
466
+ });
467
+ }
468
+
469
+ function firstString(...values) {
470
+ return values.find((value) => typeof value === "string");
471
+ }
472
+
186
473
  function isPrivateProjectionKey(key) {
187
474
  const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
188
475
  return (
@@ -195,6 +482,39 @@ function isPrivateProjectionKey(key) {
195
482
  );
196
483
  }
197
484
 
485
+ function projectVersion(entry) {
486
+ return Object.freeze({
487
+ version: stringOrNull(entry.version),
488
+ observedAt: stringOrNull(entry.observedAt),
489
+ state: normalizePublicState(entry.state),
490
+ desiredStateFingerprintPresent: typeof entry.desiredStateFingerprint === "string"
491
+ });
492
+ }
493
+
494
+ function arrayOfStrings(value) {
495
+ return Object.freeze(Array.isArray(value) ? value.filter((item) => typeof item === "string") : []);
496
+ }
497
+
498
+ function stringOrNull(value) {
499
+ return typeof value === "string" ? value : null;
500
+ }
501
+
502
+ function numberOrNull(value) {
503
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
504
+ }
505
+
506
+ function normalizePublicState(value) {
507
+ return String(value ?? "unknown")
508
+ .trim()
509
+ .toLowerCase()
510
+ .replace(/_/g, "-")
511
+ .replace(/\s+/g, "-");
512
+ }
513
+
514
+ function isRecord(value) {
515
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
516
+ }
517
+
198
518
  export function assertReadOnlyClientSurface(client) {
199
519
  const methodNames = Object.keys(client);
200
520
  const violatingMethod = methodNames.find((methodName) =>
@@ -211,7 +531,9 @@ export function assertReadOnlyClientSurface(client) {
211
531
  }
212
532
 
213
533
  function buildUrl(baseUrl, path, params = {}) {
214
- const url = new URL(`${baseUrl.pathname}${path}`, baseUrl);
534
+ const basePath = baseUrl.pathname === "/" ? "" : baseUrl.pathname.replace(/\/+$/, "");
535
+ const endpointPath = path.startsWith("/") ? path : `/${path}`;
536
+ const url = new URL(`${basePath}${endpointPath}`, baseUrl);
215
537
 
216
538
  for (const [key, value] of Object.entries(params)) {
217
539
  if (value === undefined || value === null || value === "") {
@@ -224,18 +546,40 @@ function buildUrl(baseUrl, path, params = {}) {
224
546
  }
225
547
 
226
548
  function pickListParams(params) {
549
+ if (!isRecord(params)) {
550
+ throw new ReadbackError("List resources params must be an object.", { code: "invalid-list-params" });
551
+ }
552
+
227
553
  const allowed = ["q", "type", "cursor", "limit", "status"];
228
554
  const picked = {};
229
555
 
230
556
  for (const key of allowed) {
231
557
  if (Object.hasOwn(params, key)) {
232
- picked[key] = params[key];
558
+ if (key === "limit") {
559
+ if (params[key] !== undefined && params[key] !== null && params[key] !== "") {
560
+ picked[key] = normalizeListLimit(params[key]);
561
+ }
562
+ } else if (params[key] !== undefined && params[key] !== null && params[key] !== "") {
563
+ picked[key] = String(params[key]);
564
+ }
233
565
  }
234
566
  }
235
567
 
236
568
  return picked;
237
569
  }
238
570
 
571
+ function normalizeListLimit(value) {
572
+ const numeric = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
573
+
574
+ if (!Number.isInteger(numeric) || numeric < 1 || numeric > 100) {
575
+ throw new ReadbackError("List resources limit must be an integer from 1 to 100.", {
576
+ code: "invalid-list-limit"
577
+ });
578
+ }
579
+
580
+ return numeric;
581
+ }
582
+
239
583
  function pickContextBundleParams(params) {
240
584
  return pickAllowedParams(params, ["intent", "audience", "limit"]);
241
585
  }
@@ -0,0 +1,401 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ import { Readable, Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { ReadbackError, normalizeDownloadMetadata } from "./client.mjs";
8
+
9
+ const DEFAULT_MAX_REDIRECTS = 3;
10
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
11
+ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
12
+ const UNSAFE_FILENAME_PATTERN = /[<>:"/\\|?*\x00-\x1f]/;
13
+
14
+ export async function downloadResourceArtifact(options = {}) {
15
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
16
+ throw new ReadbackError("Download options are required.", { code: "invalid-download-options" });
17
+ }
18
+
19
+ const metadata = await resolveMetadata(options);
20
+ const normalized = isNormalizedDownloadMetadata(metadata) ? metadata : normalizeDownloadMetadata(metadata);
21
+
22
+ if (normalized.availability !== "available") {
23
+ throw new ReadbackError("Download is not available for this resource.", {
24
+ code: "download-unavailable"
25
+ });
26
+ }
27
+ if (!normalized.url) {
28
+ throw new ReadbackError("Download metadata is missing a public URL.", {
29
+ code: "missing-download-url"
30
+ });
31
+ }
32
+ if (normalized.digestPresent && !normalized.digestValid) {
33
+ throw new ReadbackError("Download metadata includes an invalid digest.", {
34
+ code: "invalid-download-digest"
35
+ });
36
+ }
37
+
38
+ const maxBytes = normalizeMaxBytes(options.maxBytes);
39
+ if (maxBytes !== null && normalized.sizeBytes !== null && normalized.sizeBytes > maxBytes) {
40
+ throw new ReadbackError("Download exceeds the configured maximum byte count.", {
41
+ code: "download-too-large"
42
+ });
43
+ }
44
+
45
+ const initialUrl = parseSafeDownloadUrl(normalized.url);
46
+ const target = await resolveOutputTarget({
47
+ cwd: options.cwd,
48
+ outputPath: options.outputPath,
49
+ filename: normalized.filename,
50
+ url: initialUrl,
51
+ force: options.force === true
52
+ });
53
+
54
+ let tempPath = target.tempPath;
55
+ try {
56
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
57
+ if (typeof fetchImpl !== "function") {
58
+ throw new ReadbackError("A fetch implementation is required.", { code: "missing-fetch" });
59
+ }
60
+
61
+ const { response } = await fetchWithSafeRedirects(initialUrl, {
62
+ fetchImpl,
63
+ allowedRedirectOrigins: options.allowedRedirectOrigins,
64
+ maxRedirects: options.maxRedirects
65
+ });
66
+
67
+ if (!response.ok) {
68
+ throw new ReadbackError("Download request failed.", {
69
+ code: "download-http-error",
70
+ status: response.status
71
+ });
72
+ }
73
+
74
+ validateContentLength(response.headers?.get?.("content-length") ?? null, {
75
+ expectedSize: normalized.sizeBytes,
76
+ maxBytes
77
+ });
78
+
79
+ const digest = prepareDigest(normalized.digest);
80
+ const result = await streamResponseToTempFile(response, tempPath, {
81
+ digest,
82
+ maxBytes
83
+ });
84
+
85
+ if (normalized.sizeBytes !== null && result.bytesWritten !== normalized.sizeBytes) {
86
+ throw new ReadbackError("Downloaded byte count does not match metadata.", {
87
+ code: "download-size-mismatch"
88
+ });
89
+ }
90
+ if (digest && result.digestValue !== digest.value) {
91
+ throw new ReadbackError("Downloaded digest does not match metadata.", {
92
+ code: "download-digest-mismatch"
93
+ });
94
+ }
95
+
96
+ if (!options.force && (await pathExists(target.finalPath))) {
97
+ throw new ReadbackError("Output file already exists.", { code: "output-exists" });
98
+ }
99
+
100
+ await rename(tempPath, target.finalPath);
101
+ tempPath = null;
102
+
103
+ return Object.freeze({
104
+ ok: true,
105
+ resourceId: normalized.resourceId,
106
+ outputPath: target.finalPath,
107
+ filename: target.filename,
108
+ bytesWritten: result.bytesWritten,
109
+ digest: digest ? Object.freeze({ algorithm: digest.algorithm, value: result.digestValue }) : null,
110
+ mediaType: normalized.mediaType
111
+ });
112
+ } catch (error) {
113
+ if (tempPath) {
114
+ await rm(tempPath, { force: true });
115
+ }
116
+ if (error instanceof ReadbackError) {
117
+ throw error;
118
+ }
119
+ throw new ReadbackError("Download failed.", {
120
+ code: "download-failed",
121
+ cause: error
122
+ });
123
+ }
124
+ }
125
+
126
+ async function resolveMetadata(options) {
127
+ if (options.metadata) {
128
+ return options.metadata;
129
+ }
130
+ if (!options.client || typeof options.client.getDownloadMetadata !== "function") {
131
+ throw new ReadbackError("Download metadata or a readback client is required.", {
132
+ code: "missing-download-metadata"
133
+ });
134
+ }
135
+ if (typeof options.resourceId !== "string" || options.resourceId.trim() === "") {
136
+ throw new ReadbackError("Resource id is required.", { code: "missing-resource-id" });
137
+ }
138
+ return options.client.getDownloadMetadata(options.resourceId);
139
+ }
140
+
141
+ function isNormalizedDownloadMetadata(value) {
142
+ return Boolean(
143
+ value &&
144
+ typeof value === "object" &&
145
+ !Array.isArray(value) &&
146
+ typeof value.availability === "string" &&
147
+ Object.hasOwn(value, "digestPresent") &&
148
+ Object.hasOwn(value, "digestValid")
149
+ );
150
+ }
151
+
152
+ function normalizeMaxBytes(value) {
153
+ if (value === undefined || value === null) {
154
+ return null;
155
+ }
156
+ const numeric = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
157
+ if (!Number.isSafeInteger(numeric) || numeric < 1) {
158
+ throw new ReadbackError("maxBytes must be a positive safe integer.", {
159
+ code: "invalid-max-bytes"
160
+ });
161
+ }
162
+ return numeric;
163
+ }
164
+
165
+ function parseSafeDownloadUrl(value) {
166
+ let parsed;
167
+ try {
168
+ parsed = new URL(value);
169
+ } catch (error) {
170
+ throw new ReadbackError("Download URL is invalid.", {
171
+ code: "unsafe-download-url",
172
+ cause: error
173
+ });
174
+ }
175
+
176
+ if (parsed.protocol === "https:" || (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname))) {
177
+ parsed.hash = "";
178
+ return parsed;
179
+ }
180
+
181
+ throw new ReadbackError("Download URL must use HTTPS outside loopback development.", {
182
+ code: "unsafe-download-url"
183
+ });
184
+ }
185
+
186
+ async function resolveOutputTarget({ cwd, outputPath, filename, url, force }) {
187
+ if (typeof outputPath !== "string" || outputPath.trim() === "") {
188
+ throw new ReadbackError("An output path is required.", { code: "missing-output-path" });
189
+ }
190
+ if (hasParentPathSegment(outputPath)) {
191
+ throw new ReadbackError("Output path must not contain parent-directory traversal.", {
192
+ code: "unsafe-output-path"
193
+ });
194
+ }
195
+
196
+ const baseCwd = cwd ? path.resolve(cwd) : process.cwd();
197
+ const resolvedOutput = path.resolve(baseCwd, outputPath);
198
+ const directoryMode = outputPath.endsWith("/") || outputPath.endsWith("\\") || (await isDirectory(resolvedOutput));
199
+ const finalPath = directoryMode
200
+ ? path.resolve(resolvedOutput, validateSafeFilename(filename || filenameFromUrl(url)))
201
+ : resolvedOutput;
202
+ const outputRoot = directoryMode ? resolvedOutput : path.dirname(finalPath);
203
+ const finalName = path.basename(finalPath);
204
+
205
+ validateSafeFilename(finalName);
206
+ assertPathInside(outputRoot, finalPath);
207
+ await mkdir(path.dirname(finalPath), { recursive: true });
208
+
209
+ if (!force && (await pathExists(finalPath))) {
210
+ throw new ReadbackError("Output file already exists.", { code: "output-exists" });
211
+ }
212
+
213
+ const tempPath = path.join(path.dirname(finalPath), `.${finalName}.${process.pid}.${Date.now()}.tmp`);
214
+ return { finalPath, tempPath, filename: finalName };
215
+ }
216
+
217
+ async function isDirectory(value) {
218
+ try {
219
+ return (await stat(value)).isDirectory();
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ async function pathExists(value) {
226
+ try {
227
+ await stat(value);
228
+ return true;
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ function validateSafeFilename(value) {
235
+ if (
236
+ typeof value !== "string" ||
237
+ value.trim() === "" ||
238
+ value === "." ||
239
+ value === ".." ||
240
+ value.includes("/") ||
241
+ value.includes("\\") ||
242
+ UNSAFE_FILENAME_PATTERN.test(value) ||
243
+ WINDOWS_RESERVED_NAMES.test(value)
244
+ ) {
245
+ throw new ReadbackError("Download filename is unsafe.", { code: "unsafe-output-filename" });
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function filenameFromUrl(url) {
251
+ const name = path.basename(decodeURIComponent(url.pathname));
252
+ if (!name || name === "/" || name === ".") {
253
+ throw new ReadbackError("Download metadata is missing a safe filename.", {
254
+ code: "missing-download-filename"
255
+ });
256
+ }
257
+ return name;
258
+ }
259
+
260
+ function assertPathInside(root, target) {
261
+ const relative = path.relative(path.resolve(root), path.resolve(target));
262
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
263
+ return;
264
+ }
265
+ throw new ReadbackError("Output path escapes the selected output directory.", {
266
+ code: "unsafe-output-path"
267
+ });
268
+ }
269
+
270
+ function hasParentPathSegment(value) {
271
+ return String(value)
272
+ .split(/[\\/]+/)
273
+ .some((segment) => segment === "..");
274
+ }
275
+
276
+ async function fetchWithSafeRedirects(initialUrl, { fetchImpl, allowedRedirectOrigins, maxRedirects }) {
277
+ const allowedOrigins = new Set([initialUrl.origin, ...(Array.isArray(allowedRedirectOrigins) ? allowedRedirectOrigins : [])]);
278
+ const redirectLimit = maxRedirects === undefined ? DEFAULT_MAX_REDIRECTS : normalizeRedirectLimit(maxRedirects);
279
+ let current = initialUrl;
280
+
281
+ for (let redirectCount = 0; redirectCount <= redirectLimit; redirectCount += 1) {
282
+ const response = await fetchImpl(current, {
283
+ method: "GET",
284
+ redirect: "manual"
285
+ });
286
+
287
+ if (!isRedirect(response.status)) {
288
+ return { response, url: current };
289
+ }
290
+
291
+ const location = response.headers?.get?.("location");
292
+ if (!location) {
293
+ throw new ReadbackError("Download redirect is missing a location.", {
294
+ code: "unsafe-download-redirect"
295
+ });
296
+ }
297
+ if (redirectCount === redirectLimit) {
298
+ throw new ReadbackError("Download redirect limit exceeded.", {
299
+ code: "download-redirect-limit"
300
+ });
301
+ }
302
+
303
+ const next = parseSafeDownloadUrl(new URL(location, current).href);
304
+ if (!allowedOrigins.has(next.origin)) {
305
+ throw new ReadbackError("Download redirect target is not allowed.", {
306
+ code: "unsafe-download-redirect"
307
+ });
308
+ }
309
+ current = next;
310
+ }
311
+
312
+ throw new ReadbackError("Download redirect limit exceeded.", {
313
+ code: "download-redirect-limit"
314
+ });
315
+ }
316
+
317
+ function normalizeRedirectLimit(value) {
318
+ if (!Number.isInteger(value) || value < 0 || value > 10) {
319
+ throw new ReadbackError("maxRedirects must be an integer from 0 to 10.", {
320
+ code: "invalid-redirect-limit"
321
+ });
322
+ }
323
+ return value;
324
+ }
325
+
326
+ function isRedirect(status) {
327
+ return [301, 302, 303, 307, 308].includes(status);
328
+ }
329
+
330
+ function validateContentLength(value, { expectedSize, maxBytes }) {
331
+ if (value === null || value === "") {
332
+ return;
333
+ }
334
+ const contentLength = Number(value);
335
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
336
+ throw new ReadbackError("Download response has an invalid content length.", {
337
+ code: "download-size-mismatch"
338
+ });
339
+ }
340
+ if (expectedSize !== null && contentLength !== expectedSize) {
341
+ throw new ReadbackError("Download content length does not match metadata.", {
342
+ code: "download-size-mismatch"
343
+ });
344
+ }
345
+ if (maxBytes !== null && contentLength > maxBytes) {
346
+ throw new ReadbackError("Download exceeds the configured maximum byte count.", {
347
+ code: "download-too-large"
348
+ });
349
+ }
350
+ }
351
+
352
+ function prepareDigest(value) {
353
+ if (!value) {
354
+ return null;
355
+ }
356
+ const algorithm = value.algorithm === "sha-256" ? "sha256" : value.algorithm;
357
+ try {
358
+ createHash(algorithm);
359
+ } catch (error) {
360
+ throw new ReadbackError("Download digest algorithm is not supported.", {
361
+ code: "unsupported-download-digest",
362
+ cause: error
363
+ });
364
+ }
365
+ return { algorithm, value: value.value };
366
+ }
367
+
368
+ async function streamResponseToTempFile(response, tempPath, { digest, maxBytes }) {
369
+ if (!response.body) {
370
+ throw new ReadbackError("Download response is missing a body.", {
371
+ code: "download-empty-body"
372
+ });
373
+ }
374
+
375
+ let bytesWritten = 0;
376
+ const hash = digest ? createHash(digest.algorithm) : null;
377
+ const counter = new Transform({
378
+ transform(chunk, _encoding, callback) {
379
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
380
+ bytesWritten += buffer.length;
381
+ if (maxBytes !== null && bytesWritten > maxBytes) {
382
+ callback(
383
+ new ReadbackError("Download exceeds the configured maximum byte count.", {
384
+ code: "download-too-large"
385
+ })
386
+ );
387
+ return;
388
+ }
389
+ hash?.update(buffer);
390
+ callback(null, buffer);
391
+ }
392
+ });
393
+
394
+ const readable = typeof response.body.getReader === "function" ? Readable.fromWeb(response.body) : response.body;
395
+ await pipeline(readable, counter, createWriteStream(tempPath, { flags: "wx" }));
396
+
397
+ return {
398
+ bytesWritten,
399
+ digestValue: hash ? hash.digest("hex") : null
400
+ };
401
+ }
package/src/index.mjs CHANGED
@@ -2,7 +2,12 @@ export {
2
2
  ReadbackError,
3
3
  assertReadOnlyClientSurface,
4
4
  createReadbackClient,
5
+ normalizeDownloadMetadata,
6
+ normalizeParserVariantReadback,
7
+ normalizeResourceList,
8
+ normalizeTrustReadback,
5
9
  normalizeBaseUrl,
6
10
  normalizePublicReadback
7
11
  } from "./client.mjs";
8
12
  export { createBadgeMarkdown, createBadgeState, listBadgeStates } from "./badge.mjs";
13
+ export { downloadResourceArtifact } from "./download.mjs";