@gigamusic/checkout 4.8.1 → 4.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigamusic/checkout",
3
- "version": "4.8.1",
3
+ "version": "4.9.1",
4
4
  "description": "Next.js route-handler factories for Stripe Checkout, Stripe webhooks, and presigned-URL downloads. Stripe is hard-wired; all secrets enter as factory args.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "archiver": "^7.0.1",
32
32
  "stripe": "^22.0.0",
33
33
  "@gigamusic/core": "4.2.0",
34
- "@gigamusic/storage": "3.0.0",
35
- "@gigamusic/db": "4.8.0",
36
- "@gigamusic/email": "4.2.0"
34
+ "@gigamusic/db": "4.9.0",
35
+ "@gigamusic/email": "4.2.0",
36
+ "@gigamusic/storage": "3.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "next": ">=15"
package/src/checkout.ts CHANGED
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
2
2
  import Stripe from "stripe";
3
3
  import type { ReleaseWithTracks, TrackWithFiles } from "@gigamusic/db";
4
4
  import type { CheckoutCartItem, CheckoutDeps } from "./types.js";
5
+ import { chunkMetadataValue } from "./metadata-chunks.js";
5
6
 
6
7
  /**
7
8
  * Stripe rejects card charges below 50 cents (USD). A pay-what-you-want
@@ -182,7 +183,7 @@ export function createCheckoutHandler(
182
183
  metadata: {
183
184
  site,
184
185
  catalog_purchase: "true",
185
- release_ids: JSON.stringify(releases.map((r) => r.id)),
186
+ ...chunkMetadataValue("release_ids", JSON.stringify(releases.map((r) => r.id))),
186
187
  track_ids: "[]",
187
188
  },
188
189
  managed_payments: MANAGED_PAYMENTS_DISABLED,
@@ -395,14 +396,18 @@ export function createCheckoutHandler(
395
396
  const session = await stripe.checkout.sessions.create({
396
397
  mode: "payment",
397
398
  line_items: lineItems,
399
+ // Each value is spread across continuation keys past Stripe's 500-char
400
+ // cap; `fulfillCheckoutSession` reassembles them.
398
401
  metadata: {
399
402
  site,
400
- release_ids: JSON.stringify(resolvedReleaseIds),
401
- track_ids: JSON.stringify(resolvedTrackIds),
402
- amounts: JSON.stringify(chargedAmounts),
403
+ ...chunkMetadataValue("release_ids", JSON.stringify(resolvedReleaseIds)),
404
+ ...chunkMetadataValue("track_ids", JSON.stringify(resolvedTrackIds)),
405
+ ...chunkMetadataValue("amounts", JSON.stringify(chargedAmounts)),
403
406
  // Ignored by fulfillment — bundles decompose into per-release order
404
407
  // items — but invaluable when supporting "what did I actually buy?".
405
- ...(bundleIds.length > 0 ? { bundle_ids: JSON.stringify(bundleIds) } : {}),
408
+ ...(bundleIds.length > 0
409
+ ? chunkMetadataValue("bundle_ids", JSON.stringify(bundleIds))
410
+ : {}),
406
411
  },
407
412
  managed_payments: MANAGED_PAYMENTS_DISABLED,
408
413
  success_url: successUrl,
package/src/fulfill.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from "@gigamusic/db";
9
9
  import { renderPurchaseConfirmation } from "@gigamusic/email";
10
10
  import type { FulfillSessionDeps, PurchaseConfirmationDeps } from "./types.js";
11
+ import { readChunkedMetadataValue } from "./metadata-chunks.js";
11
12
 
12
13
  export type FulfillSessionResult =
13
14
  /** This call wrote the order row. The caller owns sending the confirmation email. */
@@ -89,12 +90,14 @@ export async function fulfillCheckoutSession(
89
90
  return { status: "already-recorded", order: existing };
90
91
  }
91
92
 
