@gigamusic/checkout 4.6.0 → 4.8.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/src/checkout.ts CHANGED
@@ -32,20 +32,24 @@ function resolveUnitAmount(
32
32
  }
33
33
 
34
34
  /**
35
- * Split a bundle's charged total across its member releases, proportional to
36
- * list price, using largest-remainder allocation.
35
+ * Split a bundle's charged total across its members, proportional to list
36
+ * price, using largest-remainder allocation.
37
+ *
38
+ * Members are keyed by their `amounts`-metadata key (`r<id>` / `t<id>`) rather
39
+ * than a bare numeric id, so a bundle holding tracks can't have a track id
40
+ * collide with a release id.
37
41
  *
38
42
  * The returned values sum to `totalCents` exactly. That's load-bearing: they
39
43
  * become the `amounts` metadata `fulfillCheckoutSession` turns into
40
44
  * `order_items.price` rows, and `sum(order_items) === orders.amountTotal` is
41
45
  * what refunds and admin order views rely on. Ties break deterministically
42
- * (remainder desc, price desc, id asc) so a retry apportions identically.
46
+ * (remainder desc, price desc, key asc) so a retry apportions identically.
43
47
  */
44
48
  function apportion(
45
49
  totalCents: number,
46
- members: { id: number; price: number }[],
47
- ): Map<number, number> {
48
- const result = new Map<number, number>();
50
+ members: { key: string; price: number }[],
51
+ ): Map<string, number> {
52
+ const result = new Map<string, number>();
49
53
  if (members.length === 0) return result;
50
54
 
51
55
  const total = Math.max(0, Math.round(totalCents));
@@ -61,8 +65,8 @@ function apportion(
61
65
  let remainder = total - base.reduce((s, v) => s + v, 0);
62
66
 
63
67
  const order = members
64
- .map((m, i) => ({ i, frac: exact[i]! - base[i]!, price: m.price, id: m.id }))
65
- .sort((a, b) => b.frac - a.frac || b.price - a.price || a.id - b.id);
68
+ .map((m, i) => ({ i, frac: exact[i]! - base[i]!, price: m.price, key: m.key }))
69
+ .sort((a, b) => b.frac - a.frac || b.price - a.price || a.key.localeCompare(b.key));
66
70
 
67
71
  for (const { i } of order) {
68
72
  if (remainder <= 0) break;
@@ -70,7 +74,7 @@ function apportion(
70
74
  remainder -= 1;
71
75
  }
72
76
 
73
- members.forEach((m, i) => result.set(m.id, base[i]!));
77
+ members.forEach((m, i) => result.set(m.key, base[i]!));
74
78
  return result;
75
79
  }
76
80
 
@@ -284,20 +288,29 @@ export function createCheckoutHandler(
284
288
  });
285
289
 
286
290
  // Bundles resolve against the same published-release snapshot as the
287
- // loose lines, so member prices, names, and covers stay consistent.
291
+ // loose lines, so member prices, names, and covers stay consistent. A
292
+ // bundle may hold releases, tracks, or both — the package doesn't care
293
+ // which; the consumer decides what a bundle is allowed to contain.
288
294
  const bundleLineItems: typeof releaseLineItems = [];
289
- const bundleMemberIds: number[] = [];
295
+ const bundleReleaseMemberIds: number[] = [];
296
+ const bundleTrackMemberIds: number[] = [];
290
297
  const unavailableBundleIds: string[] = [];
291
298
 
292
299
  for (const bundleId of bundleIds) {
293
300
  const resolved = await bundlePurchase!(bundleId);
294
- const members = (resolved?.releaseIds ?? [])
301
+ const releaseMembers = (resolved?.releaseIds ?? [])
295
302
  .map((id) => releaseById.get(id))
296
303
  .filter((r): r is ReleaseWithTracks => r !== undefined);
304
+ const trackMembers = (resolved?.trackIds ?? [])
305
+ .map((id) => trackContext.get(id))
306
+ .filter(
307
+ (c): c is { track: TrackWithFiles; release: ReleaseWithTracks } =>
308
+ c !== undefined,
309
+ );
297
310
 
298
311
  // No such bundle, or nothing in it is published any more. Charging for
299
312
  // the latter would produce a paid order with zero items.
300
- if (!resolved || members.length === 0) {
313
+ if (!resolved || releaseMembers.length + trackMembers.length === 0) {
301
314
  unavailableBundleIds.push(bundleId);
302
315
  continue;
303
316
  }
@@ -306,7 +319,11 @@ export function createCheckoutHandler(
306
319
  STRIPE_MIN_CHARGE_CENTS,
307
320
  Math.round(resolved.totalCents),
308
321
  );
309
- const cover = members.find((m) => m.coverImageUrl)?.coverImageUrl;
322
+ // A track carries no cover of its own, so a track-only bundle falls
323
+ // back to the cover of the release the first member belongs to.
324
+ const cover =
325
+ releaseMembers.find((m) => m.coverImageUrl)?.coverImageUrl ??
326
+ trackMembers.find((c) => c.release.coverImageUrl)?.release.coverImageUrl;
310
327
  bundleLineItems.push({
311
328
  price_data: {
312
329
  currency,
@@ -319,13 +336,16 @@ export function createCheckoutHandler(
319
336
  quantity: 1 as const,
320
337
  });
321
338
 
322
- for (const [id, cents] of apportion(
323
- total,
324
- members.map((m) => ({ id: m.id, price: m.price })),
325
- )) {
326
- chargedAmounts[`r${id}`] = (chargedAmounts[`r${id}`] ?? 0) + cents;
327
- bundleMemberIds.push(id);
339
+ // Apportioned jointly across both member kinds — the total has to land
340
+ // on the members as one set, not split per kind and rounded twice.
341
+ for (const [key, cents] of apportion(total, [
342
+ ...releaseMembers.map((m) => ({ key: `r${m.id}`, price: m.price })),
343
+ ...trackMembers.map(({ track }) => ({ key: `t${track.id}`, price: track.price })),
344
+ ])) {
345
+ chargedAmounts[key] = (chargedAmounts[key] ?? 0) + cents;
328
346
  }
347
+ bundleReleaseMemberIds.push(...releaseMembers.map((m) => m.id));
348
+ bundleTrackMemberIds.push(...trackMembers.map(({ track }) => track.id));
329
349
  }
330
350
 
331
351
  if (unavailableBundleIds.length > 0) {
@@ -340,17 +360,21 @@ export function createCheckoutHandler(
340
360
  return jsonError("No valid items found", 400);
341
361
  }
342
362
 
343
- // Deduped: a release reachable from both a bundle and a loose line still
344
- // yields one order item, whose price is the summed `amounts` entry.
363
+ // Deduped: a release or track reachable from both a bundle and a loose
364
+ // line still yields one order item, whose price is the summed `amounts`
365
+ // entry.
345
366
  const resolvedReleaseIds = [
346
367
  ...new Set([
347
368
  ...(releaseLineItems.length ? releaseIds.filter((id) => releaseById.has(id)) : []),
348
- ...bundleMemberIds,
369
+ ...bundleReleaseMemberIds,
370
+ ]),
371
+ ];
372
+ const resolvedTrackIds = [
373
+ ...new Set([
374
+ ...(trackLineItems.length ? trackIds.filter((id) => trackContext.has(id)) : []),
375
+ ...bundleTrackMemberIds,
349
376
  ]),
350
377
  ];
351
- const resolvedTrackIds = trackLineItems.length
352
- ? [...new Set(trackIds.filter((id) => trackContext.has(id)))]
353
- : [];
354
378
 
355
379
  const session = await stripe.checkout.sessions.create({
356
380
  mode: "payment",
package/src/download.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { NextRequest } from "next/server";
2
- import type { Queries, TrackFile } from "@gigamusic/db";
2
+ import type { Queries } from "@gigamusic/db";
3
3
  import type { DownloadDeps } from "./types.js";
4
+ import { cleanDownloadFilename, coverArtFilename } from "./filenames.js";
4
5
 
5
6
  interface RouteContext {
6
7
  params: Promise<{ token: string }>;
@@ -13,9 +14,34 @@ interface RouteContext {
13
14
  * URL — the storage provider has `Content-Disposition: attachment` baked in,
14
15
  * so the browser triggers a same-tab download.
15
16
  *
16
- * Order of checks: locate the file in the catalog first (so a missing-file
17
- * is 404), *then* enforce the token-track grant (a wrong trackId for the
18
- * given token is 403, not 404).
17
+ * Order of checks: locate the file first (so a missing-file is 404), *then*
18
+ * enforce the token-track grant (a wrong trackId for the given token is 403,
19
+ * not 404).
20
+ *
21
+ * One extra job beyond serving the order page's per-track buttons, in service
22
+ * of `createDownloadZipHandler`, whose manifest points every zip entry back
23
+ * here so the R2 signature is minted when the service worker actually reaches
24
+ * that file (see `zip.ts`): `?asset=cover&releaseId=…` serves a release's
25
+ * cover art, which isn't track-keyed and so has no `trackId` to address it by.
26
+ * That branch checks `queries.tokenGrantsRelease` instead.
27
+ *
28
+ * ### Lookups are keyed, not scanned
29
+ *
30
+ * Both branches resolve their target with a single keyed query
31
+ * (`getTrackFile` / `getReleaseById`). They must not go back to
32
+ * `listPublishedReleases()`: that pulls every published release with its
33
+ * tracks and files, and because zip manifests fan every entry through this
34
+ * route, one bulk download would issue that read once per file — dozens of
35
+ * full-catalog scans to serve a single archive, against the most expensive
36
+ * resource these sites have.
37
+ *
38
+ * Neither keyed query filters on `isPublished`, which is deliberate and is
39
+ * also what makes them a complete replacement for the catalog walk rather
40
+ * than a narrowing of it. Ownership is decided by the grant check below, not
41
+ * by catalog membership, so a release unpublished after purchase and an
42
+ * à-la-carte track belonging to no release both stay reachable to the customer
43
+ * who bought them — exactly the cases the zip resolver lists, since it reads
44
+ * the order rather than the catalog.
19
45
  */
20
46
  export function createDownloadHandler(
21
47
  deps: DownloadDeps,
@@ -25,9 +51,13 @@ export function createDownloadHandler(
25
51
  return async (req: NextRequest, ctx: RouteContext): Promise<Response> => {
26
52
  const { token } = await ctx.params;
27
53
  const requestUrl = new URL(req.url);
28
- const trackIdRaw = requestUrl.searchParams.get("trackId");
29
54
  const format = requestUrl.searchParams.get("format") ?? "mp3";
30
55
 
56
+ if (requestUrl.searchParams.get("asset") === "cover") {
57
+ return handleCoverArt(deps, token, requestUrl.searchParams.get("releaseId"));
58
+ }
59
+
60
+ const trackIdRaw = requestUrl.searchParams.get("trackId");
31
61
  if (!trackIdRaw) {
32
62
  return Response.json({ error: "Missing trackId" }, { status: 400 });
33
63
  }
@@ -41,7 +71,7 @@ export function createDownloadHandler(
41
71
  return Response.json({ error: "Invalid download link" }, { status: 404 });
42
72
  }
43
73
 
44
- const file = await findTrackFileInCatalog(queries, trackId, format);
74
+ const file = await queries.getTrackFile(trackId, format);
45
75
  if (!file) {
46
76
  return Response.json({ error: "File not found" }, { status: 404 });
47
77
  }
@@ -59,27 +89,44 @@ export function createDownloadHandler(
59
89
  };
60
90
  }
61
91
 
62
- /** Walk the published catalog (released to all customers) to find the file matching `trackId` + `format`. */
63
- async function findTrackFileInCatalog(
64
- queries: Queries,
65
- trackId: number,
66
- format: string,
67
- ): Promise<TrackFile | null> {
68
- const releases = await queries.listPublishedReleases();
69
- for (const release of releases) {
70
- const track = release.tracks.find((t) => t.id === trackId);
71
- if (!track) continue;
72
- const file = track.files.find((f) => f.format === format);
73
- return file ?? null;
92
+ /** Serve `?asset=cover&releaseId=…` the release-keyed twin of the track branch above. */
93
+ async function handleCoverArt(
94
+ deps: DownloadDeps,
95
+ token: string,
96
+ releaseIdRaw: string | null,
97
+ ): Promise<Response> {
98
+ const { queries, storage } = deps;
99
+
100
+ if (!releaseIdRaw) {
101
+ return Response.json({ error: "Missing releaseId" }, { status: 400 });
102
+ }
103
+ const releaseId = Number(releaseIdRaw);
104
+ if (!Number.isFinite(releaseId)) {
105
+ return Response.json({ error: "Invalid releaseId" }, { status: 400 });
106
+ }
107
+
108
+ const downloadToken = await queries.getDownloadToken(token);
109
+ if (!downloadToken) {
110
+ return Response.json({ error: "Invalid download link" }, { status: 404 });
74
111
  }
75
- return null;
76
- }
77
112
 
78
- /** Trim stray leading/trailing whitespace from a download filename while keeping its extension intact. */
79
- export function cleanDownloadFilename(name: string): string {
80
- const dot = name.lastIndexOf(".");
81
- if (dot <= 0) return name.trim();
82
- return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
113
+ const release = await queries.getReleaseById(releaseId);
114
+ if (!release?.coverImageUrl) {
115
+ return Response.json({ error: "File not found" }, { status: 404 });
116
+ }
117
+
118
+ const ok = await queries.tokenGrantsRelease(token, releaseId);
119
+ if (!ok) {
120
+ return Response.json({ error: "Release not in order" }, { status: 403 });
121
+ }
122
+
123
+ const url = await storage.getPresignedDownloadUrl(release.coverImageUrl, {
124
+ filename: coverArtFilename(release.name),
125
+ contentType: "image/jpeg",
126
+ });
127
+ return Response.redirect(url, 302);
83
128
  }
84
129
 
130
+ export { cleanDownloadFilename };
131
+
85
132
  export type { Queries };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Filename helpers shared by the single-file download route and the zip
3
+ * routes. They live here rather than in either module because `download.ts`
4
+ * now serves cover art (and so needs `coverArtFilename`) while `zip.ts` needs
5
+ * `cleanDownloadFilename` — importing across the two directly would be a cycle.
6
+ */
7
+
8
+ /** Trim stray leading/trailing whitespace from a download filename while keeping its extension intact. */
9
+ export function cleanDownloadFilename(name: string): string {
10
+ const dot = name.lastIndexOf(".");
11
+ if (dot <= 0) return name.trim();
12
+ return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
13
+ }
14
+
15
+ /** Strip path separators so a release/track name can't spawn unintended zip subfolders. */
16
+ export function sanitizeSegment(name: string): string {
17
+ return name.replace(/[/\\]+/g, "-").trim();
18
+ }
19
+
20
+ /** Zip filename for a release's cover art. */
21
+ export function coverArtFilename(releaseName: string): string {
22
+ return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
23
+ }
package/src/index.ts CHANGED
@@ -16,4 +16,5 @@ export type {
16
16
  PurchaseConfirmationDeps,
17
17
  ResolvedBundle,
18
18
  WebhookDeps,
19
+ ZipEntryFailure,
19
20
  } from "./types.js";
package/src/types.ts CHANGED
@@ -74,9 +74,10 @@ export interface CheckoutDeps {
74
74
  *
75
75
  * Called once per distinct `bundleId` in the cart. As with the catalog, the
76
76
  * consumer owns pricing math: hand back the final `totalCents` and the
77
- * member `releaseIds`, and the handler apportions the total across those
78
- * members (proportional to list price, largest-remainder) so the recorded
79
- * order items sum to exactly what Stripe charged.
77
+ * members (`releaseIds`, `trackIds`, or both), and the handler apportions
78
+ * the total across those members (proportional to list price,
79
+ * largest-remainder) so the recorded order items sum to exactly what Stripe
80
+ * charged.
80
81
  *
81
82
  * Resolving to `null`/`undefined` means "no such bundle" — the handler
82
83
  * replies 409 `{ error: "bundle-unavailable", bundleIds }` so the cart can
@@ -88,7 +89,14 @@ export interface CheckoutDeps {
88
89
  ) => Promise<ResolvedBundle | null | undefined> | ResolvedBundle | null | undefined;
89
90
  }
90
91
 
91
- /** What `bundlePurchase` returns for a bundle the consumer recognises. */
92
+ /**
93
+ * What `bundlePurchase` returns for a bundle the consumer recognises.
94
+ *
95
+ * A bundle's members are `releaseIds`, `trackIds`, or both — the package
96
+ * imposes no rule about mixing them, so a consumer is free to model bundles as
97
+ * releases-only, singles-only, or a combination. Supply at least one member
98
+ * list; a bundle that resolves to no surviving member is reported unavailable.
99
+ */
92
100
  export interface ResolvedBundle {
93
101
  /** Final charged price for the whole bundle, in cents. Clamped up to the Stripe card minimum. */
94
102
  totalCents: number;
@@ -96,10 +104,16 @@ export interface ResolvedBundle {
96
104
  productName: string;
97
105
  /**
98
106
  * Member release ids. Resolved against `listPublishedReleases()` — ids that
99
- * aren't published are dropped, and a bundle left with none is treated as
100
- * unavailable rather than charging for an order with no items.
107
+ * aren't published are dropped, and a bundle left with no members at all is
108
+ * treated as unavailable rather than charging for an order with no items.
109
+ */
110
+ releaseIds?: number[];
111
+ /**
112
+ * Member track ids — individual songs, for a "bundle of singles". Resolved
113
+ * against the tracks of `listPublishedReleases()`, so a track whose release
114
+ * is unpublished is dropped on the same terms as an unpublished release.
101
115
  */
102
- releaseIds: number[];
116
+ trackIds?: number[];
103
117
  }
104
118
 
105
119
  /** What `fulfillCheckoutSession` needs to turn a paid Stripe session into an order row. */
@@ -154,9 +168,50 @@ export interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDep
154
168
  webhookSecret: string;
155
169
  }
156
170
 
171
+ /**
172
+ * One entry `createDownloadZipStreamHandler` could not stream into the
173
+ * archive. The customer still gets a zip — the entry is replaced by a
174
+ * `_FAILED_<name>.txt` placeholder so one bad object doesn't corrupt the rest
175
+ * — which is exactly why the failure needs surfacing somewhere the operator
176
+ * will see it: from the outside, a half-empty archive looks like a successful
177
+ * download.
178
+ */
179
+ export interface ZipEntryFailure {
180
+ /** Download token whose bundle was being streamed — identifies the order. */
181
+ token: string;
182
+ /** Path of the entry inside the archive, e.g. `"Album/track.wav"`. */
183
+ fileName: string;
184
+ storageKey: string;
185
+ /** Message from the failed storage fetch or mid-stream error. */
186
+ reason: string;
187
+ /** Bytes received before the failure; 0 when the fetch never got going. */
188
+ bytesReceived: number;
189
+ }
190
+
157
191
  export interface DownloadDeps {
158
192
  queries: Queries;
159
193
  storage: StorageProvider;
194
+ /**
195
+ * Path of the single-file download route (`createDownloadHandler`) that
196
+ * `createDownloadZipHandler`'s manifest addresses its entries through, with
197
+ * `{token}` as the placeholder — e.g. `"/downloads/{token}/file"`.
198
+ *
199
+ * Defaults to the manifest route's own path minus its last segment, which
200
+ * resolves to `/download/[token]` under the layout `docs/setup.md`
201
+ * prescribes. Only set this if you mount the two routes such that the
202
+ * relationship doesn't hold.
203
+ */
204
+ downloadPath?: string;
205
+ /**
206
+ * Called by `createDownloadZipStreamHandler` once per entry it fails to
207
+ * stream, so a broken bulk download can raise an alert instead of silently
208
+ * shipping a zip full of `_FAILED_*.txt` placeholders. Failures are also
209
+ * logged, with a per-request summary line carrying the failed/total count.
210
+ *
211
+ * Errors thrown here are swallowed — reporting must never take down a
212
+ * download that is otherwise still producing bytes.
213
+ */
214
+ onZipEntryFailure?: (failure: ZipEntryFailure) => void;
160
215
  /**
161
216
  * Optional artist-name prefix prepended to zip filenames — both the SW
162
217
  * manifest and the server-side stream output use it. Example: passing
package/src/zip-stream.ts CHANGED
@@ -3,7 +3,7 @@ import { Readable, Transform } from "node:stream";
3
3
  import { finished } from "node:stream/promises";
4
4
  import type { ReadableStream as NodeReadableStream } from "node:stream/web";
5
5
  import archiver from "archiver";
6
- import type { DownloadDeps } from "./types.js";
6
+ import type { DownloadDeps, ZipEntryFailure } from "./types.js";
7
7
  import { resolveZipBundle } from "./zip.js";
8
8
 
9
9
  interface RouteContext {
@@ -28,7 +28,18 @@ interface RouteContext {
28
28
  *
29
29
  * Per-file failure policy matches the SW: any failed storage fetch becomes
30
30
  * a `_FAILED_<name>.txt` placeholder so one bad object doesn't taint the
31
- * whole archive.
31
+ * whole archive. Because that turns a broken purchase into an
32
+ * apparently-successful download, every failure is reported through
33
+ * `deps.onZipEntryFailure` and summarised in a single log line at the end of
34
+ * the request.
35
+ *
36
+ * Unlike the manifest path, this handler presigns each file *inside* the loop,
37
+ * immediately before fetching it, so signature expiry can't bite. Its own
38
+ * ceiling is the platform's function timeout: audio bytes proxy through the
39
+ * function, so the whole archive has to be produced within `maxDuration`
40
+ * (Vercel: 300s by default, up to 800s on Fluid compute). Multi-gigabyte
41
+ * bundles need that raised in the consuming route — `export const maxDuration`
42
+ * — or they'll be cut off mid-stream.
32
43
  */
33
44
  export function createDownloadZipStreamHandler(
34
45
  deps: DownloadDeps,
@@ -63,6 +74,17 @@ export function createDownloadZipStreamHandler(
63
74
  // Tear the pipeline down if the client disconnects mid-download.
64
75
  req.signal.addEventListener("abort", () => archive.abort());
65
76
 
77
+ const failures: ZipEntryFailure[] = [];
78
+ /** Record one unusable entry and hand it to the consumer's reporter, never letting that reporter break the stream. */
79
+ const reportFailure = (failure: ZipEntryFailure) => {
80
+ failures.push(failure);
81
+ try {
82
+ deps.onZipEntryFailure?.(failure);
83
+ } catch (err) {
84
+ console.warn("[zip-stream] onZipEntryFailure threw:", err);
85
+ }
86
+ };
87
+
66
88
  // Serialise storage fetches: only open file N's socket after archiver
67
89
  // has fully consumed file N-1. The naive parallel approach (open every
68
90
  // socket up front, let archiver drain them in order) leaves the later
@@ -115,6 +137,13 @@ export function createDownloadZipStreamHandler(
115
137
  `[zip-stream] body stream error for ${file.fileName} ` +
116
138
  `(storageKey=${file.storageKey}, bytesReceived=${bytesReceived}): ${detail}`,
117
139
  );
140
+ reportFailure({
141
+ token,
142
+ fileName: file.fileName,
143
+ storageKey: file.storageKey,
144
+ reason: detail,
145
+ bytesReceived,
146
+ });
118
147
  try {
119
148
  archive.append(
120
149
  `Streaming "${file.fileName}" from storage failed after ${bytesReceived} bytes: ${detail}.\n` +
@@ -132,6 +161,13 @@ export function createDownloadZipStreamHandler(
132
161
  if (req.signal.aborted) return;
133
162
  const detail = err instanceof Error ? err.message : String(err);
134
163
  console.warn(`[zip-stream] fetch failed for ${file.fileName}:`, detail);
164
+ reportFailure({
165
+ token,
166
+ fileName: file.fileName,
167
+ storageKey: file.storageKey,
168
+ reason: detail,
169
+ bytesReceived: 0,
170
+ });
135
171
  archive.append(
136
172
  `Failed to download "${file.fileName}" from storage: ${detail}.\n` +
137
173
  `Try downloading the track individually from your order page.\n`,
@@ -139,6 +175,12 @@ export function createDownloadZipStreamHandler(
139
175
  );
140
176
  }
141
177
  }
178
+ if (failures.length > 0) {
179
+ console.error(
180
+ `[zip-stream] ${failures.length}/${resolution.files.length} entries failed ` +
181
+ `for token ${token}: ${failures.map((f) => f.fileName).join(", ")}`,
182
+ );
183
+ }
142
184
  await archive.finalize();
143
185
  })().catch((err) => {
144
186
  console.error("[zip-stream] pipeline error:", err);