@blamejs/blamejs-shop 0.4.28 → 0.4.30

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.
@@ -33,6 +33,15 @@
33
33
  * prior row's terminal state is cleared from the unique-index slot
34
34
  * first.
35
35
  *
36
+ * Privacy requests. `exportForCustomer({ customer_id?, email_hash? })`
37
+ * returns the subject's subscription rows (the plaintext address
38
+ * included — it is the subject's own data; token hashes excluded)
39
+ * and `eraseForCustomer({ customer_id?, email_hash?, dry_run? })`
40
+ * hard-deletes them — a stock alert is convenience data with no
41
+ * retention basis. Both match on customer_id OR the stock-alert-
42
+ * namespace email hash, so a caller holding either key covers the
43
+ * subject's rows.
44
+ *
36
45
  * Composes:
37
46
  * - `b.crypto.namespaceHash` — email hash + token hash.
38
47
  * - `b.crypto.generateBytes` — 24 random bytes →
@@ -157,6 +166,43 @@ function _hashUnsubToken(plaintext) {
157
166
  return b.crypto.namespaceHash(UNSUB_NAMESPACE, plaintext);
158
167
  }
159
168
 
169
+ // Normalize the DSR selector for exportForCustomer / eraseForCustomer:
170
+ // at least one of customer_id / email_hash must be present. Bad UUID
171
+ // shape throws (operator catches the typo at the boundary); a null /
172
+ // absent key is simply not used in the predicate. `email_hash` is the
173
+ // stock-alert-namespace digest (hex from b.crypto.namespaceHash), so a
174
+ // generous shape gate (non-empty printable string) is all it needs.
175
+ function _dsrSelector(input, method) {
176
+ if (!input || typeof input !== "object") {
177
+ throw new TypeError("stockAlerts." + method + ": input object required");
178
+ }
179
+ var custId = _optUuid(input.customer_id, "customer_id");
180
+ var emailHash = null;
181
+ if (input.email_hash != null) {
182
+ if (typeof input.email_hash !== "string" || !input.email_hash.length ||
183
+ input.email_hash.length > 256 || /[\x00-\x1f\x7f]/.test(input.email_hash)) {
184
+ throw new TypeError("stockAlerts." + method + ": email_hash must be a non-empty printable string <= 256 chars when provided");
185
+ }
186
+ emailHash = input.email_hash;
187
+ }
188
+ if (custId == null && emailHash == null) {
189
+ throw new TypeError("stockAlerts." + method + ": at least one of customer_id / email_hash is required");
190
+ }
191
+ return { custId: custId, emailHash: emailHash };
192
+ }
193
+
194
+ // Build the `customer_id = ? OR email_hash = ?` predicate from whichever
195
+ // selector keys are present. Shared by export + erase so the two never
196
+ // diverge on which rows belong to the subject.
197
+ function _dsrWhere(sel) {
198
+ var ors = [];
199
+ var params = [];
200
+ var idx = 1;
201
+ if (sel.custId != null) { ors.push("customer_id = ?" + idx); params.push(sel.custId); idx += 1; }
202
+ if (sel.emailHash != null) { ors.push("email_hash = ?" + idx); params.push(sel.emailHash); idx += 1; }
203
+ return { clause: "(" + ors.join(" OR ") + ")", params: params };
204
+ }
205
+
160
206
  // ---- factory ------------------------------------------------------------
161
207
 