92
- const releaseIds = safeJsonIdList(session.metadata?.release_ids);
93
- const trackIds = safeJsonIdList(session.metadata?.track_ids);
93
+ const releaseIds = safeJsonIdList(
94
+ readChunkedMetadataValue(session.metadata, "release_ids"),
95
+ );
96
+ const trackIds = safeJsonIdList(readChunkedMetadataValue(session.metadata, "track_ids"));
94
97
  // Per-line amounts the customer actually paid, keyed `r<id>` / `t<id>`,
95
98
  // stamped by `createCheckoutHandler`. Absent on pre-upgrade sessions and on
96
99
  // catalog purchases — those fall back to the catalog price below.
97
- const chargedAmounts = safeAmountMap(session.metadata?.amounts);
100
+ const chargedAmounts = safeAmountMap(readChunkedMetadataValue(session.metadata, "amounts"));
98
101
 
99
102
  if (releaseIds.length === 0 && trackIds.length === 0) {
100
103
  return { status: "skipped", reason: "no-items" };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Stripe caps every metadata value at 500 characters (and a session at 50
3
+ * keys). The cart's JSON id lists and per-line `amounts` map are unbounded —
4
+ * a 50-track cart already pushes `amounts` past the cap and Stripe refuses to
5
+ * create the session — so long values are spread across numbered
6
+ * continuation keys and stitched back together at fulfillment.
7
+ */
8
+ const STRIPE_METADATA_VALUE_LIMIT = 500;
9
+
10
+ /**
11
+ * Spread `value` over `key`, `key_1`, `key_2`, … so no single value exceeds
12
+ * Stripe's limit. Values that already fit land under the bare `key` alone,
13
+ * so short carts produce exactly the metadata they always have.
14
+ */
15
+ export function chunkMetadataValue(key: string, value: string): Record<string, string> {
16
+ const out: Record<string, string> = {};
17
+ for (let i = 0, offset = 0; offset < value.length || i === 0; i++) {
18
+ out[i === 0 ? key : `${key}_${i}`] = value.slice(
19
+ offset,
20
+ offset + STRIPE_METADATA_VALUE_LIMIT,
21
+ );
22
+ offset += STRIPE_METADATA_VALUE_LIMIT;
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /**
28
+ * Inverse of `chunkMetadataValue`: read `key` and every consecutive
29
+ * `key_<n>` continuation. Returns `undefined` when the bare key is absent, so
30
+ * callers treat pre-upgrade sessions exactly as before.
31
+ */
32
+ export function readChunkedMetadataValue(
33
+ metadata: Record<string, string> | null | undefined,
34
+ key: string,
35
+ ): string | undefined {
36
+ const head = metadata?.[key];
37
+ if (head === undefined || head === null) return undefined;
38
+ let value = head;
39
+ for (let i = 1; ; i++) {
40
+ const part = metadata?.[`${key}_${i}`];
41
+ if (part === undefined || part === null) break;
42
+ value += part;
43
+ }
44
+ return value;
45
+ }
package/src/types.ts CHANGED
@@ -212,6 +212,33 @@ export interface DownloadDeps {
212
212
  * download that is otherwise still producing bytes.
213
213
  */
214
214
  onZipEntryFailure?: (failure: ZipEntryFailure) => void;
215
+ /**
216
+ * How long `createDownloadZipStreamHandler` lets the customer's connection
217
+ * go without accepting a byte before it treats the download as abandoned,
218
+ * tears the archive down and stops paying for R2 egress. Defaults to 30s.
219
+ *
220
+ * This exists because `req.signal` cannot be relied on to fire when a
221
+ * customer closes a bulk download on Vercel — abandoned streams were
222
+ * observed running the function's full `maxDuration` and stacking up on one
223
+ * instance until it ran out of memory. Raise it only if you serve customers
224
+ * slow enough to spend that long on a single 256 KB window; lower it to
225
+ * reclaim capacity faster.
226
+ */
227
+ zipStreamIdleTimeoutMs?: number;
228
+ /**
229
+ * Set `false` to make `createDownloadZipStreamHandler` stream without a
230
+ * `Content-Length`, as it did before the header was introduced.
231
+ *
232
+ * The header is what lets a browser notice that a zip arrived truncated
233
+ * instead of filing a corrupt archive as a finished download, so leaving it
234
+ * on is strongly preferred. The escape hatch is here for one scenario: the
235
+ * declared length is computed from `track_files.fileSize`, so a catalog
236
+ * whose recorded sizes have drifted from the objects in storage would fail
237
+ * every bulk download rather than silently shipping a mismatched body.
238
+ * Turning this off restores the old behaviour without a package downgrade
239
+ * while the rows are repaired. Defaults to `true`.
240
+ */
241
+ zipStreamContentLength?: boolean;
215
242
  /**
216
243
  * Optional artist-name prefix prepended to zip filenames — both the SW
217
244
  * manifest and the server-side stream output use it. Example: passing
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Byte-exact size prediction for the archives `createDownloadZipStreamHandler`
3
+ * produces, so that route can send a real `Content-Length`.
4
+ *
5
+ * Why this matters: without `Content-Length` the response is chunked, and a
6
+ * function that dies mid-body (timeout, OOM) looks *complete* to the browser.
7
+ * The customer ends up with a file full of valid MP3 bytes and no
8
+ * End-of-Central-Directory record — macOS Archive Utility calls that
9
+ * "Error 79 – Inappropriate file type or format". Declaring the length up
10
+ * front turns that silent corruption into a download the browser itself flags
11
+ * as failed.
12
+ *
13
+ * The numbers below are not guesses about the zip spec in general; they are
14
+ * the exact byte counts `compress-commons@6`'s `ZipArchiveOutputStream` emits
15
+ * for the specific shape of archive this handler builds — every entry `store`d
16
+ * (no DEFLATE) and appended from a *stream*, which is what forces a data
17
+ * descriptor after each entry's payload. A buffer-sourced entry has a
18
+ * different layout (real sizes in the local header, no data descriptor), which
19
+ * is one reason the stream handler abandons its `Content-Length` the moment it
20
+ * has to substitute a `_FAILED_*.txt` placeholder.
21
+ */
22
+
23
+ /** signature + version + flags + method + mtime + crc + csize + size + name len + extra len */
24
+ const LOCAL_FILE_HEADER_BYTES = 30;
25
+ /** LFH fields + version made by + comment len + disk + attrs + LFH offset */
26
+ const CENTRAL_FILE_HEADER_BYTES = 46;
27
+ /** signature + crc + csize + size, all 32-bit. */
28
+ const DATA_DESCRIPTOR_BYTES = 16;
29
+ /** Same, with 64-bit sizes — used once an entry itself crosses 4 GiB. */
30
+ const ZIP64_DATA_DESCRIPTOR_BYTES = 24;
31
+ /** header id + payload len + size + csize + LFH offset (three 64-bit fields). */
32
+ const ZIP64_EXTRA_FIELD_BYTES = 28;
33
+ /** signature + disks + counts + CD size + CD offset + comment len, comment empty. */
34
+ const END_OF_CENTRAL_DIRECTORY_BYTES = 22;
35
+ /** The zip64 EOCD record plus its locator, written as a pair or not at all. */
36
+ const ZIP64_END_OF_CENTRAL_DIRECTORY_BYTES = 56 + 20;
37
+
38
+ /** Largest value a 32-bit zip field can hold; anything above forces zip64 encoding. */
39
+ const ZIP64_MAGIC = 0xffffffff;
40
+ /** Largest entry count the classic 16-bit EOCD record can express. */
41
+ const ZIP64_MAGIC_SHORT = 0xffff;
42
+
43
+ /** One entry as it will be appended: the in-archive path and the payload's exact byte count. */
44
+ export interface PredictedZipEntry {
45
+ name: string;
46
+ size: number;
47
+ }
48
+
49
+ /**
50
+ * Reject any entry name whose bytes archiver would rewrite.
51
+ *
52
+ * `archiver` runs every entry name through `sanitizePath` (collapse separator
53
+ * runs, `\` → `/`, drop a `scheme:` prefix, drop leading `/` and `../`) before
54
+ * writing it, and the name's byte length lands in three separate headers. If
55
+ * our prediction is computed from a name archiver then shortens, the declared
56
+ * `Content-Length` is wrong — which is worse than sending none at all. Rather
57
+ * than reimplementing that normalisation and hoping the two stay in step, we
58
+ * only predict for names that are already fixed points of it.
59
+ */
60
+ export function isPredictableZipEntryName(name: string): boolean {
61
+ if (name.length === 0) return false;
62
+ if (name.includes("\\")) return false;
63
+ if (name.includes("//")) return false;
64
+ if (name.startsWith("/")) return false;
65
+ if (name.startsWith("../")) return false;
66
+ if (name.endsWith("/")) return false;
67
+ return !/^\w+:/.test(name);
68
+ }
69
+
70
+ /**
71
+ * Exact byte length of the stored, stream-appended zip built from `entries`,
72
+ * or `null` when the layout can't be pinned down — an unusable entry name, a
73
+ * non-integer size, or a total large enough to lose precision as a JS number.
74
+ * Callers must treat `null` as "omit the header and fall back to chunked".
75
+ *
76
+ * Both zip64 escalations are modelled, because either one silently changes the
77
+ * byte count: an individual entry past 4 GiB widens its data descriptor and
78
+ * gains a central-directory extra field, and an archive whose central
79
+ * directory starts (or ends up) past 4 GiB — or that holds more than 65535
80
+ * entries — gains a zip64 EOCD record and locator ahead of the classic EOCD.
81
+ */
82
+ export function predictStoredZipLength(entries: PredictedZipEntry[]): number | null {
83
+ if (entries.length === 0) return null;
84
+
85
+ let localBytes = 0;
86
+ let centralBytes = 0;
87
+
88
+ for (const entry of entries) {
89
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0) return null;
90
+ if (!isPredictableZipEntryName(entry.name)) return null;
91
+
92
+ const nameBytes = Buffer.byteLength(entry.name, "utf8");
93
+ const entryIsZip64 = entry.size > ZIP64_MAGIC;
94
+ // The entry's own local-header offset, captured before this entry moves it.
95
+ const localHeaderOffset = localBytes;
96
+
97
+ localBytes +=
98
+ LOCAL_FILE_HEADER_BYTES +
99
+ nameBytes +
100
+ entry.size +
101
+ (entryIsZip64 ? ZIP64_DATA_DESCRIPTOR_BYTES : DATA_DESCRIPTOR_BYTES);
102
+
103
+ centralBytes +=
104
+ CENTRAL_FILE_HEADER_BYTES +
105
+ nameBytes +
106
+ (entryIsZip64 || localHeaderOffset > ZIP64_MAGIC ? ZIP64_EXTRA_FIELD_BYTES : 0);
107
+ }
108
+
109
+ const archiveIsZip64 =
110
+ entries.length > ZIP64_MAGIC_SHORT ||
111
+ localBytes > ZIP64_MAGIC ||
112
+ centralBytes > ZIP64_MAGIC;
113
+
114
+ const total =
115
+ localBytes +
116
+ centralBytes +
117
+ (archiveIsZip64 ? ZIP64_END_OF_CENTRAL_DIRECTORY_BYTES : 0) +
118
+ END_OF_CENTRAL_DIRECTORY_BYTES;
119
+
120
+ return Number.isSafeInteger(total) ? total : null;
121
+ }
122
+
123
+ /**
124
+ * Byte cost of the zip64 EOCD record + locator pair, exported so the test
125
+ * suite can assert it against a real `forceZip64` archive — the one zip64
126
+ * branch that can be exercised without a multi-gigabyte fixture.
127
+ */
128
+ export const ZIP64_TRAILER_BYTES = ZIP64_END_OF_CENTRAL_DIRECTORY_BYTES;