@blamejs/blamejs-shop 0.0.52 → 0.0.54

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/lib/tax.js CHANGED
@@ -154,8 +154,396 @@ function create(opts) {
154
154
  return operatorTable(opts);
155
155
  }
156
156
 
157
+ // ---- VAT / GST primitives ----------------------------------------------
158
+ //
159
+ // Sales tax (US-style) is *added* to a displayed price: $10 + 8.75% =
160
+ // $10.875. VAT (EU) and GST (AU / CA / IN / NZ / SG) are typically
161
+ // *extracted* from a displayed price: a €10 line at 20% VAT means the
162
+ // operator receives €8.33 net + €1.67 VAT, not €10 + €2. The B2B
163
+ // "reverse charge" mechanism on intra-EU trade moves the VAT
164
+ // obligation to the buyer (the seller invoices zero VAT and documents
165
+ // the buyer's VAT ID); the buyer self-assesses + remits in their own
166
+ // member state.
167
+ //
168
+ // These helpers are *pure functions* — they don't touch the network
169
+ // and they don't talk to the operator-table adapter. They're the
170
+ // math + format-validation surface the storefront / invoice
171
+ // primitives compose against. The operator-table adapter still owns
172
+ // jurisdiction-rule selection.
173
+
174
+ // Banker's rounding (round-half-to-even) for non-negative `raw`.
175
+ // JS Math.round is round-half-up, which biases tax totals upward
176
+ // across many small carts; this matches the rounding mode the
177
+ // existing operatorTable adapter uses.
178
+ function _bankersRound(raw) {
179
+ var floor = Math.floor(raw);
180
+ var frac = raw - floor;
181
+ if (frac < 0.5) return floor;
182
+ if (frac > 0.5) return floor + 1;
183
+ return (floor % 2 === 0) ? floor : floor + 1;
184
+ }
185
+
186
+ function _currency(c) {
187
+ if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
188
+ throw new TypeError("tax: currency must be a 3-letter ISO 4217 code (uppercase), got " + JSON.stringify(c));
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Extract VAT from a VAT-inclusive (gross) price.
194
+ *
195
+ * tax = round(gross * rate_bps / (10000 + rate_bps))
196
+ * net = gross - tax
197
+ *
198
+ * Returns `{ net_minor, tax_minor, gross_minor, rate_bps }`. The
199
+ * gross figure round-trips exactly (`net + tax === gross`) by
200
+ * construction — `net` is derived from `tax`, not rounded
201
+ * independently.
202
+ *
203
+ * Use when the storefront displays VAT-inclusive prices (the EU
204
+ * default for B2C). Banker's rounding to even.
205
+ */
206
+ function calculateInclusive(args) {
207
+ if (!args || typeof args !== "object") throw new TypeError("tax.calculateInclusive: args object required");
208
+ _nonNegInt(args.amount_minor, "amount_minor");
209
+ _bps(args.rate_bps);
210
+ _currency(args.currency);
211
+ var gross = args.amount_minor;
212
+ var rate = args.rate_bps;
213
+ var tax = _bankersRound(gross * rate / (10000 + rate));
214
+ return {
215
+ net_minor: gross - tax,
216
+ tax_minor: tax,
217
+ gross_minor: gross,
218
+ rate_bps: rate,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Add VAT to a VAT-exclusive (net) price.
224
+ *
225
+ * tax = round(net * rate_bps / 10000)
226
+ * gross = net + tax
227
+ *
228
+ * Returns `{ net_minor, tax_minor, gross_minor, rate_bps }`. Use
229
+ * when the storefront displays VAT-exclusive prices (the standard
230
+ * B2B presentation, and the US-sales-tax shape). Banker's rounding
231
+ * to even — same convention as `operatorTable.calculate`.
232
+ */
233
+ function calculateExclusive(args) {
234
+ if (!args || typeof args !== "object") throw new TypeError("tax.calculateExclusive: args object required");
235
+ _nonNegInt(args.amount_minor, "amount_minor");
236
+ _bps(args.rate_bps);
237
+ _currency(args.currency);
238
+ var net = args.amount_minor;
239
+ var rate = args.rate_bps;
240
+ var tax = _bankersRound(net * rate / 10000);
241
+ return {
242
+ net_minor: net,
243
+ tax_minor: tax,
244
+ gross_minor: net + tax,
245
+ rate_bps: rate,
246
+ };
247
+ }
248
+
249
+ // ---- VAT ID format validation -------------------------------------------
250
+ //
251
+ // VAT IDs have country-specific formats. The well-known patterns
252
+ // below cover the EU-27 + GB + Switzerland. These regexes validate
253
+ // the *format* only — they do NOT confirm the VAT ID is registered
254
+ // or active. That requires a live HTTPS call to the EU VIES service
255
+ // (or the equivalent national registry), which this primitive
256
+ // deliberately does NOT make:
257
+ //
258
+ // - Zero npm runtime deps (composing an HTTPS+XML client here is
259
+ // out of scope for a math primitive).
260
+ // - No SSRF surface in the tax math path.
261
+ // - VIES is a soft availability commitment; operators that depend
262
+ // on it need their own retry / cache / fallback policy, which
263
+ // belongs in the operator's checkout pipeline, not in lib/tax.js.
264
+ //
265
+ // Operators are responsible for VIES live-checking before storing
266
+ // any buyer VAT ID. This primitive only refuses obviously-malformed
267
+ // IDs (wrong country prefix, wrong length, wrong character class).
268
+ //
269
+ // The format table is operator-extensible — assign to
270
+ // `tax.VAT_FORMATS["XX"]` (a RegExp) to add or override a country.
271
+ //
272
+ // References: each pattern matches the published national format
273
+ // rule; mixed alphanumeric positions (FR, ES, IE) are preserved.
274
+ var VAT_FORMATS = {
275
+ AT: /^ATU[0-9]{8}$/, // Austria — U + 8 digits
276
+ BE: /^BE[01][0-9]{9}$/, // Belgium — 10 digits starting 0 or 1
277
+ BG: /^BG[0-9]{9,10}$/, // Bulgaria — 9 or 10 digits
278
+ CY: /^CY[0-9]{8}[A-Z]$/, // Cyprus — 8 digits + letter
279
+ CZ: /^CZ[0-9]{8,10}$/, // Czechia — 8-10 digits
280
+ DE: /^DE[0-9]{9}$/, // Germany — 9 digits
281
+ DK: /^DK[0-9]{8}$/, // Denmark — 8 digits
282
+ EE: /^EE[0-9]{9}$/, // Estonia — 9 digits
283
+ EL: /^EL[0-9]{9}$/, // Greece (EU-internal — Greece uses EL not GR)
284
+ GR: /^EL[0-9]{9}$/, // Greece alias — operators sometimes pass GR
285
+ ES: /^ES[A-Z0-9][0-9]{7}[A-Z0-9]$/, // Spain — alpha/digit, 7 digits, alpha/digit
286
+ FI: /^FI[0-9]{8}$/, // Finland — 8 digits
287
+ FR: /^FR[0-9A-HJ-NP-Z]{2}[0-9]{9}$/, // France — 2-char prefix (no I/O) + 9 digits
288
+ HR: /^HR[0-9]{11}$/, // Croatia — 11 digits
289
+ HU: /^HU[0-9]{8}$/, // Hungary — 8 digits
290
+ IE: /^IE[0-9][0-9A-Z*+][0-9]{5}[A-Z]{1,2}$/, // Ireland — 9-character mixed
291
+ IT: /^IT[0-9]{11}$/, // Italy — 11 digits
292
+ LT: /^LT(?:[0-9]{9}|[0-9]{12})$/, // Lithuania — 9 or 12 digits
293
+ LU: /^LU[0-9]{8}$/, // Luxembourg — 8 digits
294
+ LV: /^LV[0-9]{11}$/, // Latvia — 11 digits
295
+ MT: /^MT[0-9]{8}$/, // Malta — 8 digits
296
+ NL: /^NL[0-9]{9}B[0-9]{2}$/, // Netherlands — 9 digits + B + 2 digits
297
+ PL: /^PL[0-9]{10}$/, // Poland — 10 digits
298
+ PT: /^PT[0-9]{9}$/, // Portugal — 9 digits
299
+ RO: /^RO[0-9]{2,10}$/, // Romania — 2-10 digits
300
+ SE: /^SE[0-9]{12}$/, // Sweden — 12 digits
301
+ SI: /^SI[0-9]{8}$/, // Slovenia — 8 digits
302
+ SK: /^SK[0-9]{10}$/, // Slovakia — 10 digits
303
+ // Non-EU, retained for the EU-adjacent operator surface:
304
+ GB: /^GB(?:[0-9]{9}|[0-9]{12}|GD[0-9]{3}|HA[0-9]{3})$/, // United Kingdom — 9 / 12 digits or GD/HA + 3
305
+ CH: /^CHE[0-9]{9}(?:MWST|TVA|IVA)?$/, // Switzerland — CHE + 9 digits, optional suffix
306
+ };
307
+
308
+ // EU-27 member-state country codes (for reverse-charge eligibility).
309
+ // GB is intentionally NOT included — Brexit removed the UK from the
310
+ // intra-EU reverse-charge mechanism. CH was never an EU member.
311
+ var EU_COUNTRY_CODES = [
312
+ "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
313
+ "FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
314
+ "NL", "PL", "PT", "RO", "SE", "SI", "SK",
315
+ ];
316
+ var _EU_SET = EU_COUNTRY_CODES.reduce(function (acc, c) { acc[c] = true; return acc; }, {});
317
+
318
+ function _isEu(cc) {
319
+ if (typeof cc !== "string") return false;
320
+ return _EU_SET[cc] === true;
321
+ }
322
+
323
+ /**
324
+ * Validate a VAT ID's *format* against the country's published
325
+ * pattern. Returns `{ ok, country, vat_number, format }`:
326
+ *
327
+ * ok — true when the ID matches the country's regex
328
+ * country — the country code (uppercase, normalised)
329
+ * vat_number — the ID with the country prefix stripped
330
+ * format — the regex source string, for diagnostics
331
+ *
332
+ * On a mismatch returns `{ ok: false, country, vat_number: null,
333
+ * format }`. On an unknown country code throws — the caller asked
334
+ * for validation against a country we don't have a pattern for, and
335
+ * silently returning `{ ok: true }` would be unsafe.
336
+ *
337
+ * IMPORTANT: this is FORMAT validation only. It does NOT confirm
338
+ * the VAT ID is registered, active, or assigned to the named buyer.
339
+ * Operators MUST cross-check against VIES (https://ec.europa.eu/
340
+ * taxation_customs/vies/) or the equivalent national registry
341
+ * before relying on a VAT ID for reverse-charge invoicing. This
342
+ * primitive is the cheap pre-filter, not the legal record-of-truth.
343
+ *
344
+ * Extend `tax.VAT_FORMATS["XX"] = /^XX.../` to register additional
345
+ * country patterns at boot time.
346
+ */
347
+ function validateVatId(vat_id, country_code) {
348
+ if (typeof vat_id !== "string") {
349
+ throw new TypeError("tax.validateVatId: vat_id must be a string, got " + JSON.stringify(vat_id));
350
+ }
351
+ if (typeof country_code !== "string" || !/^[A-Z]{2}$/.test(country_code)) {
352
+ throw new TypeError("tax.validateVatId: country_code must be a 2-letter uppercase ISO 3166-1 code, got " + JSON.stringify(country_code));
353
+ }
354
+ var pattern = VAT_FORMATS[country_code];
355
+ if (!pattern) {
356
+ throw new TypeError("tax.validateVatId: no VAT format registered for country " + JSON.stringify(country_code) + " — extend tax.VAT_FORMATS to add one");
357
+ }
358
+ // Normalise: strip whitespace; uppercase. Many invoicing UIs let
359
+ // operators paste "DE 123 456 789" or "de123456789" — accept both.
360
+ var normalised = vat_id.replace(/\s+/g, "").toUpperCase();
361
+ var matched = pattern.test(normalised);
362
+ if (!matched) {
363
+ return {
364
+ ok: false,
365
+ country: country_code,
366
+ vat_number: null,
367
+ format: pattern.source,
368
+ };
369
+ }
370
+ // The country prefix on EU/GB/CH VAT IDs is always the first 2-3
371
+ // chars. We strip the matching country prefix from the front and
372
+ // return the remainder as the bare VAT number.
373
+ var prefix = normalised.indexOf(country_code) === 0 ? country_code : normalised.slice(0, 3);
374
+ return {
375
+ ok: true,
376
+ country: country_code,
377
+ vat_number: normalised.slice(prefix.length),
378
+ format: pattern.source,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Decide whether the intra-EU B2B reverse-charge mechanism applies
384
+ * to the current transaction. Returns one of:
385
+ *
386
+ * { rate_bps: 0, reverse_charge: true, reason: "eu-b2b",
387
+ * seller: { country, vat_number }, buyer: { country, vat_number } }
388
+ *
389
+ * { rate_bps: null, reverse_charge: false, reason: <why-not> }
390
+ *
391
+ * When the function returns `reverse_charge: true` the caller MUST
392
+ * invoice with VAT zero AND document both VAT IDs on the invoice;
393
+ * the buyer self-assesses + remits in their member state.
394
+ *
395
+ * Conditions (ALL must hold):
396
+ * 1. Seller has a VAT ID AND its country is in EU_COUNTRY_CODES.
397
+ * 2. Buyer has a VAT ID AND its country is in EU_COUNTRY_CODES.
398
+ * 3. Both VAT IDs pass format validation.
399
+ * 4. The buyer's billing country matches the buyer-VAT-ID country
400
+ * (a mismatch is a fraud red flag — the operator should refuse
401
+ * reverse charge and either charge the seller's rate or block
402
+ * checkout pending VIES confirmation).
403
+ *
404
+ * The `ctx` argument is reserved for future signals (e.g. a
405
+ * cached VIES-confirmation flag the operator's storefront sets
406
+ * after a successful live check). Today only the `vat_validated_at`
407
+ * timestamp is honoured as an advisory field on the seller side.
408
+ *
409
+ * IMPORTANT: this primitive does NOT call VIES. The operator must
410
+ * confirm the buyer's VAT ID against the EU VIES service (or
411
+ * equivalent national registry) BEFORE relying on the
412
+ * `reverse_charge: true` outcome to suppress VAT on an invoice.
413
+ * Format-pass + VIES-fail is a fraud vector; the operator's
414
+ * checkout pipeline owns the live check.
415
+ */
416
+ function applyReverseCharge(args) {
417
+ if (!args || typeof args !== "object") throw new TypeError("tax.applyReverseCharge: args object required");
418
+ var ctx = args.ctx || {};
419
+ var buyerCountry = args.buyer_country;
420
+ if (typeof buyerCountry !== "string" || !/^[A-Z]{2}$/.test(buyerCountry)) {
421
+ throw new TypeError("tax.applyReverseCharge: buyer_country must be a 2-letter uppercase ISO 3166-1 code, got " + JSON.stringify(buyerCountry));
422
+ }
423
+ var sellerVatId = args.seller_vat_id;
424
+ var buyerVatId = args.buyer_vat_id;
425
+
426
+ // Both VAT IDs must be present. Missing either one means the
427
+ // buyer is treated as a B2C consumer — charge the seller's rate.
428
+ if (typeof sellerVatId !== "string" || !sellerVatId) {
429
+ return { rate_bps: null, reverse_charge: false, reason: "missing-seller-vat-id" };
430
+ }
431
+ if (typeof buyerVatId !== "string" || !buyerVatId) {
432
+ return { rate_bps: null, reverse_charge: false, reason: "missing-buyer-vat-id" };
433
+ }
434
+
435
+ // The seller's country prefix is the first 2 chars of the VAT ID.
436
+ // CH starts CHE — handle that out-of-band.
437
+ var sellerCountry = sellerVatId.slice(0, 2).toUpperCase();
438
+ if (sellerCountry === "CH") sellerCountry = "CH";
439
+
440
+ if (!_isEu(sellerCountry)) {
441
+ return { rate_bps: null, reverse_charge: false, reason: "seller-not-eu" };
442
+ }
443
+ if (!_isEu(buyerCountry)) {
444
+ return { rate_bps: null, reverse_charge: false, reason: "buyer-not-eu" };
445
+ }
446
+
447
+ // Both VAT IDs must format-validate.
448
+ var sellerCheck;
449
+ var buyerCheck;
450
+ try { sellerCheck = validateVatId(sellerVatId, sellerCountry); }
451
+ catch (_e) { return { rate_bps: null, reverse_charge: false, reason: "seller-vat-id-unknown-country" }; }
452
+ if (!sellerCheck.ok) {
453
+ return { rate_bps: null, reverse_charge: false, reason: "seller-vat-id-bad-format" };
454
+ }
455
+
456
+ // The buyer's VAT ID country prefix must match the declared
457
+ // buyer_country. A mismatch is a red flag — the buyer may be
458
+ // trying to invoice into the wrong jurisdiction.
459
+ var buyerVatCountry = buyerVatId.slice(0, 2).toUpperCase();
460
+ if (buyerVatCountry !== buyerCountry) {
461
+ return { rate_bps: null, reverse_charge: false, reason: "buyer-vat-id-country-mismatch" };
462
+ }
463
+ try { buyerCheck = validateVatId(buyerVatId, buyerCountry); }
464
+ catch (_e) { return { rate_bps: null, reverse_charge: false, reason: "buyer-vat-id-unknown-country" }; }
465
+ if (!buyerCheck.ok) {
466
+ return { rate_bps: null, reverse_charge: false, reason: "buyer-vat-id-bad-format" };
467
+ }
468
+
469
+ return {
470
+ rate_bps: 0,
471
+ reverse_charge: true,
472
+ reason: "eu-b2b",
473
+ seller: { country: sellerCountry, vat_number: sellerCheck.vat_number },
474
+ buyer: { country: buyerCountry, vat_number: buyerCheck.vat_number },
475
+ // Advisory: timestamp at which the operator last live-checked
476
+ // VIES for the buyer (if supplied via ctx). The primitive does
477
+ // not consume this — it's surfaced so downstream invoice
478
+ // rendering can show "VIES-confirmed YYYY-MM-DD".
479
+ vies_validated_at: typeof ctx.vies_validated_at === "string" ? ctx.vies_validated_at : null,
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Render a `rate_bps` value as a human-readable percentage string,
485
+ * locale-aware via `Intl.NumberFormat`. Returns:
486
+ *
487
+ * format({ rate_bps: 2000 }) -> "20.0%"
488
+ * format({ rate_bps: 550 }) -> "5.5%"
489
+ * format({ rate_bps: 0, reverse_charge: true }) -> "0% (reverse charge)"
490
+ * format({ rate_bps: 2000, locale: "de-DE" }) -> "20,0 %"
491
+ * format({ rate_bps: 2000, locale: "fr-FR" }) -> "20,0 %" (or NBSP variant)
492
+ *
493
+ * Locale defaults to `"en-US"`. The `reverse_charge` annotation is
494
+ * intentionally English-only in v1 — operator-facing accounting
495
+ * strings ship in English on every storefront the framework
496
+ * currently supports; localising the bracketed phrase lands when
497
+ * the storefront primitive grows a translation surface.
498
+ */
499
+ function format(args) {
500
+ if (!args || typeof args !== "object") throw new TypeError("tax.format: args object required");
501
+ _bps(args.rate_bps);
502
+ if (args.locale !== undefined && args.locale !== null) {
503
+ if (typeof args.locale !== "string" || args.locale.length === 0) {
504
+ throw new TypeError("tax.format: locale must be a non-empty BCP 47 string");
505
+ }
506
+ }
507
+ var locale = args.locale || "en-US";
508
+ var nf;
509
+ try {
510
+ nf = new Intl.NumberFormat(locale, {
511
+ style: "percent",
512
+ minimumFractionDigits: 1,
513
+ maximumFractionDigits: 2,
514
+ });
515
+ } catch (_e) {
516
+ throw new TypeError("tax.format: locale " + JSON.stringify(locale) + " rejected by Intl.NumberFormat");
517
+ }
518
+ // Intl percent formatter expects a decimal fraction (0.2 → "20%").
519
+ var rendered = nf.format(args.rate_bps / 10000);
520
+ // Strip a redundant ".0" / ",0" when the rate is a whole percent
521
+ // AND not the zero rate — "20.0%" reads naturally; "0.0%" reads
522
+ // as a precision claim we don't intend. For 0% with the reverse-
523
+ // charge flag we always render "0%" to keep the annotation
524
+ // grammatical.
525
+ if (args.reverse_charge === true && args.rate_bps === 0) {
526
+ // Intl renders 0 as "0%" already in en-US but as "0 %" in de/fr;
527
+ // we want a stable shape with the annotation.
528
+ var zero = new Intl.NumberFormat(locale, {
529
+ style: "percent",
530
+ minimumFractionDigits: 0,
531
+ maximumFractionDigits: 0,
532
+ }).format(0);
533
+ return zero + " (reverse charge)";
534
+ }
535
+ return rendered;
536
+ }
537
+
157
538
  module.exports = {
158
- create: create,
159
- operatorTable: operatorTable,
160
- MAX_BPS: MAX_BPS,
539
+ create: create,
540
+ operatorTable: operatorTable,
541
+ MAX_BPS: MAX_BPS,
542
+ calculateInclusive: calculateInclusive,
543
+ calculateExclusive: calculateExclusive,
544
+ validateVatId: validateVatId,
545
+ applyReverseCharge: applyReverseCharge,
546
+ format: format,
547
+ VAT_FORMATS: VAT_FORMATS,
548
+ EU_COUNTRY_CODES: EU_COUNTRY_CODES,
161
549
  };
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.12.3",
7
- "tag": "v0.12.3",
6
+ "version": "0.12.4",
7
+ "tag": "v0.12.4",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.4 (2026-05-22) — **`SECURITY.md` Watch list — remove stale "framework doesn't ship CMS / S/MIME" entry.** The Watch list bullet claiming `framework does not ship a CMS / S/MIME / PKCS#7 surface today` has been wrong since v0.10.13 — `b.cms.encodeSignedData` / `decode` / `encodeEnvelopedData` / `parseSignedData` shipped then, and `b.mail.crypto.smime.sign` / `verify` / `verifyAll` / `checkCert` shipped under the mail-stack. The Watch list is for CVE classes the framework deliberately doesn't ship a primitive for; CMS no longer fits that shape. Entry removed. **Fixed:** *Watch list no longer claims CMS / S/MIME are unshipped* — `b.cms` exposes RFC 5652 ContentInfo / SignedData / EnvelopedData encode + decode with PQC signer support (ML-DSA-65 per RFC 9909 §5, ML-DSA-87 per RFC 9909 §6, SLH-DSA-SHAKE-256f per RFC 9881). `b.mail.crypto.smime` builds on it for RFC 8551 S/MIME signed + enveloped mail with `checkCert` for X.509 chain validation. The SECURITY.md Watch list entry that pointed operators to external CMS libraries is gone; operators on regulated mail interop reach for the in-framework primitives instead.
12
+
11
13
  - v0.12.3 (2026-05-22) — **README "What ships in the box" backfill — mail-stack listeners + JSCalendar + new postures.** The README's "Communication" + "Compliance regimes" bullets lagged behind the v0.11.24-v0.12.1 ship cadence. Backfilled: `b.mail.send.deliver` (turnkey outbound delivery chain), the four mail-server listeners (mx / submission / imap / jmap), the JMAP EmailSubmission/set reference handler, mail-crypto (CMS + PGP+WKD), the mail-stack agent, `b.calendar` (RFC 8984 JSCalendar substrate with full BY*+BYSETPOS+multi-rule expansion), and the 16 newly-promoted postures from v0.12.1 (`42-cfr-part-2` / `hti-1` / `uscdi-v4` / `irs-1075` / `nist-800-172-r3` / `tlp-2.0` / `soci-au` / `ffiec-cat-2` / `cri-profile-v2.0` / `m-22-09` / `m-22-18` / `nist-800-53-r5-privacy` / `nist-ai-600-1-genai` / `nist-csf-2.0` / `sb-53` / `nyc-ll144-2024`). **Changed:** *Communication section names every mail-stack listener + delivery chain + crypto primitive* — New entries: `b.mail.send.deliver` (MX → MTA-STS → DANE → REQUIRETLS → SMTP → DSN chain), four `b.mail.server.*` listeners, JMAP EmailSubmission reference handler, `b.mail.crypto.cms` + `b.mail.crypto.pgp`, `b.mail.agent` + `b.mailStore`, and `b.calendar` (JSCalendar / iCalendar bridge for JMAP Calendars interop). · *Compliance regimes section lists the 16 v0.12.1 backfilled postures* — New rows organise the additions under three sub-bullets: AI governance adds `nyc-ll144-2024` / `sb-53` / `nist-ai-rmf-1.0` / `nist-ai-600-1-genai` alongside the existing AI-act / NYC-LL144 / Colorado / Illinois entries; a new "Federal / sectoral" row covers `42-cfr-part-2` / `hti-1` / `uscdi-v4` / `irs-1075` / `nist-csf-2.0` / `nist-800-53-r5-privacy` / `nist-800-172-r3` / `m-22-09` / `m-22-18` / `ffiec-cat-2` / `cri-profile-v2.0`; a new "Critical infrastructure / info-sharing" row covers `soci-au` / `tlp-2.0`.
12
14
 
13
15
  - v0.12.2 (2026-05-22) — **Release-process docs point at `scripts/release.js` (the orchestrator shipped in v0.12.0).** `CONTRIBUTING.md` (maintainer section) and `examples/wiki/DEPLOY.md` ("Tag-driven releases") described the old multi-step manual release flow — version bump → commit → push → tag → push tag — without mentioning the v0.12.0 orchestrator. Both docs now point at `node scripts/release.js` as the canonical release mechanism, list the eight idempotent subcommands, and call out the two pre-requisites the script enforces (release-notes JSON + signed-commit config). **Added:** *`scripts/release.js regen` — re-run artifact regeneration mid-flow* — Edits to `release-notes/v<next>.json` after `prepare` (e.g. addressing a Codex finding, fixing a leak-vocabulary refusal) previously required running `node scripts/generate-changelog-entry.js --rebuild` + `scripts/refresh-api-snapshot.js` + `scripts/check-api-snapshot.js` + `scripts/check-changelog-extract.js` manually. The new `regen` subcommand wraps all four into a single idempotent step. Safe to run any time from any branch. The `prepare` phase calls the same shared helper internally so behaviour stays consistent. **Changed:** *`CONTRIBUTING.md` maintainer section names the orchestrator* — The release-process bullet now reads `node scripts/release.js — eight idempotent subcommands (prepare → smoke → commit → push → watch → merge → tag → publish) plus all for a one-shot`. The existing DEPLOY.md link stays as a pointer for the wiki-container side of the same flow. · *`examples/wiki/DEPLOY.md` Tag-driven releases section rewritten* — Replaces the four-bullet manual flow with the orchestrator surface, including the `all` / `all --minor` one-shot, the per-phase subcommands, and the pre-requisites the script enforces (release-notes JSON present, SSH signing config in place). The downstream wiki-image deploy step on the host (pin `docker-compose.prod.yml` + `docker compose pull && up -d`) is unchanged. **Fixed:** *`scripts/release.js` signature verification uses `git verify-commit` as the canonical truth* — The v0.12.0 orchestrator's commit-signature gate parsed `git log -1 --pretty=%h %G? %GS` looking for `G` in the second column. On some platforms the `%G?` format token's `?` character can be eaten by argument resolution, returning empty stdout even when the signature is Good. The fix runs `git verify-commit HEAD` (whose exit code is the canonical signal `required_signatures` GH ruleset enforces) as the primary check; the `%G?` parse stays as a human-readable confirmation but no longer gates the script. Surfaced via dogfooding the orchestrator on this very release. · *`scripts/release.js` Docker bind-mount path handles Windows host paths with spaces* — The `push` phase's gitleaks step bind-mounted the repo root via `-v <path>:/repo`. The previous path transform produced `/C:/Users/...` on Windows, which Docker's `-v src:dst[:mode]` parser interpreted as having three colon-separated fields. Fix: transform `C:\Users\...` to `//c/Users/...` (lowercased drive letter, double-slash prefix — matches Git Bash's `$(pwd)` form Docker Desktop accepts). POSIX hosts pass through unchanged. Operators with Windows paths containing spaces, parentheses, or special characters can now run `node scripts/release.js push` without manual mount fiddling.
@@ -412,7 +412,6 @@ CVE classes the framework tracks but does not currently ship a primitive for —
412
412
  - **AdonisJS multipart-filename → arbitrary-write class** — the framework's `b.fileUpload` routes every multipart filename through `b.guardFilename.gate({ profile: "strict" })` by default (path traversal / null-byte / NTFS ADS / UNC / overlong UTF-8 / Windows reserved names / RTLO bidi). Operators implementing a parallel multipart receiver outside the framework's primitive must wire the same gate.
413
413
  - **fs.realpath symlink-chain Permission Model bypass class** — see "Operator territory" entry above; the framework's symlink defenses live at the application layer (`b.vault` PEM-file read-side + `b.staticServe` realpath gate); operators using Node's experimental Permission Model add it as defense-in-depth, never as the primary symlink-resolution boundary.
414
414
  - **QUIC / HTTP/3 outbound (RFC 9000 / RFC 9001)** — the framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only. QUIC + HTTP/3 are deferred-with-condition: re-open when Node's `--experimental-quic` flag graduates to stable and `node:http3` ships. Until then, operators wanting outbound h3 wire their own client outside the framework (see `lib/http-client.js` header note on the future `kind: "h3"` transport shape). The framework's TLS 1.3 + h2 anti-amplification + flow-control caps remain in force on every other transport. Inbound h3 is similarly deferred; operators terminating h3 at the edge route h2 / h1 to the framework's `b.router`.
415
- - **CMS (RFC 5083 / RFC 5652) + SHAKE-in-CMS (RFC 8702)** — the framework does not ship a CMS / S/MIME / PKCS#7 surface today. Operators integrating S/MIME-encoded mail or PKCS#7-signed payloads route through a separately-firewalled CMS library and pin the SHAKE-256 / SHA3-512 hash identifiers from RFC 8702 §3 / §4 when they set the signing algorithm. CMS support is deferred-with-condition: re-open when operator demand surfaces for S/MIME-encoded mail receivers OR when a regulatory regime mandates CMS-shaped envelope formats. The framework's existing `b.crypto` envelope (XChaCha20-Poly1305 + ML-KEM-1024 + SLH-DSA) covers the at-rest + in-transit shapes operators need today without the CMS legacy surface.
416
415
 
417
416
  ---
418
417
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.3",
4
- "createdAt": "2026-05-22T21:50:06.665Z",
3
+ "frameworkVersion": "0.12.4",
4
+ "createdAt": "2026-05-22T23:12:46.997Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.4",
4
+ "date": "2026-05-22",
5
+ "headline": "`SECURITY.md` Watch list — remove stale \"framework doesn't ship CMS / S/MIME\" entry",
6
+ "summary": "The Watch list bullet claiming `framework does not ship a CMS / S/MIME / PKCS#7 surface today` has been wrong since v0.10.13 — `b.cms.encodeSignedData` / `decode` / `encodeEnvelopedData` / `parseSignedData` shipped then, and `b.mail.crypto.smime.sign` / `verify` / `verifyAll` / `checkCert` shipped under the mail-stack. The Watch list is for CVE classes the framework deliberately doesn't ship a primitive for; CMS no longer fits that shape. Entry removed.",
7
+ "sections": [
8
+ {
9
+ "heading": "Fixed",
10
+ "items": [
11
+ {
12
+ "title": "Watch list no longer claims CMS / S/MIME are unshipped",
13
+ "body": "`b.cms` exposes RFC 5652 ContentInfo / SignedData / EnvelopedData encode + decode with PQC signer support (ML-DSA-65 per RFC 9909 §5, ML-DSA-87 per RFC 9909 §6, SLH-DSA-SHAKE-256f per RFC 9881). `b.mail.crypto.smime` builds on it for RFC 8551 S/MIME signed + enveloped mail with `checkCert` for X.509 chain validation. The SECURITY.md Watch list entry that pointed operators to external CMS libraries is gone; operators on regulated mail interop reach for the in-framework primitives instead."
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ "references": []
19
+ }