162
208
  function create(opts) {
@@ -235,6 +281,14 @@ function create(opts) {
235
281
  if (existing) {
236
282
  var stillLive = existing.notified_at == null && Number(existing.expires_at) > now;
237
283
  if (stillLive) {
284
+ // A re-subscribe NEVER re-keys the existing row's customer_id.
285
+ // The email on this request is caller-supplied and unproven, so
286
+ // adopting a row first created by someone else would let a
287
+ // signed-in caller pull a stranger's subscription into their own
288
+ // account scope. A row stays owned by whoever the INSERT linked
289
+ // it to (or anonymous); anonymous rows are reachable for privacy
290
+ // requests via the email_hash key + bearer token, exactly as
291
+ // eraseForCustomer's residual-scope note documents.
238
292
  return {
239
293
  id: existing.id,
240
294
  status: existing.confirmed_at == null ? "already-pending" : "already-confirmed",
@@ -427,6 +481,62 @@ function create(opts) {
427
481
  return rows;
428
482
  },
429
483
 
484
+ // exportForCustomer({ customer_id?, email_hash? }) — subject-access
485
+ // (Art. 15) read. A subscription is keyed to a person by EITHER
486
+ // customer_id (a signed-in subscriber) OR the module's own
487
+ // stock-alert-namespace email hash (an anonymous subscriber). Both
488
+ // keys are matched with OR so a caller holding both covers every row;
489
+ // a caller resolving the subject by customer_id alone (the DSR
490
+ // composition root — no raw email is held anywhere to re-hash under
491
+ // this namespace) covers every account-linked row. The exported shape
492
+ // includes `email_normalised` — the subject's own address, stored
493
+ // plaintext by design for the notification dispatcher — and excludes
494
+ // the confirmation / unsubscribe token hashes (bearer credentials,
495
+ // not subject data).
496
+ exportForCustomer: async function (input) {
497
+ var sel = _dsrSelector(input, "exportForCustomer");
498
+ var w = _dsrWhere(sel);
499
+ return (await query(
500
+ "SELECT id, email_hash, email_normalised, sku, variant_id, customer_id, " +
501
+ "confirmed_at, notified_at, expires_at, created_at " +
502
+ "FROM stock_alerts WHERE " + w.clause +
503
+ " ORDER BY created_at DESC, id DESC LIMIT ?" + (w.params.length + 1),
504
+ w.params.concat([MAX_LIST_LIMIT]),
505
+ )).rows;
506
+ },
507
+
508
+ // eraseForCustomer({ customer_id?, email_hash?, dry_run? }) — GDPR
509
+ // Art. 17 erasure. A stock alert is pure convenience data holding a
510
+ // PLAINTEXT email with no retention basis, so erasure hard-DELETEs
511
+ // the rows (which also frees the (email, sku, variant) unique-index
512
+ // slot cleanly — the person can re-subscribe later). Same dual-key
513
+ // selector as the export so the two never diverge on which rows
514
+ // belong to the subject. `dry_run: true` counts the rows a wet run
515
+ // WOULD delete without mutating. Returns the DSR reader contract
516
+ // shape `{ table, deleted }`.
517
+ //
518
+ // Residual scope, stated rather than implied: rows subscribed
519
+ // anonymously (customer_id NULL) are reachable only via the
520
+ // email_hash key — a customer_id-only call cannot match them because
521
+ // the system never holds the raw address to re-derive this
522
+ // namespace's hash. Those rows stay covered by the retention TTL
523
+ // (default 90 days, swept by cleanupExpired) and by the per-row
524
+ // bearer unsubscribe token the subscriber holds.
525
+ eraseForCustomer: async function (input) {
526
+ var dryRun = !!(input && input.dry_run);
527
+ var sel = _dsrSelector(input, "eraseForCustomer");
528
+ var w = _dsrWhere(sel);
529
+ if (dryRun) {
530
+ var c = (await query(
531
+ "SELECT COUNT(*) AS n FROM stock_alerts WHERE " + w.clause,
532
+ w.params,
533
+ )).rows[0];
534
+ return { table: "stock_alerts", deleted: c ? Number(c.n) : 0 };
535
+ }
536
+ var r = await query("DELETE FROM stock_alerts WHERE " + w.clause, w.params);
537
+ return { table: "stock_alerts", deleted: Number((r && r.rowCount) || 0) };
538
+ },
539
+
430
540
  // Operator-driven sweeper. Walks every pending (confirmed +
431
541
  // un-notified + un-expired) subscription whose SKU now has
432
542
  // catalog.inventory.stock_on_hand - stock_held > 0 and:
@@ -146,16 +146,17 @@ function create(opts) {
146
146
  query = function (sql, params) { return b.externalDb.query(sql, params); };
147
147
  }
148
148
 
149
- // O(1) current-balance read: the latest row by `occurred_at DESC`
150
- // holds `balance_after_minor` as the denormalized snapshot. No SUM
149
+ // O(1) current-balance read: the latest row by `occurred_at DESC, id
150
+ // DESC` holds `balance_after_minor` as the denormalized snapshot. No SUM
151
151
  // aggregation at read time. Falls through to 0 when no rows exist
152
- // (a customer that has never had a ledger row has zero credit).
153
- // Returns both the snapshot and the occurred_at so the write path
154
- // can guarantee strict monotonicity (see `_resolveOccurredAt`).
152
+ // (a customer that has never had a ledger row has zero credit). The id
153
+ // tie-break keeps any legacy same-millisecond rows deterministic; new
154
+ // writes can't tie _writeRowAtomic computes a strictly-monotonic
155
+ // per-customer occurred_at inside the INSERT itself.
155
156
  async function _readLatest(customerId) {
156
157
  var r = await query(
157
158
  "SELECT balance_after_minor, occurred_at FROM store_credit_ledger " +
158
- "WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
159
+ "WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
159
160
  [customerId],
160
161
  );
161
162
  if (!r.rows.length) return { balance: 0, occurred_at: null };
@@ -167,27 +168,62 @@ function create(opts) {
167
168
  return latest.balance;
168
169
  }
169
170
 
170
- // Two writes against the same customer in the same millisecond
171
- // would tie on `occurred_at` and make the "latest row" ambiguous.
172
- // Bump the requested timestamp to `prior + 1` when it would
173
- // collide (or land older than the prior row, which an
174
- // out-of-order operator write could trigger). The result is a
175
- // strictly-monotonic per-customer `occurred_at` sequence.
176
- function _resolveOccurredAt(requestedTs, latestTs) {
177
- if (latestTs == null) return requestedTs;
178
- if (requestedTs > latestTs) return requestedTs;
179
- return latestTs + 1;
180
- }
181
-
182
- async function _writeRow(customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts) {
171
+ // Single-statement guarded write the concurrency spine of the wallet.
172
+ // The live balance AND the strictly-monotonic per-customer occurred_at
173
+ // are computed by correlated subqueries INSIDE the INSERT, so two
174
+ // concurrent writes can never base off the same stale snapshot: the
175
+ // statements serialize at the database and the second sees the first's
176
+ // row. (A JS-side read-then-write here double-fulfilled concurrent
177
+ // debits and silently dropped one of two same-millisecond credits.)
178
+ // `kind` selects the guard + arithmetic:
179
+ // credit balance_after = live + amount, no balance gate (the in-SQL
180
+ // occurred_at is what closes the same-millisecond tie);
181
+ // debit — balance_after = live - amount, gated on live >= amount
182
+ // (zero rows = insufficient, or lost the race — same refusal);
183
+ // expire burns MIN(amount, live), gated on live > 0 (zero rows =
184
+ // wallet already empty; callers degrade gracefully — by
185
+ // design, never a throw).
186
+ // Returns the written row's resolved values, or null when the guard
187
+ // refused the write.
188
+ async function _writeRowAtomic(kind, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs) {
183
189
  var id = b.uuid.v7();
184
- await query(
190
+ var balSub = "COALESCE((SELECT balance_after_minor FROM store_credit_ledger " +
191
+ "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
192
+ var tsSub = "COALESCE((SELECT occurred_at FROM store_credit_ledger " +
193
+ "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
194
+ var tsExpr = "CASE WHEN ?8 > " + tsSub + " THEN ?8 ELSE " + tsSub + " + 1 END";
195
+ var amountExpr, afterExpr, guard;
196
+ if (kind === "credit") {
197
+ amountExpr = "?3";
198
+ afterExpr = balSub + " + ?3";
199
+ guard = "1";
200
+ } else if (kind === "debit") {
201
+ amountExpr = "?3";
202
+ afterExpr = balSub + " - ?3";
203
+ guard = balSub + " >= ?3";
204
+ } else { // expire — burn MIN(amount, live balance)
205
+ amountExpr = "CASE WHEN " + balSub + " < ?3 THEN " + balSub + " ELSE ?3 END";
206
+ afterExpr = "CASE WHEN " + balSub + " < ?3 THEN 0 ELSE " + balSub + " - ?3 END";
207
+ guard = balSub + " > 0";
208
+ }
209
+ var res = await query(
185
210
  "INSERT INTO store_credit_ledger " +
186
211
  "(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at) " +
187
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
188
- [id, customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts],
212
+ "SELECT ?1, ?2, ?9, " + amountExpr + ", ?4, ?5, ?6, " + afterExpr + ", ?7, " + tsExpr + " " +
213
+ "WHERE " + guard,
214
+ [id, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs, kind],
189
215
  );
190
- return id;
216
+ if (Number(res.rowCount || 0) === 0) return null;
217
+ var row = (await query(
218
+ "SELECT amount_minor, balance_after_minor, occurred_at FROM store_credit_ledger WHERE id = ?1",
219
+ [id],
220
+ )).rows[0];
221
+ return {
222
+ id: id,
223
+ amount_minor: row.amount_minor,
224
+ balance_after_minor: row.balance_after_minor,
225
+ occurred_at: row.occurred_at,
226
+ };
191
227
  }
192
228
 
193
229
  return {
@@ -206,21 +242,18 @@ function create(opts) {
206
242
  var requested = _epochMs(input.occurred_at, "occurred_at");
207
243
  if (requested == null) requested = _now();
208
244
 
209
- var latest = await _readLatest(customerId);
210
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
211
- var after = latest.balance + amount;
212
- var id = await _writeRow(customerId, "credit", amount, source, sourceRef, null, after, expiresAt, ts);
245
+ var w = await _writeRowAtomic("credit", customerId, amount, source, sourceRef, null, expiresAt, requested);
213
246
 
214
247
  return {
215
- id: id,
248
+ id: w.id,
216
249
  customer_id: customerId,
217
250
  kind: "credit",
218
251
  amount_minor: amount,
219
252
  source: source,
220
253
  source_ref: sourceRef,
221
254
  expires_at: expiresAt,
222
- balance_after_minor: after,
223
- occurred_at: ts,
255
+ balance_after_minor: w.balance_after_minor,
256
+ occurred_at: w.occurred_at,
224
257
  };
225
258
  },
226
259
 
@@ -234,24 +267,24 @@ function create(opts) {
234
267
  var requested = _epochMs(input.occurred_at, "occurred_at");
235
268
  if (requested == null) requested = _now();
236
269
 
237
- var latest = await _readLatest(customerId);
238
- if (amount > latest.balance) {
270
+ // The balance gate lives INSIDE the insert — a refused write covers
271
+ // both "always insufficient" and "a concurrent debit drained it
272
+ // first", with no window between check and write.
273
+ var w = await _writeRowAtomic("debit", customerId, amount, null, null, orderId, null, requested);
274
+ if (!w) {
239
275
  var insufficient = new Error("storeCredit.debit: amount exceeds available balance");
240
276
  insufficient.code = "STORE_CREDIT_INSUFFICIENT_BALANCE";
241
277
  throw insufficient;
242
278
  }
243
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
244
- var after = latest.balance - amount;
245
- var id = await _writeRow(customerId, "debit", amount, null, null, orderId, after, null, ts);
246
279
 
247
280
  return {
248
- id: id,
281
+ id: w.id,
249
282
  customer_id: customerId,
250
283
  kind: "debit",
251
284
  amount_minor: amount,
252
285
  order_id: orderId,
253
- balance_after_minor: after,
254
- occurred_at: ts,
286
+ balance_after_minor: w.balance_after_minor,
287
+ occurred_at: w.occurred_at,
255
288
  };
256
289
  },
257
290
 
@@ -272,18 +305,15 @@ function create(opts) {
272
305
  var requested = _epochMs(input.occurred_at, "occurred_at");
273
306
  if (requested == null) requested = _now();
274
307
 
275
- var latest = await _readLatest(customerId);
276
- // Expire caps at the current balance operators running a
277
- // scheduled sweep over computed "expiring before X" amounts
278
- // should degrade gracefully rather than refusing when an
279
- // interim debit has already drained the wallet.
280
- var toBurn = amount > latest.balance ? latest.balance : amount;
281
- if (toBurn === 0) {
282
- // No-op write: persisting a zero-amount row would violate
283
- // the CHECK(amount_minor > 0) constraint. Surface a
284
- // structured refusal so the caller can distinguish "already
285
- // empty" from "actually burned N". A no-op expire is a
286
- // valid outcome of a bulk sweep — don't throw.
308
+ // Expire caps at the current balance INSIDE the insert — operators
309
+ // running a scheduled sweep over computed "expiring before X"
310
+ // amounts degrade gracefully rather than refusing when an interim
311
+ // debit has already drained the wallet. A refused write (wallet
312
+ // already at zero) is the structured no-op below, never a throw —
313
+ // by design: a no-op expire is a valid outcome of a bulk sweep,
314
+ // and a zero-amount row would violate CHECK(amount_minor > 0).
315
+ var w = await _writeRowAtomic("expire", customerId, amount, null, reason, null, null, requested);
316
+ if (!w) {
287
317
  return {
288
318
  id: null,
289
319
  customer_id: customerId,
@@ -291,24 +321,21 @@ function create(opts) {
291
321
  amount_minor: 0,
292
322
  requested_minor: amount,
293
323
  reason: reason,
294
- balance_after_minor: latest.balance,
324
+ balance_after_minor: await _currentBalance(customerId),
295
325
  occurred_at: requested,
296
326
  noop: true,
297
327
  };
298
328
  }
299
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
300
- var after = latest.balance - toBurn;
301
- var id = await _writeRow(customerId, "expire", toBurn, null, reason, null, after, null, ts);
302
329
 
303
330
  return {
304
- id: id,
331
+ id: w.id,
305
332
  customer_id: customerId,
306
333
  kind: "expire",
307
- amount_minor: toBurn,
334
+ amount_minor: w.amount_minor,
308
335
  requested_minor: amount,
309
336
  reason: reason,
310
- balance_after_minor: after,
311
- occurred_at: ts,
337
+ balance_after_minor: w.balance_after_minor,
338
+ occurred_at: w.occurred_at,
312
339
  noop: false,
313
340
  };
314
341
  },
@@ -585,29 +612,22 @@ function create(opts) {
585
612
  continue;
586
613
  }
587
614
 
588
- var latest = await _readLatest(customerId);
589
- // Cap the burn at the wallet's current balance.
590
- // Debits between the credit and the sweep may have spent
591
- // the expired amount already; we never drive the balance
592
- // negative. The expired credits were "first-out" from the
593
- // operator's POV but the schema doesn't track FIFO at
594
- // row-level, so cap by current balance and let the audit
595
- // trail reflect what was actually burned.
596
- var toBurn = pendingBurn > latest.balance ? latest.balance : pendingBurn;
597
- if (toBurn <= 0) {
598
- // Wallet already empty — record nothing (no CHECK > 0
599
- // violation). Operator can reconcile via history.
600
- continue;
601
- }
602
- var ts = _resolveOccurredAt(now, latest.occurred_at);
603
- var after = latest.balance - toBurn;
604
- var id = await _writeRow(customerId, "expire", toBurn, null, SWEEP_SOURCE_REF, null, after, null, ts);
615
+ // The burn caps at the wallet's current balance INSIDE the
616
+ // guarded insert. Debits between the credit and the sweep may
617
+ // have spent the expired amount already; the write never drives
618
+ // the balance negative, and a wallet already at zero refuses the
619
+ // write entirely (no CHECK > 0 violation; operator reconciles
620
+ // via history). The expired credits were "first-out" from the
621
+ // operator's POV the schema doesn't track FIFO at row level,
622
+ // so the audit trail reflects what was actually burned.
623
+ var w = await _writeRowAtomic("expire", customerId, pendingBurn, null, SWEEP_SOURCE_REF, null, null, now);
624
+ if (!w) continue;
605
625
  processed.push({
606
- id: id,
626
+ id: w.id,
607
627
  customer_id: customerId,
608
- amount_minor: toBurn,
609
- balance_after_minor: after,
610
- occurred_at: ts,
628
+ amount_minor: w.amount_minor,
629
+ balance_after_minor: w.balance_after_minor,
630
+ occurred_at: w.occurred_at,
611
631
  });
612
632
  }
613
633
  return { processed: processed, swept_at: now };
package/lib/storefront.js CHANGED
@@ -14864,7 +14864,7 @@ function mount(router, deps) {
14864
14864
  // the buyer must lower a quantity or drop a line; nothing was
14865
14865
  // charged and any holds placed mid-confirm were already released.
14866
14866
  if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0 ||
14867
- code === "INSUFFICIENT_STOCK") {
14867
+ code === "INSUFFICIENT_STOCK" || code === "AUTO_DISCOUNT_EXHAUSTED") {
14868
14868
  try {
14869
14869
  var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
14870
14870
  if (coLines.length) {
@@ -14998,7 +14998,7 @@ function mount(router, deps) {
14998
14998
  // Customer-correctable credit errors (bad gift-card code,
14999
14999
  // insufficient loyalty balance, points on a guest cart) are 400s
15000
15000
  // whose message the button surfaces inline.
15001
- var gcErr = ecode.indexOf("GIFTCARD_") === 0 || ecode.indexOf("LOYALTY_") === 0;
15001
+ var gcErr = ecode.indexOf("GIFTCARD_") === 0 || ecode.indexOf("LOYALTY_") === 0 || ecode === "AUTO_DISCOUNT_EXHAUSTED";
15002
15002
  // Out-of-stock is a 409 (conflict) carrying the friendly per-line
15003
15003
  // message so the PayPal button surfaces it; nothing was charged
15004
15004
  // and the mid-confirm holds were already released.
@@ -21025,11 +21025,22 @@ function mount(router, deps) {
21025
21025
  var body = req.body || {};
21026
21026
  var cartCount = 0;
21027
21027
  try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
21028
+ // Link the subscription to the signed-in customer when there is one,
21029
+ // so the row joins the account's privacy export / erasure scope. A
21030
+ // missing, stale, or revoked auth cookie reads as signed-out and the
21031
+ // anonymous subscribe (the primary, edge-served PDP flow — this route
21032
+ // stays in EDGE_POST_PATHS, no auth required) is unchanged.
21033
+ var saCustomerId = null;
21034
+ try {
21035
+ var saEnv = _currentCustomerEnv(req);
21036
+ if (saEnv && !(await _sessionRevoked(saEnv))) saCustomerId = saEnv.customer_id;
21037
+ } catch (_e) { saCustomerId = null; }
21028
21038
  try {
21029
21039
  var result = await deps.stockAlerts.subscribe({
21030
- email: body.email,
21031
- sku: body.sku,
21032
- variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
21040
+ email: body.email,
21041
+ sku: body.sku,
21042
+ variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
21043
+ customer_id: saCustomerId,
21033
21044
  });
21034
21045
  // Only a brand-new subscription carries a plaintext token. Send the
21035
21046
  // confirmation email best-effort; a mailer hiccup must not 500 the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {