@blamejs/core 0.11.40 → 0.11.41

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 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.41 (2026-05-21) — **`b.calendar.expandRecurrence` picks up BYSETPOS (RFC 5545 §3.3.10).** Closes the v0.11.31 deferral on BYSETPOS — the recurrence filter that picks the Nth candidate from a BY*-filtered set within a FREQ interval. Common operator patterns now work directly: `FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1` for last Friday of each month, `FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2` for second Tuesday, `FREQ=YEARLY;BYMONTH=10;BYDAY=SU;BYSETPOS=1` for first Sunday of October (the DST-end announcement pattern). Supported for `FREQ=MONTHLY` / `YEARLY` / `WEEKLY` at day-granularity (time-of-day inherited from `start`). `FREQ=DAILY` + BYSETPOS is refused with `calendar/bad-recurrence` since the semantics aren't meaningful at sub-day frequency. **Added:** *`bySetPos` filter on RecurrenceRule* — `recurrenceRules[i].bySetPos: [1, -1, 2]` picks the listed positions from the BY*-filtered candidate set within each FREQ interval. Positive values are 1-indexed from the start of the period; negative values count from the end. Multiple positions emit per period (e.g. `[1, -1]` emits both the first and last matching day of each month). Out-of-range positions silently drop per RFC 5545's tolerant grammar. · *MONTHLY / YEARLY / WEEKLY support* — BYSETPOS expands within month / year / WKST-aligned-week boundaries. For each period, all day-level candidates that pass the existing BY* filters (byDay / byMonth / byMonthDay / byWeekNo / byYearDay) are enumerated, sorted ascending, then indexed by `bySetPos`. The expand loop's step budget (`MAX_EXPAND_INSTANCES * 366`) is shared across periods so the BYSETPOS path can't outrun the v0.11.31 DoS bound. · *DAILY frequency refused* — `FREQ=DAILY` + BYSETPOS throws `calendar/bad-recurrence` at expand time. The combination has no meaningful per-period set semantics (a day has no sub-day BY* set to pick from in the v1 day-granularity model). Operators with a sub-day BYSETPOS use case use `FREQ=HOURLY` semantics directly without BYSETPOS, or open an issue with the use case. **Security:** *Step budget shared with non-BYSETPOS path* — BYSETPOS enumeration decrements the same `MAX_EXPAND_INSTANCES * 366` step budget the non-BYSETPOS path uses, and the per-period day-loop has a hard 400-iteration safety cap (covers max-366-days in a leap year + slack). Adversarial events combining many rules + sparse BY* filters + BYSETPOS can't outpace the original single-rule DoS bound. **References:** [RFC 5545 §3.3.10 (RRULE — BYSETPOS)](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.10) · [RFC 8984 §4.3.2 (JSCalendar RecurrenceRule)](https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.2)
12
+
11
13
  - v0.11.40 (2026-05-21) — **Wiki Docker default port moves from 8080 → 3008.** The `examples/wiki` Docker stack now defaults to port 3008 throughout — `WIKI_PORT`, the Dockerfile `EXPOSE` directive, the in-container HEALTHCHECK URL, the dev `docker-compose.yml` host mapping, the Caddy reverse-proxy upstream, and the `server.js` + `lib/build-app.js` code defaults. Production deployments (`docker-compose.prod.yml`) are operator-invisible since Caddy fronts the container on 80/443 — the upstream port is never exposed to the host. Dev users hitting the wiki container directly now use `http://localhost:3008`. Operators who set `WIKI_PORT` explicitly in their `.env` keep working unchanged. **Changed:** *Wiki default port 8080 → 3008* — Default `WIKI_PORT` moves from 8080 (HTTP-alt, frequently collides with Tomcat / Jenkins / Confluence / Spring-Boot defaults) to 3008 (IANA-unassigned, no service-convention collision). The new default applies consistently across: `examples/wiki/Dockerfile` (`ENV WIKI_PORT`, `EXPOSE`, HEALTHCHECK URL), `examples/wiki/server.js`, `examples/wiki/lib/build-app.js`, `examples/wiki/docker-compose.yml` (host mapping `3008:3008`), `examples/wiki/docker-compose.prod.yml` (internal-network expose), `examples/wiki/Caddyfile` (upstream resolver), `examples/wiki/README.md`, `examples/wiki/DEPLOY.md`. · *Operator action* — Dev users with `localhost:8080` bookmarks, curl scripts, host-firewall allowlists, or IDE port forwarders pointed at the wiki need to switch to `localhost:3008`. Operators who set `WIKI_PORT=8080` explicitly in their `.env` keep working unchanged. Production deployments behind Caddy see no change — the upstream port is internal-network only. **References:** [IANA Service Name and Port Number Registry](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml)
12
14
 
13
15
  - 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)
package/lib/calendar.js CHANGED
@@ -425,13 +425,14 @@ function toIcal(jsCal, opts) {
425
425
  *
426
426
  * v1 supports FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
427
427
  * UNTIL. BYDAY / BYMONTH / BYMONTHDAY / BYWEEKNO / BYYEARDAY /
428
- * BYHOUR / BYMINUTE / BYSECOND refine the base frequency. Multiple
428
+ * BYHOUR / BYMINUTE / BYSECOND refine the base frequency. BYSETPOS
429
+ * picks the Nth candidate from the BY*-filtered set within a FREQ
430
+ * interval (positive = 1-indexed from start, negative = from end);
431
+ * supported for FREQ=MONTHLY / YEARLY / WEEKLY with day-granularity
432
+ * candidates (time-of-day inherited from start). Multiple
429
433
  * `recurrenceRules` are expanded independently and UNIONed; per
430
434
  * 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).
435
+ * the combined set. (RFC 7529 non-Gregorian calendars not in scope.)
435
436
  *
436
437
  * @opts
437
438
  * from: string, // ISO 8601 UTC timestamp — lower bound of expansion window
@@ -645,6 +646,36 @@ function _expandSingleRule(rule, startMs, ctx) {
645
646
  if (bySecondSet && !bySecondSet[d.getUTCSeconds()]) return false;
646
647
  return true;
647
648
  }
649
+ // RFC 5545 §3.3.10 BYSETPOS — picks the Nth candidate from the
650
+ // BY*-filtered set within a FREQ interval. Positive = 1-indexed
651
+ // from start; negative = from end. Operators reach for this most
652
+ // often with "last Friday of month" (FREQ=MONTHLY;BYDAY=FR;
653
+ // BYSETPOS=-1) or "second Tuesday of month" (BYSETPOS=2).
654
+ //
655
+ // v1 supports BYSETPOS for FREQ=MONTHLY / YEARLY / WEEKLY at
656
+ // day-granularity — candidates are days within the period at the
657
+ // start's time-of-day. Sub-day BY* filters (byHour/byMinute/
658
+ // bySecond) are ignored under BYSETPOS for v1; the rare combo
659
+ // (BYSETPOS + byHour) reverts to the standard non-bysetpos step
660
+ // path when applicable.
661
+ var bySetPosArr = _bySetPosArray(rule.bySetPos);
662
+ if (bySetPosArr) {
663
+ return _expandWithBysetpos({
664
+ rule: rule,
665
+ startMs: startMs,
666
+ freq: freq,
667
+ interval: interval,
668
+ count: count,
669
+ untilMs: untilMs,
670
+ fromMs: fromMs,
671
+ toMs: toMs,
672
+ maxCount: maxCount,
673
+ matchesBy: _matchesBy,
674
+ bySetPos: bySetPosArr,
675
+ stepBudgetRef: ctx.stepBudgetRef,
676
+ });
677
+ }
678
+
648
679
  var t = startMs;
649
680
  // Safety cap on the step loop: at most MAX_EXPAND_INSTANCES * 366
650
681
  // iterations so BY* filters that match sparsely (e.g. FREQ=DAILY;
@@ -670,6 +701,139 @@ function _expandSingleRule(rule, startMs, ctx) {
670
701
  return { instances: out, stepBudgetRemaining: ctx.stepBudgetRef.remaining };
671
702
  }
672
703
 
704
+ // Parse + validate rule.bySetPos. Returns null when absent / empty;
705
+ // otherwise an array of integers in [-366, -1] U [1, 366] (RFC 5545
706
+ // grammar). Zero values + out-of-range values are silently dropped.
707
+ function _bySetPosArray(raw) {
708
+ if (!Array.isArray(raw) || raw.length === 0) return null;
709
+ var out = [];
710
+ for (var i = 0; i < raw.length; i += 1) {
711
+ var n = parseInt(raw[i], 10);
712
+ if (isFinite(n) && n !== 0 && n >= -366 && n <= 366) out.push(n); // allow:raw-byte-literal — RFC 5545 §3.3.10 bysetpos range
713
+ }
714
+ return out.length > 0 ? out : null;
715
+ }
716
+
717
+ // BYSETPOS expander. Iterates by FREQ interval; for each period,
718
+ // enumerates day-level candidates within the period; applies the
719
+ // caller's matchesBy filter; sorts ascending; picks the position(s)
720
+ // per bySetPos. Time-of-day per candidate matches the rule's start.
721
+ function _expandWithBysetpos(ctx) {
722
+ var startMs = ctx.startMs;
723
+ var freq = ctx.freq;
724
+ var interval = ctx.interval;
725
+ var count = ctx.count;
726
+ var untilMs = ctx.untilMs;
727
+ var fromMs = ctx.fromMs;
728
+ var toMs = ctx.toMs;
729
+ var maxCount = ctx.maxCount;
730
+ var matchesBy = ctx.matchesBy;
731
+ var bySetPos = ctx.bySetPos;
732
+ var stepBudgetRef = ctx.stepBudgetRef;
733
+
734
+ if (freq !== "monthly" && freq !== "yearly" && freq !== "weekly") {
735
+ throw new CalendarError("calendar/bad-recurrence",
736
+ "b.calendar.expandRecurrence: BYSETPOS supported only with FREQ=MONTHLY / YEARLY / WEEKLY (got '" + freq + "')");
737
+ }
738
+
739
+ var startDate = new Date(startMs);
740
+ var hh = startDate.getUTCHours();
741
+ var mm = startDate.getUTCMinutes();
742
+ var ss = startDate.getUTCSeconds();
743
+ var ms = startDate.getUTCMilliseconds();
744
+
745
+ var out = [];
746
+ // Period anchor (period 0 = start's period).
747
+ var periodIndex = 0;
748
+
749
+ while (out.length < count && out.length < maxCount && stepBudgetRef.remaining > 0) {
750
+ var period = _periodForIndex(freq, startDate, periodIndex * interval);
751
+ periodIndex += 1;
752
+ // Out-of-window early exit. Window-uppper applies once the period
753
+ // start crosses toMs; until applies once period-start crosses untilMs.
754
+ if (period.startMs > untilMs) break;
755
+ if (toMs !== null && period.startMs > toMs) break;
756
+
757
+ // Enumerate day-level candidates within the period at start's
758
+ // time-of-day. The budget decrements per candidate so adversarial
759
+ // periods (e.g. YEARLY = 366 days) can't loop forever.
760
+ var candidates = [];
761
+ var dayMs = period.startMs;
762
+ var safety = 400; // allow:raw-byte-literal — period day cap (covers leap year 366 + slack)
763
+ while (dayMs <= period.endMs && safety-- > 0 && stepBudgetRef.remaining > 0) {
764
+ stepBudgetRef.remaining -= 1;
765
+ var candidate = _withTimeOfDay(dayMs, hh, mm, ss, ms);
766
+ if (matchesBy(candidate)) candidates.push(candidate);
767
+ dayMs += 86400000; // allow:raw-time-literal — 86400000 ms/day step // allow:raw-byte-literal — same constant in ms/day form
768
+ }
769
+
770
+ // Sort + apply BYSETPOS. Positive index 1-based from start;
771
+ // negative from end. Out-of-range positions silently drop.
772
+ candidates.sort(function (a, b) { return a - b; });
773
+ var picked = Object.create(null);
774
+ for (var pi = 0; pi < bySetPos.length; pi += 1) {
775
+ var pos = bySetPos[pi];
776
+ var idx = pos > 0 ? pos - 1 : candidates.length + pos;
777
+ if (idx >= 0 && idx < candidates.length) picked[candidates[idx]] = true;
778
+ }
779
+ // Emit picked candidates in ascending order, gated by window +
780
+ // untilMs + per-rule count cap.
781
+ //
782
+ // Codex P1 — recurrence instances MUST NOT precede DTSTART (per
783
+ // RFC 5545 §3.8.5.3). The period-boundary enumeration above
784
+ // includes candidates BEFORE startMs when the period containing
785
+ // startMs has earlier BY*-matching days (e.g. start = May 20
786
+ // Friday, BYDAY=FR;BYSETPOS=1 → enumeration would pick May 1).
787
+ // Refusing pre-start candidates here both fixes the semantics
788
+ // AND avoids consuming the per-rule COUNT cap on instances the
789
+ // operator never asked for.
790
+ var pickedKeys = Object.keys(picked).map(Number).sort(function (a, b) { return a - b; });
791
+ for (var ki = 0; ki < pickedKeys.length; ki += 1) {
792
+ var pickedMs = pickedKeys[ki];
793
+ if (pickedMs < startMs) continue;
794
+ if (pickedMs > untilMs) { count = out.length; break; }
795
+ if (toMs !== null && pickedMs > toMs) { count = out.length; break; }
796
+ if (fromMs !== null && pickedMs < fromMs) continue;
797
+ if (out.length >= count || out.length >= maxCount) break;
798
+ out.push(_msToIsoZ(pickedMs));
799
+ }
800
+ }
801
+ return { instances: out, stepBudgetRemaining: stepBudgetRef.remaining };
802
+ }
803
+
804
+ // Compute period [startMs, endMs] given a base start date + interval
805
+ // offset. Period START is anchored at the first second of the period
806
+ // (Jan 1 for YEARLY, day 1 for MONTHLY, WKST-Monday for WEEKLY) so
807
+ // the day-enumeration loop strides whole-day from there.
808
+ function _periodForIndex(freq, startDate, offset) {
809
+ if (freq === "yearly") {
810
+ var year = startDate.getUTCFullYear() + offset;
811
+ var ys = Date.UTC(year, 0, 1, 0, 0, 0, 0);
812
+ var ye = Date.UTC(year + 1, 0, 1, 0, 0, 0, 0) - 1;
813
+ return { startMs: ys, endMs: ye };
814
+ }
815
+ if (freq === "monthly") {
816
+ var bm = startDate.getUTCMonth() + offset;
817
+ var by = startDate.getUTCFullYear() + Math.floor(bm / 12); // allow:raw-byte-literal — months/year
818
+ var mm = ((bm % 12) + 12) % 12; // allow:raw-byte-literal — months/year
819
+ var ms = Date.UTC(by, mm, 1, 0, 0, 0, 0);
820
+ var me = Date.UTC(by, mm + 1, 1, 0, 0, 0, 0) - 1;
821
+ return { startMs: ms, endMs: me };
822
+ }
823
+ // weekly — align to WKST=Monday (RFC 5545 default WKST).
824
+ var anchor = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate(), 0, 0, 0, 0));
825
+ var dow = anchor.getUTCDay() || 7;
826
+ anchor.setUTCDate(anchor.getUTCDate() - (dow - 1) + offset * 7); // allow:raw-byte-literal — days/week
827
+ var ws = anchor.getTime();
828
+ var we = ws + 7 * 86400000 - 1; // allow:raw-byte-literal + allow:raw-time-literal — 7-day window
829
+ return { startMs: ws, endMs: we };
830
+ }
831
+
832
+ function _withTimeOfDay(dayMs, hh, mm, ss, ms) {
833
+ var d = new Date(dayMs);
834
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hh, mm, ss, ms);
835
+ }
836
+
673
837
  // ---- Internal helpers ----------------------------------------------------
674
838
 
675
839
  function _veventToJsCalEvent(ve) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.40",
3
+ "version": "0.11.41",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:1f82638d-96d9-44e6-9642-4d336ee1d858",
5
+ "serialNumber": "urn:uuid:5557c11a-5977-46f0-8a4f-c25e3b621e70",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-22T00:19:00.297Z",
8
+ "timestamp": "2026-05-22T01:26:53.795Z",
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.40",
22
+ "bom-ref": "@blamejs/core@0.11.41",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.40",
25
+ "version": "0.11.41",
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.40",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.41",
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.40",
57
+ "ref": "@blamejs/core@0.11.41",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]