@gigamusic/checkout 4.8.0 → 4.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigamusic/checkout",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
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,7 +31,7 @@
31
31
  "archiver": "^7.0.1",
32
32
  "stripe": "^22.0.0",
33
33
  "@gigamusic/core": "4.2.0",
34
- "@gigamusic/db": "4.8.0",
34
+ "@gigamusic/db": "4.9.0",
35
35
  "@gigamusic/email": "4.2.0",
36
36
  "@gigamusic/storage": "3.0.0"
37
37
  },
@@ -51,7 +51,7 @@
51
51
  "dev": "tsup --watch",
52
52
  "lint": "eslint src",
53
53
  "test": "vitest run",
54
- "typecheck": "tsc --noEmit",
54
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json",
55
55
  "clean": "rm -rf dist .turbo *.tsbuildinfo"
56
56
  }
57
57
  }
package/src/checkout.ts CHANGED
@@ -12,6 +12,21 @@ import type { CheckoutCartItem, CheckoutDeps } from "./types.js";
12
12
  */
13
13
  const STRIPE_MIN_CHARGE_CENTS = 50;
14
14
 
15
+ /**
16
+ * Stripe turns Managed Payments — its merchant-of-record product — on by
17
+ * default for newly created accounts. Left alone it rejects every line item
18
+ * built here, because Managed Payments requires a `product_data.tax_code` we
19
+ * don't set. Supplying one isn't the fix: the session then quietly comes back
20
+ * with `managed_payments.enabled: true`, which sells through Link as merchant
21
+ * of record, adds a per-transaction fee, replaces our purchase emails with
22
+ * Link's, and lets Stripe refund customers on our behalf.
23
+ *
24
+ * Sites composing this package sell as their own merchant, so opt out per
25
+ * session rather than depending on an account-level default that differs
26
+ * between an artist's old and new Stripe accounts.
27
+ */
28
+ const MANAGED_PAYMENTS_DISABLED = { enabled: false } as const;
29
+
15
30
  /**
16
31
  * Resolve the per-line `unit_amount`: a positive pay-what-you-want override
17
32
  * (clamped up to the Stripe minimum) when supplied, otherwise the catalog
@@ -170,6 +185,7 @@ export function createCheckoutHandler(
170
185
  release_ids: JSON.stringify(releases.map((r) => r.id)),
171
186
  track_ids: "[]",
172
187
  },
188
+ managed_payments: MANAGED_PAYMENTS_DISABLED,
173
189
  success_url: successUrl,
174
190
  cancel_url: cancelUrl,
175
191
  });
@@ -388,6 +404,7 @@ export function createCheckoutHandler(
388
404
  // items — but invaluable when supporting "what did I actually buy?".
389
405
  ...(bundleIds.length > 0 ? { bundle_ids: JSON.stringify(bundleIds) } : {}),
390
406
  },
407
+ managed_payments: MANAGED_PAYMENTS_DISABLED,
391
408
  success_url: successUrl,
392
409
  cancel_url: cancelUrl,
393
410
  });
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;