@blamejs/core 0.11.38 → 0.11.39
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/CHANGELOG.md +2 -0
- package/lib/calendar.js +49 -11
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.39 (2026-05-21) — **`b.calendar.expandRecurrence` honors multiple `recurrenceRules` (RFC 8984 §4.3.2).** Closes the v0.11.31 deferral on the multi-rule path. `expandRecurrence` now walks every entry in `event.recurrenceRules`, expands each rule independently against the same `start` anchor, and UNIONs the resulting instances (dedup + sorted ascending). Per-rule `count` caps apply per-rule per RFC 8984 §4.3.2; the global `max` / `MAX_EXPAND_INSTANCES` cap applies to the unioned set. The step budget (`MAX_EXPAND_INSTANCES * 366`) is shared across all rules in the same expand call so an N-rule fan-out can't amplify the worst-case loop past the single-rule bound. **Added:** *Multi-rule expansion + UNION* — `event.recurrenceRules` of length > 1 now expands every rule, not just the first. Each rule's stepping (frequency / interval / count / until / BY* filters) operates independently; the resulting ISO 8601 UTC instant strings dedupe via object-keyed set semantics, then sort ascending. The single-rule case is structurally unchanged — same step loop, same per-rule cap behaviour. · *Per-rule `count` applies per-rule (RFC 8984 §4.3.2)* — Two rules with `count: 3` and `count: 2` produce up to 5 instances in the unioned output (minus any timestamps that dedupe across rules), not 5 instances total across the rules. Matches RFC 8984 §4.3.2 phrasing: each Recurrence Rule's count is applied per rule. · *Global step budget shared across rules* — The `MAX_EXPAND_INSTANCES * 366` step budget (introduced in v0.11.31) now tracks across rules in the same expand call. A 4-rule event with sparse BY* filters can't accumulate 4× the single-rule worst-case work; the budget is depleted across the full rule set. **Security:** *DoS amplification across N rules bounded by the same step budget* — The step budget is shared (not per-rule), so adversarial multi-rule events can't bypass the v0.11.31 BY*-filter cap by stacking N rules with sparse filters. Sparse-filter rules still complete within the original 10-year `MAX_EXPAND_SPAN_MS` window cap. **Detectors:** *No new detector — covered by existing recurrence-test surface* — Multi-rule UNION + dedup + global step budget are exercised by three new tests in `test/layer-0-primitives/calendar.test.js` (multi-rule union, same-rule dedup, global max applied to union). No detector needed — the bug class is testable end-to-end and the test file IS the canonical regression surface. **References:** [RFC 8984 §4.3.2 (JSCalendar RecurrenceRule — multi-rule expansion)](https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.2) · [RFC 5545 §3.8.5.3 (iCalendar RRULE — semantics under composition)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.5.3)
|
|
12
|
+
|
|
11
13
|
- v0.11.38 (2026-05-21) — **`b.mail.server.jmap.emailSubmissionSetHandler` — reference JMAP EmailSubmission/set composing `b.mail.send.deliver`.** Reference implementation of JMAP `EmailSubmission/set` (RFC 8621 §7.5) that composes `b.mail.send.deliver` (v0.11.24). Operators wire it as a method handler on `b.mail.server.jmap.create({ methods: ... })`. The handler walks `args.create`, validates each EmailSubmission's shape against the RFC 8621 §7.5 vocabulary (`identityId` / `emailId` / `envelope.mailFrom.email` / `envelope.rcptTo[]`), looks up the referenced Email blob via an operator-supplied `lookupEmail(emailId, accountId, actor)`, hands the RFC 822 body to `deliver(envelope)`, and maps the result into JMAP `deliveryStatus` (`recipient → { smtpReply, delivered, displayed }` per RFC 8621 §7.4). Closes the v0.11.24 deferral on a reference JMAP→deliver bridge. **Added:** *`b.mail.server.jmap.emailSubmissionSetHandler(opts)` factory* — Returns an async `(actor, args, ctx) → result` function suitable for the `opts.methods` map on `b.mail.server.jmap.create`. Required opts: `deliver` (a `b.mail.send.deliver` instance), `lookupEmail` (async `(emailId, accountId, actor) → Buffer|null`), `identities` (sync `(accountId) → Array<{ id, email }>`). Optional: `onCreated(subId, submission, accountId)` for persistence, `onDestroyed(subId, accountId)`, `onCancel(subId, accountId) → boolean` for undo support, `maxRecipients` (default 1000). · *Full RFC 8621 §7.5 error vocabulary* — Refusals map to the spec's typed `notCreated` shape with JMAP-namespaced types: `identityNotFound` (unknown identityId), `emailNotFound` (lookupEmail returned null), `forbiddenMailFrom` (envelope.mailFrom doesn't match identity.email), `invalidRecipients` (malformed envelope.rcptTo[i].email), `noRecipients` (empty rcptTo), `tooManyRecipients` (exceeds maxRecipients), `invalidProperties` (missing required keys). Per RFC 8621 §3.6.2, only `undoStatus` is honored in `args.update`; non-canceled values + non-undoStatus keys return `invalidProperties`. · *Delivery-result → JMAP deliveryStatus mapping* — `b.mail.send.deliver`'s `{ delivered, deferred, failed }` outcomes translate into the per-recipient JMAP deliveryStatus shape: `delivered` → `{ smtpReply, delivered: "yes" }`; `deferred` → `{ smtpReply, delivered: "queued" }`; `failed` → `{ smtpReply, delivered: "no" }`. `displayed` is `unknown` until a downstream MDN (RFC 9007) reports otherwise — operators with MDN ingest update via `EmailSubmission/set`'s update branch. · *Undo via `onCancel` hook* — `args.update[subId] = { undoStatus: "canceled" }` invokes `opts.onCancel(subId, accountId) → boolean`. Operators backing the JMAP server with a deferred-send queue (e.g. `b.outbox`) return true when the cancel succeeded; the handler refuses with `cannotUnsend` when `onCancel` isn't configured (operator hasn't wired a queue-based send model). · *Audit event on every set* — Emits `mail.jmap.emailsubmission.set` with `{ accountId, created, notCreated, updated, notUpdated, destroyed, notDestroyed }` counts. The metadata never carries recipient email addresses or RFC 822 body bytes — those stay in the operator's deliver primitive's per-host audit stream. **Security:** *Identity binding refuses spoofed `envelope.mailFrom`* — RFC 8621 §7.5.1.2 — every create gates `envelope.mailFrom.email` against the identity record's `email` field. The reference handler refuses with `forbiddenMailFrom` when they differ. Operators wiring a delegation model populate the identity's `mayDelegate` / per-domain authorized-senders list and supply the corresponding `identities(accountId)` lookup; the reference handler treats the lookup output as the trust source. · *Recipient count cap matches `b.mail.send.deliver`* — Default `maxRecipients = 1000` aligns with `b.mail.send.deliver`'s recipient-fan-out cap; operators reduce it for tighter postures (e.g. 50 for transactional-mail accounts) without weakening the deliver primitive's own gate. **Detectors:** *Two new KNOWN_CLUSTERS family-subset entries* — The new handler's opts-walk shingle structurally resembles `lib/importmap-integrity.js:build` (SRI module-map walk) and `lib/middleware/security-headers.js:create` (header-map walk). Added explanatory family-subset entries documenting why each primitive's per-entry body validates a distinct spec vocabulary (W3C Importmap-Integrity vs RFC 8621 §7.5 EmailSubmission vs HTTP header-value sanitisation) so the dup detector can't surface the false-positive again. **References:** [RFC 8621 §7 (JMAP EmailSubmission)](https://www.rfc-editor.org/rfc/rfc8621.html#section-7) · [RFC 8621 §7.5 (EmailSubmission/set)](https://www.rfc-editor.org/rfc/rfc8621.html#section-7.5) · [RFC 8621 §7.4 (deliveryStatus shape)](https://www.rfc-editor.org/rfc/rfc8621.html#section-7.4) · [RFC 8620 §3.6.1 (JMAP error vocabulary)](https://www.rfc-editor.org/rfc/rfc8620.html#section-3.6.1)
|
|
12
14
|
|
|
13
15
|
- v0.11.37 (2026-05-21) — **`b.calendar` VJOURNAL ↔ JSCalendar Note (RFC 5545 §3.6.3).** Closes the v0.11.31 deferral on VJOURNAL. `b.calendar.fromIcal` now recognises VJOURNAL components and maps them to JSCalendar-shaped Note objects (`@type: "Note"`). `b.calendar.toIcal` emits a VJOURNAL envelope when the input `@type` is `Note`. `b.calendar.validate` adds Note-specific shape rules: optional `start` LocalDateTime, no `duration` / `due` / `progress` / `percentComplete` / `progressUpdated` (those are Event / Task-only properties), optional `status` from the RFC 5545 §3.8.1.11 VJOURNAL vocabulary (`draft` | `final` | `cancelled`). VJOURNAL is the only iCalendar component that may carry multiple DESCRIPTION properties — Note preserves operator-visible boundaries by joining them with a blank-line separator. A VCALENDAR carrying VEVENT + VTODO + VJOURNAL children now returns a mixed `Event` + `Task` + `Note` array. **Added:** *`b.calendar.fromIcal` maps VJOURNAL → JSCalendar Note* — VJOURNAL components in the VCALENDAR map to `@type: "Note"` objects with `uid` / `updated` / `title` (SUMMARY) / `description` (DESCRIPTION) / `start` (DTSTART → LocalDateTime, optional) / `timeZone` / `status` (lower-cased STATUS) / `locations` / `recurrenceRules`. UTC DTSTART maps to `timeZone: "Etc/UTC"` the same way DTSTART does for Event. · *`b.calendar.toIcal` emits VJOURNAL when `@type === "Note"`* — The envelope is `BEGIN:VJOURNAL` / `END:VJOURNAL` instead of `BEGIN:VEVENT` or `BEGIN:VTODO`. Note's `status` round-trips uppercased per RFC 5545 §3.8.1.11. Note does NOT emit DURATION / DUE / PERCENT-COMPLETE / COMPLETED on the wire (those properties are forbidden on VJOURNAL per RFC 5545 §3.6.3 grammar). · *`b.calendar.validate` learns Note-specific shape rules (RFC 5545 §3.6.3)* — Note's `start` must be a LocalDateTime when present. Setting `duration`, `due`, `progress`, `percentComplete`, or `progressUpdated` on a Note is refused (those are Event / Task-only properties). `status` must be in the `JSCAL_NOTE_STATUS` catalogue (`draft` | `final` | `cancelled` — different from the Task progress vocabulary). Structured `CalendarError` codes: `calendar/bad-note-status` plus reuse of `calendar/bad-due`, `calendar/bad-progress`, `calendar/bad-duration`, `calendar/bad-percent`, `calendar/bad-progress-updated` for the forbidden-property refusals. · *Multiple DESCRIPTION properties preserved* — VJOURNAL is the only iCalendar component permitted to carry multiple DESCRIPTION properties (one per discrete journal entry). `fromIcal` joins them into a single Note.description with `\n\n` between entries, preserving the operator-visible boundary. Single-DESCRIPTION VJOURNALs map to the literal string with no join. · *Mixed-component VCALENDAR returns array of Event + Task + Note shapes* — A VCALENDAR carrying VEVENT + VTODO + VJOURNAL children now returns an Array — events first, tasks second, journals third, in their declared order. The single-component shortcut (returning the bare object) still applies when only one component is present. · *`JSCAL_TYPES.Note` and `JSCAL_NOTE_STATUS` exports* — `b.calendar.JSCAL_TYPES.Note === "Note"` (the discriminator string). `b.calendar.JSCAL_NOTE_STATUS` exposes the `draft` / `final` / `cancelled` vocabulary as a frozen object for operator-side enum lookups. **Changed:** *JSCalendar `Note` is a blamejs-recognised extension* — RFC 8984 §1.2 only enumerates `Event`, `Task`, and `Group` as discriminator values. `Note` is not formally an RFC 8984 type — blamejs adopts it as a recognised extension shape for VJOURNAL round-trip interop. Operators interoperating with strict RFC 8984 consumers should map Note → Group + a vendor-namespaced property before exchange. **References:** [RFC 5545 §3.6.3 (iCalendar VJOURNAL component)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.6.3) · [RFC 5545 §3.8.1.11 (STATUS — DRAFT/FINAL/CANCELLED on VJOURNAL)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.8.1.11) · [RFC 8984 §1.2 (JSCalendar @type discriminator)](https://www.rfc-editor.org/rfc/rfc8984.html#section-1.2)
|
package/lib/calendar.js
CHANGED
|
@@ -424,9 +424,14 @@ function toIcal(jsCal, opts) {
|
|
|
424
424
|
* defend against CVE-2024-39687-class recurrence-bomb expansion.
|
|
425
425
|
*
|
|
426
426
|
* v1 supports FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
|
|
427
|
-
* UNTIL. BYDAY / BYMONTH / BYMONTHDAY
|
|
428
|
-
*
|
|
429
|
-
*
|
|
427
|
+
* UNTIL. BYDAY / BYMONTH / BYMONTHDAY / BYWEEKNO / BYYEARDAY /
|
|
428
|
+
* BYHOUR / BYMINUTE / BYSECOND refine the base frequency. Multiple
|
|
429
|
+
* `recurrenceRules` are expanded independently and UNIONed; per
|
|
430
|
+
* RFC 8984 §4.3.2 each rule's `count` cap applies per-rule, not to
|
|
431
|
+
* the combined set. BYSETPOS remains deferred-with-condition
|
|
432
|
+
* (requires expanding ALL candidates within a FREQ interval and
|
|
433
|
+
* picking the Nth — a structural restructure of the step loop;
|
|
434
|
+
* RFC 7529 non-Gregorian calendars not in scope either).
|
|
430
435
|
*
|
|
431
436
|
* @opts
|
|
432
437
|
* from: string, // ISO 8601 UTC timestamp — lower bound of expansion window
|
|
@@ -471,10 +476,42 @@ function expandRecurrence(event, opts) {
|
|
|
471
476
|
throw new CalendarError("calendar/bad-start",
|
|
472
477
|
"b.calendar.expandRecurrence: event.start is not a parseable date");
|
|
473
478
|
}
|
|
479
|
+
// RFC 8984 §4.3.2 — when multiple RecurrenceRule objects are
|
|
480
|
+
// specified, they are expanded independently and the resulting
|
|
481
|
+
// instances are UNIONed (deduped + sorted ascending). Per-rule
|
|
482
|
+
// count caps apply per-rule per the same section.
|
|
483
|
+
var globalStepBudget = MAX_EXPAND_INSTANCES * 366; // allow:raw-byte-literal — total days/year step budget shared across all rules
|
|
484
|
+
var seen = Object.create(null);
|
|
485
|
+
var unioned = [];
|
|
486
|
+
for (var rrIndex = 0; rrIndex < event.recurrenceRules.length; rrIndex += 1) {
|
|
487
|
+
var perRule = _expandSingleRule(event.recurrenceRules[rrIndex], startMs, {
|
|
488
|
+
fromMs: fromMs,
|
|
489
|
+
toMs: toMs,
|
|
490
|
+
maxCount: maxCount,
|
|
491
|
+
stepBudgetRef: { remaining: globalStepBudget },
|
|
492
|
+
});
|
|
493
|
+
globalStepBudget = perRule.stepBudgetRemaining;
|
|
494
|
+
for (var pi = 0; pi < perRule.instances.length; pi += 1) {
|
|
495
|
+
var iso = perRule.instances[pi];
|
|
496
|
+
if (!seen[iso]) {
|
|
497
|
+
seen[iso] = true;
|
|
498
|
+
unioned.push(iso);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
unioned.sort();
|
|
503
|
+
return unioned.length > maxCount ? unioned.slice(0, maxCount) : unioned;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Expand ONE RecurrenceRule per the v0.11.31..v0.11.36 logic. Returns
|
|
507
|
+
// `{ instances: [isoZ...], stepBudgetRemaining: <int> }`. Caller is
|
|
508
|
+
// responsible for merging across rules (deduplication + sort + global
|
|
509
|
+
// cap).
|
|
510
|
+
function _expandSingleRule(rule, startMs, ctx) {
|
|
511
|
+
var fromMs = ctx.fromMs;
|
|
512
|
+
var toMs = ctx.toMs;
|
|
513
|
+
var maxCount = ctx.maxCount;
|
|
474
514
|
var out = [];
|
|
475
|
-
// We honour ONLY the first recurrenceRule in v1; multiple rules
|
|
476
|
-
// compose via union which is a follow-up.
|
|
477
|
-
var rule = event.recurrenceRules[0];
|
|
478
515
|
var interval = Math.max(1, parseInt(rule.interval || 1, 10));
|
|
479
516
|
var freq = rule.frequency;
|
|
480
517
|
var count = isFinite(rule.count) ? rule.count : Infinity;
|
|
@@ -612,10 +649,11 @@ function expandRecurrence(event, opts) {
|
|
|
612
649
|
// Safety cap on the step loop: at most MAX_EXPAND_INSTANCES * 366
|
|
613
650
|
// iterations so BY* filters that match sparsely (e.g. FREQ=DAILY;
|
|
614
651
|
// BYMONTH=1 — only Jan days survive) cannot loop forever inside
|
|
615
|
-
// the 10-year span cap.
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
652
|
+
// the 10-year span cap. The budget is SHARED across all rules in
|
|
653
|
+
// the same expand call so an N-rule union can't amplify the
|
|
654
|
+
// worst-case work past the single-rule bound.
|
|
655
|
+
while (out.length < count && out.length < maxCount && ctx.stepBudgetRef.remaining > 0) {
|
|
656
|
+
ctx.stepBudgetRef.remaining -= 1;
|
|
619
657
|
if (t > untilMs) break;
|
|
620
658
|
if (toMs !== null && t > toMs) break;
|
|
621
659
|
if (_matchesBy(t)) {
|
|
@@ -629,7 +667,7 @@ function expandRecurrence(event, opts) {
|
|
|
629
667
|
"b.calendar.expandRecurrence: unsupported frequency '" + freq + "'");
|
|
630
668
|
}
|
|
631
669
|
}
|
|
632
|
-
return out;
|
|
670
|
+
return { instances: out, stepBudgetRemaining: ctx.stepBudgetRef.remaining };
|
|
633
671
|
}
|
|
634
672
|
|
|
635
673
|
// ---- Internal helpers ----------------------------------------------------
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:9bd69988-4e90-47bf-a67c-b9301b0918ca",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-21T23:
|
|
8
|
+
"timestamp": "2026-05-21T23:30:40.324Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.11.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.39",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.39",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.11.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.11.39",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.11.
|
|
57
|
+
"ref": "@blamejs/core@0.11.39",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|