@blamejs/core 0.11.30 → 0.11.32
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 +6 -0
- package/index.js +2 -0
- package/lib/calendar.js +581 -0
- package/lib/mail-crypto-pgp.js +41 -0
- package/lib/mail-server-registry.js +11 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.32 (2026-05-21) — **`b.mail.crypto.pgp.encrypt` / `.decrypt` / `.wkd` promoted to stable — WKD IDN-homograph defense.** The PGP encrypt / decrypt / Web Key Directory (WKD) primitives that shipped under `b.mail.crypto.pgp.experimental.*` at v0.10.16 are promoted to top-level stable. `b.mail.crypto.pgp.encrypt` / `b.mail.crypto.pgp.decrypt` / `b.mail.crypto.pgp.wkd.fetch` / `b.mail.crypto.pgp.wkd.computeUrl` are now the canonical paths; the v0.10.16 `experimental` aliases continue to work so existing operator code keeps importing through the old path until they migrate at their own pace. WKD picks up a hard IDN-homograph refusal at `wkd.computeUrl` — Cyrillic / Greek / full-width / Han confusable domain characters refuse with `mail-crypto/pgp/bad-domain` before any HTTP fetch runs. Operators with internationalised domains MUST Punycode-encode upstream (RFC 3492 `xn--` form). **Added:** *Top-level `b.mail.crypto.pgp.encrypt` / `.decrypt` / `.wkd.{fetch,computeUrl}`* — Same function bodies that shipped under `b.mail.crypto.pgp.experimental.*` at v0.10.16 are now exported at the top of the namespace. The framework-private envelope (BJ-PGP-PQ magic + version) is unchanged; the IANA-pending RFC 9580bis ML-KEM PKESK codepoints will land as an alternate-encoding option in a follow-up slice. The `experimental` alias keeps the v0.10.16 import paths working — operator migration is opt-in. `b.mail.crypto.pgp.encrypt === b.mail.crypto.pgp.experimental.encrypt` (same reference) so a feature-detection test like `typeof b.mail.crypto.pgp.encrypt === 'function'` is the new operator-facing pattern. **Security:** *WKD IDN-homograph refusal at `wkd.computeUrl`* — `wkd.computeUrl(email)` now refuses any domain containing characters outside the LDH+dot ASCII subset (RFC 952 / RFC 1123 §2). The threat model: Cyrillic `а` (U+0430), Greek `ο` (U+03BF), full-width `A` (U+FF21), and Han homographs visually impersonate Latin letters in `paypa1.com` / `gοogle.com` style phishing host strings; a naive `toLowerCase` + concat into the WKD URL would route the key fetch to the attacker's domain. Operators with legitimate internationalised domains MUST Punycode-encode upstream (the `xn--` form is plain ASCII LDH and passes). The framework's `b.httpClient` already refuses non-ASCII hostnames at the SSRF guard layer; this primitive surfaces the same refusal at the WKD entry point so the error surface is consistent. · *RFC 5321 + RFC 1035 length caps* — Email length capped at 320 octets (RFC 5321 §4.5.3.1 maximum). Domain length capped at 253 octets (RFC 1035 §2.3.4). Empty labels (`bad..example.com`), leading-dot, and trailing-dot domains refuse before any URL construction. Adversarial-length inputs cannot reach the tokenizer / hasher path. **References:** [draft-koch-openpgp-webkey-service (Web Key Directory)](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/) · [RFC 9580 (OpenPGP)](https://www.rfc-editor.org/rfc/rfc9580.html) · [RFC 3492 (Punycode — IDNA `xn--` form)](https://www.rfc-editor.org/rfc/rfc3492.html) · [RFC 5891 (IDNA 2008)](https://www.rfc-editor.org/rfc/rfc5891.html) · [RFC 5321 §4.5.3.1 (SMTP — local-part + domain octet maximums)](https://www.rfc-editor.org/rfc/rfc5321.html) · [RFC 1035 §2.3.4 (domain label length)](https://www.rfc-editor.org/rfc/rfc1035.html) · [Unicode TR 39 (Security Mechanisms — confusable identifiers)](https://www.unicode.org/reports/tr39/)
|
|
12
|
+
|
|
13
|
+
- v0.11.31 (2026-05-21) — **`b.calendar` — JSCalendar (RFC 8984) primitive + JMAP Calendars method catalogue.** JSCalendar is the JSON-native calendar grammar JMAP Calendars rides on. The framework now ships `b.calendar` — a thin layer over the existing `b.safeIcal.parse` (RFC 5545 bounded parser shipped earlier) that exposes validate / fromIcal / toIcal / expandRecurrence. The JMAP method catalogue at `b.mail.serverRegistry` picks up the 15 Calendar / CalendarEvent / CalendarEventNotification / ParticipantIdentity methods per the draft-ietf-jmap-calendars spec, so operators wire them through the existing `b.mail.server.jmap.create({ methods: { ... } })` dispatch path without `allowExperimental: true` escape-hatches.
|
|
14
|
+
|
|
15
|
+
v1 scope: validate JSCalendar Event / Task shape per RFC 8984 §5/§6, bidirectional VEVENT ↔ JSCalendar conversion, RRULE expansion for FREQ=DAILY/WEEKLY/MONTHLY/YEARLY/HOURLY/MINUTELY/SECONDLY with INTERVAL / COUNT / UNTIL / BYDAY / BYMONTH / BYMONTHDAY. BYSETPOS / BYWEEKNO / BYYEARDAY filters + non-Gregorian calendars (RFC 7529) + JSCalendar Group objects + VTODO/VJOURNAL mapping are deferred-with-condition for follow-up slices. **Added:** *`b.calendar.validate(jsCal)` — JSCalendar Event/Task shape gate (RFC 8984 §5/§6)* — Asserts `@type` ∈ { 'Event', 'Task' }, non-empty `uid` (≤ 1024 bytes), `updated` matches UTCDateTime grammar, Event's optional `start` matches LocalDateTime, `duration` is RFC 8601 PnYnMnDTnHnMnS, every `recurrenceRules[i].@type` is 'RecurrenceRule' with `frequency` in the JSCAL_FREQUENCIES catalogue, every `alerts.{id}.action` is 'display' or 'email'. Returns the input on success; throws `CalendarError` with a structured `.code` (`calendar/no-uid`, `calendar/bad-recurrence`, etc.) on refusal so operator-side error handling has a stable surface. · *`b.calendar.fromIcal(text, opts?)` + `b.calendar.toIcal(jsCal, opts?)`* — Bidirectional bridge: iCalendar (RFC 5545) ↔ JSCalendar (RFC 8984). `fromIcal` runs the text through `b.safeIcal.parse` (already CVE-bounded for parser DoS) and maps the first VEVENT into an Event object (UID → uid, DTSTAMP → updated, DTSTART → start, DURATION → duration, SUMMARY → title, DESCRIPTION → description, LOCATION → locations[L1].name, RRULE → recurrenceRules[0]). `toIcal` round-trips the same shape back through a CRLF-folded VCALENDAR envelope per RFC 5545 §3.1 (75-octet content-line cap). Operators wire it on the EmailSubmission send path so calendar invitations from a JSCalendar back-end emit RFC 5545 over MIME, and inbound iCalendar attachments parse straight into the JMAP method's Event return shape. · *`b.calendar.expandRecurrence(event, { from, to, max })` — bounded RRULE expansion* — Emits ISO 8601 UTC instance timestamps for an Event in the operator's `[from, to]` window. Supports FREQ=DAILY/WEEKLY/MONTHLY/YEARLY/HOURLY/MINUTELY/SECONDLY with INTERVAL, COUNT, UNTIL. Bounded by `MAX_EXPAND_INSTANCES` (4096) and `MAX_EXPAND_SPAN_MS` (10 years) — refuses with `calendar/oversize-expansion-span` when the window exceeds 10 years (CVE-2024-39687-class recurrence-bomb defense, mirroring the parser-side caps already in `b.safeIcal`). LocalDateTime starts without a `timeZone` are treated as floating-UTC during expansion so wall-clock semantics survive `Date.parse` host-locale interpretation. · *JMAP method catalogue: Calendar / CalendarEvent / CalendarEventNotification / ParticipantIdentity* — 15 new methods added to `JMAP_METHODS` in `lib/mail-server-registry.js` per draft-ietf-jmap-calendars: `Calendar/{get,changes,set,query,queryChanges}`, `CalendarEvent/{get,changes,query,queryChanges,set,copy}`, `CalendarEventNotification/{get,changes,query,queryChanges,set}`, `ParticipantIdentity/{get,changes,set}`. Operators wire concrete handlers through the existing `b.mail.server.jmap.create({ methods: { 'CalendarEvent/get': async function (actor, args) { ... } } })` path — no `allowExperimental: true` escape-hatch is required. **Security:** *Recurrence-bomb expansion caps* — `MAX_EXPAND_INSTANCES = 4096` + `MAX_EXPAND_SPAN_MS = 10 years` clamp the expansion budget at the framework boundary so an operator who forwards an unbounded JSCalendar from a hostile peer cannot DoS the host. The caps mirror `b.safeIcal`'s RRULE COUNT + BY-entries caps (which already defend the CVE-2024-39687 class). The 10-year span refusal carries `code: 'calendar/oversize-expansion-span'` so operators distinguish bomb defense from legitimate too-wide-window misconfiguration. · *UID + duration + UTCDateTime parsing is strict* — `validate` refuses `uid` over 1024 bytes (anti-DoS), `updated` that doesn't match the RFC 8984 §1.4.3 `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z` UTCDateTime grammar exactly (no `+00:00` aliases, no fractional-second-only suffixes), and `duration` that isn't `PnYnMnDTnHnMnS`. Hostile JSCalendar payloads from federation peers can't slip non-canonical-shape values into the operator's storage layer through the validator. · *iCalendar parsing routed through bounded `b.safeIcal.parse`* — `fromIcal` does NOT roll its own RFC 5545 parser — it forwards to `b.safeIcal.parse` which is already audit-hardened (CVE-2024-39929 / CVE-2025-30258 mitigation, bounded depth, capped RRULE COUNT + BY-entries). The JSCalendar layer composes the existing security substrate rather than re-litigating it. **References:** [RFC 8984 (JSCalendar — JSON Representation of Calendar Data)](https://www.rfc-editor.org/rfc/rfc8984.html) · [RFC 5545 (iCalendar — Internet Calendaring and Scheduling Core Object)](https://www.rfc-editor.org/rfc/rfc5545.html) · [draft-ietf-jmap-calendars (JMAP for Calendars)](https://datatracker.ietf.org/doc/draft-ietf-jmap-calendars/) · [RFC 8620 (JMAP Core)](https://www.rfc-editor.org/rfc/rfc8620.html) · [RFC 8601 (Duration grammar PnYnMnDTnHnMnS)](https://www.rfc-editor.org/rfc/rfc3339.html) · [RFC 7986 (New properties for iCalendar)](https://www.rfc-editor.org/rfc/rfc7986.html) · [CVE-2024-39687 (iCalendar recurrence-bomb expansion)](https://nvd.nist.gov/vuln/detail/CVE-2024-39687)
|
|
16
|
+
|
|
11
17
|
- v0.11.30 (2026-05-21) — **JMAP blob upload + download handlers on `b.mail.server.jmap` (RFC 8620 §6).** The JMAP listener now exposes turnkey HTTP handlers for blob upload (`POST /jmap/upload/{accountId}`) and blob download (`GET /jmap/download/{accountId}/{blobId}/{name}`). Upload streams the request body into `b.safeBuffer.boundedChunkCollector` (default cap 50 MiB; operator-tunable via `opts.maxBlobBytes`), then calls the operator backend's `mailStore.uploadBlob(actor, accountId, contentType, bytes) → { blobId, type?, size? }` and returns the JSON descriptor clients pass to subsequent `Email/set` / `Email/import` calls. Download walks the operator backend's `mailStore.downloadBlob(actor, accountId, blobId) → { bytes, type }` and pipes the bytes through with the right `Content-Type` + `Content-Disposition` headers. Both handlers refuse 503 when the corresponding backend hook is missing — never silent OK.
|
|
12
18
|
|
|
13
19
|
EmailSubmission (RFC 8621 §7) was already in the JMAP method catalogue at `lib/mail-server-registry.js`; operators wire `EmailSubmission/get` / `EmailSubmission/set` through the existing `opts.methods` dispatch and compose `b.mail.send.deliver` (shipped v0.11.24) for the actual outbound send. No additional framework wiring is required. **Added:** *`uploadHandler(req, res)` — POST `/jmap/upload/{accountId}`* — Streams the request body through `b.safeBuffer.boundedChunkCollector` so a runaway upload refuses with `413 maxSizeUpload` instead of OOM'ing the process. Default cap 50 MiB; tune via `b.mail.server.jmap.create({ maxBlobBytes })`. The `accountId` path segment is identifier-shape-validated (`/^[A-Za-z0-9_-]{1,64}$/`) — path-traversal shapes are refused at the boundary. Calls `mailStore.uploadBlob(actor, accountId, contentType, bytes) → { blobId, type?, size? }`; the listener echoes `accountId` + `blobId` + `type` + `size` back as a `201 Created` JSON response per RFC 8620 §6.1. · *`downloadHandler(req, res)` — GET `/jmap/download/{accountId}/{blobId}/{name}`* — Parses the path's `accountId` / `blobId` / `name` segments + optional `?accept=<type>` query, calls `mailStore.downloadBlob(actor, accountId, blobId) → { bytes, type } | Buffer | null`, and pipes the bytes through with `Content-Type` (backend-supplied OR query-`accept` OR `application/octet-stream` fallback) + `Content-Length` + `Content-Disposition: attachment; filename="<safe-name>"` (only when the filename matches the safe `[A-Za-z0-9._-]{1,200}` shape — anti header-injection). Missing blob → `404 invalidArguments`. · *Both handlers exposed on the listener handle* — `b.mail.server.jmap.create(opts)` returns `{ apiHandler, sessionHandler, discoveryHandler, eventSourceHandler, uploadHandler, downloadHandler, MailServerJmapError }`. Operators mount each at the path their HTTP router exposes; the `session.uploadUrl` / `session.downloadUrl` already advertise the canonical paths. · *EmailSubmission methods continue through the existing dispatch path* — RFC 8621 §7 — `EmailSubmission/get` / `EmailSubmission/changes` / `EmailSubmission/query` / `EmailSubmission/queryChanges` / `EmailSubmission/set` are already in the JMAP method catalogue at `lib/mail-server-registry.js`. Operators wire them through `b.mail.server.jmap.create({ methods: { 'EmailSubmission/set': async function (actor, args) { ... b.mail.send.deliver(...) ... } } })`. No additional framework wiring is required for v0.11.30; the substrate composition (JMAP method → `b.mail.send.deliver` → SMTP MX → DSN) was already complete after v0.11.24. **Security:** *AccountId path-traversal refusal at the boundary* — Both handlers validate the `accountId` URL segment against `/^[A-Za-z0-9_-]{1,64}$/`. URL-encoded path-traversal payloads (`%2E%2E%2F`, `..%2F`, `/`) refuse with `400 invalidArguments` before any backend call. Operator-side validation in `mailStore.uploadBlob` / `mailStore.downloadBlob` is a defense-in-depth second layer. · *Upload size cap via `safeBuffer.boundedChunkCollector` (cap-bounded)* — Replaces the prior hand-rolled `Buffer.concat(chunks, received)` shape with the framework's bounded-collector primitive. The collector throws `mail-server-jmap/blob-too-large` on overflow; the handler converts it into a `413 maxSizeUpload` JSON error per RFC 8620 §6.1. A misbehaving client cannot stream a multi-gigabyte payload past the limit — the collector enforces the cap byte-by-byte. · *`Content-Disposition` filename is identifier-shape only* — Download responses only set the `Content-Disposition` header when the URL's `name` segment matches `/^[A-Za-z0-9._-]{1,200}$/`. Filenames with `;` / `"` / CRLF cannot be smuggled into the header — RFC 6266 §4.3 + CVE-2023-46604-class header-injection defense. **References:** [RFC 8620 (JMAP Core — §6 Blob upload/download)](https://www.rfc-editor.org/rfc/rfc8620.html) · [RFC 8621 (JMAP Mail — §7 EmailSubmission)](https://www.rfc-editor.org/rfc/rfc8621.html) · [RFC 6266 (Content-Disposition in HTTP)](https://www.rfc-editor.org/rfc/rfc6266.html) · [RFC 5987 (Encoding-aware filename* parameter)](https://www.rfc-editor.org/rfc/rfc5987.html)
|
package/index.js
CHANGED
|
@@ -98,6 +98,7 @@ var safeSieve = require("./lib/safe-sieve");
|
|
|
98
98
|
var safeIcap = require("./lib/safe-icap");
|
|
99
99
|
var safeIcal = require("./lib/safe-ical");
|
|
100
100
|
var safeVcard = require("./lib/safe-vcard");
|
|
101
|
+
var calendar = require("./lib/calendar");
|
|
101
102
|
var mailStore = require("./lib/mail-store");
|
|
102
103
|
var ntpCheck = require("./lib/ntp-check");
|
|
103
104
|
var auditSign = require("./lib/audit-sign");
|
|
@@ -622,6 +623,7 @@ module.exports = {
|
|
|
622
623
|
safeIcap: safeIcap,
|
|
623
624
|
safeIcal: safeIcal,
|
|
624
625
|
safeVcard: safeVcard,
|
|
626
|
+
calendar: calendar,
|
|
625
627
|
mailStore: mailStore,
|
|
626
628
|
safeSchema: safeSchema,
|
|
627
629
|
pagination: pagination,
|
package/lib/calendar.js
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.calendar
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Calendar (JSCalendar)
|
|
6
|
+
* @order 400
|
|
7
|
+
* @slug calendar
|
|
8
|
+
*
|
|
9
|
+
* @intro
|
|
10
|
+
* JSCalendar (RFC 8984) primitive. Wraps the framework's existing
|
|
11
|
+
* `b.safeIcal.parse` (RFC 5545 grammar + bounded parser) with the
|
|
12
|
+
* JSON-native JSCalendar surface JMAP Calendars (RFC 8984 / draft-
|
|
13
|
+
* ietf-jmap-calendars) requires for cross-protocol interop.
|
|
14
|
+
*
|
|
15
|
+
* v1 scope:
|
|
16
|
+
* - `validate(jsCal)` — assert JSCalendar Event / Task shape
|
|
17
|
+
* - `fromIcal(text, opts?)` — VCALENDAR.VEVENT → JSCalendar Event
|
|
18
|
+
* - `toIcal(jsCal, opts?)` — JSCalendar Event → VCALENDAR
|
|
19
|
+
* - `expandRecurrence(event, { from, to, max })` — RRULE expansion
|
|
20
|
+
* for FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with UNTIL/COUNT/INTERVAL
|
|
21
|
+
*
|
|
22
|
+
* Deferred-with-condition (no operator demand yet):
|
|
23
|
+
* - BYSETPOS / BYWEEKNO / BYYEARDAY (RFC 5545 §3.3.10) — RFC 7529
|
|
24
|
+
* non-Gregorian calendars; floating timezone resolution.
|
|
25
|
+
* - VTODO / VJOURNAL → Task / Note objects (RFC 8984 §5/§6).
|
|
26
|
+
* - JSCalendar Group objects (RFC 8984 §1.4.4).
|
|
27
|
+
*
|
|
28
|
+
* @card
|
|
29
|
+
* JSCalendar (RFC 8984) ↔ iCalendar (RFC 5545) bridge — validate,
|
|
30
|
+
* convert both directions, expand recurrences. Substrate for JMAP
|
|
31
|
+
* Calendars (RFC 8984 + draft-ietf-jmap-calendars).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var safeIcal = require("./safe-ical");
|
|
35
|
+
var time = require("./time");
|
|
36
|
+
var { defineClass } = require("./framework-error");
|
|
37
|
+
|
|
38
|
+
var CalendarError = defineClass("CalendarError", { alwaysPermanent: true });
|
|
39
|
+
|
|
40
|
+
// JSCalendar shape vocabulary — RFC 8984 §1.2 (`@type`) catalogues
|
|
41
|
+
// the discriminator strings every nested object MUST carry.
|
|
42
|
+
var JSCAL_TYPES = Object.freeze({
|
|
43
|
+
Event: "Event",
|
|
44
|
+
Task: "Task",
|
|
45
|
+
Group: "Group",
|
|
46
|
+
Participant: "Participant",
|
|
47
|
+
Location: "Location",
|
|
48
|
+
Link: "Link",
|
|
49
|
+
Alert: "Alert",
|
|
50
|
+
Recurrence: "RecurrenceRule",
|
|
51
|
+
TimeZone: "TimeZone",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// RFC 8984 §4.3.2 — frequencies recognised in `RecurrenceRule.frequency`.
|
|
55
|
+
var JSCAL_FREQUENCIES = Object.freeze({
|
|
56
|
+
yearly: 1, monthly: 1, weekly: 1, daily: 1, hourly: 1, minutely: 1, secondly: 1,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// RFC 8984 §4.6.2 — alert action types.
|
|
60
|
+
var JSCAL_ALERT_ACTIONS = Object.freeze({
|
|
61
|
+
display: 1, email: 1,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Recurrence-expansion caps. Mirror b.safeIcal's RRULE limits so the
|
|
65
|
+
// expand path can't outpace what the parser already permitted.
|
|
66
|
+
var MAX_EXPAND_INSTANCES = 4096; // allow:raw-byte-literal — instance count cap, not bytes
|
|
67
|
+
var MAX_EXPAND_SPAN_MS = 10 * 365 * 24 * 60 * 60 * 1000; // allow:raw-byte-literal + allow:raw-time-literal — 10 year max expansion span
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @primitive b.calendar.validate
|
|
71
|
+
* @signature b.calendar.validate(jsCal)
|
|
72
|
+
* @since 0.11.31
|
|
73
|
+
* @status stable
|
|
74
|
+
*
|
|
75
|
+
* Validate a JSCalendar Event / Task object's required-field shape per
|
|
76
|
+
* RFC 8984 §5 (Event) + §6 (Task). Returns the input on success; throws
|
|
77
|
+
* `CalendarError` on refusal with a `.code` naming the specific shape
|
|
78
|
+
* rule that failed.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* b.calendar.validate({
|
|
82
|
+
* "@type": "Event",
|
|
83
|
+
* uid: "0e612e8b-1c4f-4e30-8e6a-4adc4e8b1c4f",
|
|
84
|
+
* updated: "2026-05-21T10:00:00Z",
|
|
85
|
+
* title: "Sprint planning",
|
|
86
|
+
* start: "2026-05-22T09:00:00",
|
|
87
|
+
* duration: "PT1H",
|
|
88
|
+
* timeZone: "America/Los_Angeles",
|
|
89
|
+
* });
|
|
90
|
+
*/
|
|
91
|
+
function validate(jsCal) {
|
|
92
|
+
if (!jsCal || typeof jsCal !== "object" || Array.isArray(jsCal)) {
|
|
93
|
+
throw new CalendarError("calendar/bad-input",
|
|
94
|
+
"b.calendar.validate: input must be a JSCalendar object");
|
|
95
|
+
}
|
|
96
|
+
var t = jsCal["@type"];
|
|
97
|
+
if (t !== JSCAL_TYPES.Event && t !== JSCAL_TYPES.Task) {
|
|
98
|
+
throw new CalendarError("calendar/bad-type",
|
|
99
|
+
"b.calendar.validate: @type must be 'Event' or 'Task' (got " + JSON.stringify(t) + ")");
|
|
100
|
+
}
|
|
101
|
+
if (typeof jsCal.uid !== "string" || jsCal.uid.length === 0) {
|
|
102
|
+
throw new CalendarError("calendar/no-uid",
|
|
103
|
+
"b.calendar.validate: uid is required (RFC 8984 §5.1.4)");
|
|
104
|
+
}
|
|
105
|
+
if (jsCal.uid.length > 1024) { // allow:raw-byte-literal — anti-DoS uid length cap
|
|
106
|
+
throw new CalendarError("calendar/oversize-uid",
|
|
107
|
+
"b.calendar.validate: uid exceeds 1024 bytes");
|
|
108
|
+
}
|
|
109
|
+
if (typeof jsCal.updated !== "string" || !_isUtcDateTime(jsCal.updated)) {
|
|
110
|
+
throw new CalendarError("calendar/bad-updated",
|
|
111
|
+
"b.calendar.validate: updated MUST be a UTCDateTime per RFC 8984 §1.4.3 (got " + JSON.stringify(jsCal.updated) + ")");
|
|
112
|
+
}
|
|
113
|
+
if (t === JSCAL_TYPES.Event) {
|
|
114
|
+
if (jsCal.start !== undefined && (typeof jsCal.start !== "string" || !_isLocalDateTime(jsCal.start))) {
|
|
115
|
+
throw new CalendarError("calendar/bad-start",
|
|
116
|
+
"b.calendar.validate: Event.start MUST be a LocalDateTime");
|
|
117
|
+
}
|
|
118
|
+
if (jsCal.duration !== undefined && (typeof jsCal.duration !== "string" || !_isDuration(jsCal.duration))) {
|
|
119
|
+
throw new CalendarError("calendar/bad-duration",
|
|
120
|
+
"b.calendar.validate: Event.duration MUST be an RFC 8601 PnYnMnDTnHnMnS Duration");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (jsCal.recurrenceRules !== undefined) {
|
|
124
|
+
if (!Array.isArray(jsCal.recurrenceRules)) {
|
|
125
|
+
throw new CalendarError("calendar/bad-recurrence",
|
|
126
|
+
"b.calendar.validate: recurrenceRules MUST be an array of RecurrenceRule");
|
|
127
|
+
}
|
|
128
|
+
for (var ri = 0; ri < jsCal.recurrenceRules.length; ri += 1) {
|
|
129
|
+
var rr = jsCal.recurrenceRules[ri];
|
|
130
|
+
if (!rr || typeof rr !== "object" || rr["@type"] !== "RecurrenceRule") {
|
|
131
|
+
throw new CalendarError("calendar/bad-recurrence",
|
|
132
|
+
"b.calendar.validate: recurrenceRules[" + ri + "].@type MUST be 'RecurrenceRule'");
|
|
133
|
+
}
|
|
134
|
+
if (!Object.prototype.hasOwnProperty.call(JSCAL_FREQUENCIES, rr.frequency)) {
|
|
135
|
+
throw new CalendarError("calendar/bad-recurrence",
|
|
136
|
+
"b.calendar.validate: recurrenceRules[" + ri + "].frequency MUST be one of " +
|
|
137
|
+
Object.keys(JSCAL_FREQUENCIES).join(" | "));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (jsCal.alerts !== undefined) {
|
|
142
|
+
if (typeof jsCal.alerts !== "object" || Array.isArray(jsCal.alerts)) {
|
|
143
|
+
throw new CalendarError("calendar/bad-alerts",
|
|
144
|
+
"b.calendar.validate: alerts MUST be an object map keyed by alert-id");
|
|
145
|
+
}
|
|
146
|
+
var alertKeys = Object.keys(jsCal.alerts);
|
|
147
|
+
for (var ai = 0; ai < alertKeys.length; ai += 1) {
|
|
148
|
+
var alert = jsCal.alerts[alertKeys[ai]];
|
|
149
|
+
if (!alert || alert["@type"] !== "Alert") {
|
|
150
|
+
throw new CalendarError("calendar/bad-alerts",
|
|
151
|
+
"b.calendar.validate: alerts[" + alertKeys[ai] + "].@type MUST be 'Alert'");
|
|
152
|
+
}
|
|
153
|
+
if (alert.action && !Object.prototype.hasOwnProperty.call(JSCAL_ALERT_ACTIONS, alert.action)) {
|
|
154
|
+
throw new CalendarError("calendar/bad-alerts",
|
|
155
|
+
"b.calendar.validate: alerts[" + alertKeys[ai] + "].action MUST be one of " +
|
|
156
|
+
Object.keys(JSCAL_ALERT_ACTIONS).join(" | "));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return jsCal;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @primitive b.calendar.fromIcal
|
|
165
|
+
* @signature b.calendar.fromIcal(text, opts?)
|
|
166
|
+
* @since 0.11.31
|
|
167
|
+
* @status stable
|
|
168
|
+
*
|
|
169
|
+
* Parse iCalendar text (RFC 5545) via `b.safeIcal.parse` and map the
|
|
170
|
+
* first VEVENT into a JSCalendar Event object (RFC 8984 §5). Returns
|
|
171
|
+
* a single Event when the VCALENDAR contains exactly one VEVENT, or
|
|
172
|
+
* an array when multiple VEVENTs are present.
|
|
173
|
+
*
|
|
174
|
+
* @opts
|
|
175
|
+
* safeIcalOpts: object, // forwarded to b.safeIcal.parse (caps, allowExperimental, etc.)
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* b.calendar.fromIcal(
|
|
179
|
+
* "BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n" +
|
|
180
|
+
* "BEGIN:VEVENT\\r\\nUID:a@b\\r\\nDTSTAMP:20260521T100000Z\\r\\n" +
|
|
181
|
+
* "DTSTART:20260522T090000Z\\r\\nDURATION:PT1H\\r\\n" +
|
|
182
|
+
* "SUMMARY:Sprint\\r\\nEND:VEVENT\\r\\nEND:VCALENDAR\\r\\n");
|
|
183
|
+
* // → { "@type":"Event", uid:"a@b", updated:"2026-05-21T10:00:00Z", ... }
|
|
184
|
+
*/
|
|
185
|
+
function fromIcal(text, opts) {
|
|
186
|
+
var ast = safeIcal.parse(text, opts || {});
|
|
187
|
+
var events = (ast && ast.vcalendar && ast.vcalendar.vevent) || [];
|
|
188
|
+
if (events.length === 0) {
|
|
189
|
+
throw new CalendarError("calendar/no-vevent",
|
|
190
|
+
"b.calendar.fromIcal: VCALENDAR has no VEVENT components");
|
|
191
|
+
}
|
|
192
|
+
var converted = events.map(_veventToJsCalEvent);
|
|
193
|
+
return converted.length === 1 ? converted[0] : converted;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @primitive b.calendar.toIcal
|
|
198
|
+
* @signature b.calendar.toIcal(jsCal, opts?)
|
|
199
|
+
* @since 0.11.31
|
|
200
|
+
* @status stable
|
|
201
|
+
*
|
|
202
|
+
* Render a JSCalendar Event back to RFC 5545 iCalendar text. Returns a
|
|
203
|
+
* CRLF-terminated string wrapped in a `BEGIN:VCALENDAR / VERSION:2.0 /
|
|
204
|
+
* PRODID:-//blamejs//Calendar//EN / BEGIN:VEVENT ... END:VEVENT /
|
|
205
|
+
* END:VCALENDAR` envelope per RFC 5545 §3.4.
|
|
206
|
+
*
|
|
207
|
+
* @opts
|
|
208
|
+
* prodid: string, // PRODID value to emit; default "-//blamejs//Calendar//EN"
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* b.calendar.toIcal({
|
|
212
|
+
* "@type": "Event",
|
|
213
|
+
* uid: "a@b",
|
|
214
|
+
* updated: "2026-05-21T10:00:00Z",
|
|
215
|
+
* title: "Sprint",
|
|
216
|
+
* start: "2026-05-22T09:00:00",
|
|
217
|
+
* duration: "PT1H",
|
|
218
|
+
* });
|
|
219
|
+
*/
|
|
220
|
+
function toIcal(jsCal, opts) {
|
|
221
|
+
validate(jsCal);
|
|
222
|
+
var prodid = (opts && opts.prodid) || "-//blamejs//Calendar//EN";
|
|
223
|
+
var lines = [
|
|
224
|
+
"BEGIN:VCALENDAR",
|
|
225
|
+
"VERSION:2.0",
|
|
226
|
+
"PRODID:" + prodid,
|
|
227
|
+
"BEGIN:VEVENT",
|
|
228
|
+
"UID:" + _foldLine(jsCal.uid),
|
|
229
|
+
"DTSTAMP:" + _utcDateTimeToIcal(jsCal.updated),
|
|
230
|
+
];
|
|
231
|
+
if (jsCal.title) lines.push("SUMMARY:" + _foldLine(_escapeText(jsCal.title)));
|
|
232
|
+
if (jsCal.description) lines.push("DESCRIPTION:" + _foldLine(_escapeText(jsCal.description)));
|
|
233
|
+
if (jsCal.start) {
|
|
234
|
+
// RFC 8984 §1.4.4 maps `timeZone: "Etc/UTC"` to a `Z`-suffix
|
|
235
|
+
// DTSTART (RFC 5545 §3.3.5 form 2); any other named timezone
|
|
236
|
+
// maps to a TZID parameter (form 3); no timeZone leaves DTSTART
|
|
237
|
+
// as floating local time (form 1).
|
|
238
|
+
var dtStartIcal = _localDateTimeToIcal(jsCal.start);
|
|
239
|
+
if (jsCal.timeZone === "Etc/UTC" || jsCal.timeZone === "UTC") {
|
|
240
|
+
lines.push("DTSTART:" + dtStartIcal + "Z");
|
|
241
|
+
} else if (jsCal.timeZone) {
|
|
242
|
+
lines.push("DTSTART;TZID=" + jsCal.timeZone + ":" + dtStartIcal);
|
|
243
|
+
} else {
|
|
244
|
+
lines.push("DTSTART:" + dtStartIcal);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (jsCal.duration) lines.push("DURATION:" + jsCal.duration);
|
|
248
|
+
if (Array.isArray(jsCal.locations) || (jsCal.locations && typeof jsCal.locations === "object")) {
|
|
249
|
+
var locValues = Array.isArray(jsCal.locations) ? jsCal.locations : Object.values(jsCal.locations);
|
|
250
|
+
for (var li = 0; li < locValues.length; li += 1) {
|
|
251
|
+
var loc = locValues[li];
|
|
252
|
+
if (loc && typeof loc.name === "string") {
|
|
253
|
+
lines.push("LOCATION:" + _foldLine(_escapeText(loc.name)));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(jsCal.recurrenceRules)) {
|
|
258
|
+
for (var rri = 0; rri < jsCal.recurrenceRules.length; rri += 1) {
|
|
259
|
+
lines.push("RRULE:" + _recurrenceRuleToIcal(jsCal.recurrenceRules[rri]));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
lines.push("END:VEVENT", "END:VCALENDAR");
|
|
263
|
+
return lines.join("\r\n") + "\r\n";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @primitive b.calendar.expandRecurrence
|
|
268
|
+
* @signature b.calendar.expandRecurrence(event, opts)
|
|
269
|
+
* @since 0.11.31
|
|
270
|
+
* @status stable
|
|
271
|
+
*
|
|
272
|
+
* Expand a JSCalendar Event's `recurrenceRules` into concrete start
|
|
273
|
+
* timestamps in the operator's `[from, to]` window. Returns an array
|
|
274
|
+
* of ISO 8601 UTC strings (`yyyy-mm-ddTHH:MM:SSZ`). Bounded by
|
|
275
|
+
* `MAX_EXPAND_INSTANCES` (4096) + `MAX_EXPAND_SPAN_MS` (10 years) to
|
|
276
|
+
* defend against CVE-2024-39687-class recurrence-bomb expansion.
|
|
277
|
+
*
|
|
278
|
+
* v1 supports FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
|
|
279
|
+
* UNTIL. BYDAY / BYMONTH / BYMONTHDAY refine the base frequency. The
|
|
280
|
+
* BYSETPOS / BYWEEKNO / BYYEARDAY filters are deferred-with-condition
|
|
281
|
+
* (RFC 7529 non-Gregorian calendars not in scope either).
|
|
282
|
+
*
|
|
283
|
+
* @opts
|
|
284
|
+
* from: string, // ISO 8601 UTC timestamp — lower bound of expansion window
|
|
285
|
+
* to: string, // ISO 8601 UTC timestamp — upper bound (window <= 10 years)
|
|
286
|
+
* max: number, // instance-count cap (default 4096; never exceeds MAX_EXPAND_INSTANCES)
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* b.calendar.expandRecurrence(
|
|
290
|
+
* { "@type": "Event", uid: "x", updated: "2026-05-21T10:00:00Z",
|
|
291
|
+
* start: "2026-05-22T09:00:00",
|
|
292
|
+
* recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily", count: 5 }] },
|
|
293
|
+
* { from: "2026-05-22T00:00:00Z", to: "2026-06-01T00:00:00Z" });
|
|
294
|
+
* // → ["2026-05-22T09:00:00Z", "2026-05-23T09:00:00Z", ..., "2026-05-26T09:00:00Z"]
|
|
295
|
+
*/
|
|
296
|
+
function expandRecurrence(event, opts) {
|
|
297
|
+
validate(event);
|
|
298
|
+
opts = opts || {};
|
|
299
|
+
if (!Array.isArray(event.recurrenceRules) || event.recurrenceRules.length === 0) {
|
|
300
|
+
return event.start ? [_localToUtc(event.start)] : [];
|
|
301
|
+
}
|
|
302
|
+
var fromMs = opts.from ? Date.parse(opts.from) : null;
|
|
303
|
+
var toMs = opts.to ? Date.parse(opts.to) : null;
|
|
304
|
+
if (fromMs !== null && toMs !== null) {
|
|
305
|
+
if (toMs - fromMs > MAX_EXPAND_SPAN_MS) {
|
|
306
|
+
throw new CalendarError("calendar/oversize-expansion-span",
|
|
307
|
+
"b.calendar.expandRecurrence: window [" + opts.from + ", " + opts.to + "] exceeds 10 years");
|
|
308
|
+
}
|
|
309
|
+
if (toMs < fromMs) {
|
|
310
|
+
throw new CalendarError("calendar/bad-expansion-window",
|
|
311
|
+
"b.calendar.expandRecurrence: opts.to must be after opts.from");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
var maxCount = Math.min(opts.max || MAX_EXPAND_INSTANCES, MAX_EXPAND_INSTANCES);
|
|
315
|
+
// JSCalendar's LocalDateTime is FLOATING when no timeZone is set;
|
|
316
|
+
// for expansion we treat it as already-UTC so the returned ISO
|
|
317
|
+
// strings carry the same wall-clock the operator stored. Appending
|
|
318
|
+
// `Z` to the LocalDateTime sidesteps Date.parse's host-locale
|
|
319
|
+
// interpretation (which would otherwise mangle the wall-clock).
|
|
320
|
+
var startInput = _isLocalDateTime(event.start) ? event.start + "Z" : event.start;
|
|
321
|
+
var startMs = Date.parse(startInput);
|
|
322
|
+
if (!isFinite(startMs)) {
|
|
323
|
+
throw new CalendarError("calendar/bad-start",
|
|
324
|
+
"b.calendar.expandRecurrence: event.start is not a parseable date");
|
|
325
|
+
}
|
|
326
|
+
var out = [];
|
|
327
|
+
// We honour ONLY the first recurrenceRule in v1; multiple rules
|
|
328
|
+
// compose via union which is a follow-up.
|
|
329
|
+
var rule = event.recurrenceRules[0];
|
|
330
|
+
var interval = Math.max(1, parseInt(rule.interval || 1, 10));
|
|
331
|
+
var freq = rule.frequency;
|
|
332
|
+
var count = isFinite(rule.count) ? rule.count : Infinity;
|
|
333
|
+
var untilMs = rule.until ? Date.parse(rule.until) : Infinity;
|
|
334
|
+
// RFC 5545 §3.3.10 BY* filters narrow which stepped occurrences
|
|
335
|
+
// emit. We support the BYDAY/BYMONTH/BYMONTHDAY subset; rule
|
|
336
|
+
// instances that fail the filter are stepped past WITHOUT counting
|
|
337
|
+
// against `count` (per RFC 5545 BY* expansion semantics — only
|
|
338
|
+
// surviving instances count).
|
|
339
|
+
var byDaySet = null;
|
|
340
|
+
if (Array.isArray(rule.byDay) && rule.byDay.length > 0) {
|
|
341
|
+
byDaySet = Object.create(null);
|
|
342
|
+
var dayCodes = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
|
343
|
+
for (var bi = 0; bi < rule.byDay.length; bi += 1) {
|
|
344
|
+
var entry = rule.byDay[bi];
|
|
345
|
+
var dayKey = (entry && entry.day ? entry.day : entry || "").toLowerCase();
|
|
346
|
+
if (Object.prototype.hasOwnProperty.call(dayCodes, dayKey)) {
|
|
347
|
+
byDaySet[dayCodes[dayKey]] = true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
var byMonthSet = null;
|
|
352
|
+
if (Array.isArray(rule.byMonth) && rule.byMonth.length > 0) {
|
|
353
|
+
byMonthSet = Object.create(null);
|
|
354
|
+
for (var mi = 0; mi < rule.byMonth.length; mi += 1) {
|
|
355
|
+
var mn = parseInt(rule.byMonth[mi], 10);
|
|
356
|
+
if (isFinite(mn) && mn >= 1 && mn <= 12) byMonthSet[mn] = true; // allow:raw-byte-literal — 12 calendar months
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
var byMonthDaySet = null;
|
|
360
|
+
if (Array.isArray(rule.byMonthDay) && rule.byMonthDay.length > 0) {
|
|
361
|
+
byMonthDaySet = Object.create(null);
|
|
362
|
+
for (var mdi = 0; mdi < rule.byMonthDay.length; mdi += 1) {
|
|
363
|
+
var mdn = parseInt(rule.byMonthDay[mdi], 10);
|
|
364
|
+
if (isFinite(mdn) && mdn !== 0 && mdn >= -31 && mdn <= 31) byMonthDaySet[mdn] = true; // allow:raw-byte-literal — calendar day-of-month bounds
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function _matchesBy(t) {
|
|
368
|
+
var d = new Date(t);
|
|
369
|
+
if (byDaySet && !byDaySet[d.getUTCDay()]) return false;
|
|
370
|
+
if (byMonthSet && !byMonthSet[d.getUTCMonth() + 1]) return false;
|
|
371
|
+
if (byMonthDaySet && !byMonthDaySet[d.getUTCDate()]) return false;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
var t = startMs;
|
|
375
|
+
// Safety cap on the step loop: at most MAX_EXPAND_INSTANCES * 366
|
|
376
|
+
// iterations so BY* filters that match sparsely (e.g. FREQ=DAILY;
|
|
377
|
+
// BYMONTH=1 — only Jan days survive) cannot loop forever inside
|
|
378
|
+
// the 10-year span cap.
|
|
379
|
+
var stepBudget = MAX_EXPAND_INSTANCES * 366; // allow:raw-byte-literal — days/year stepping budget
|
|
380
|
+
while (out.length < count && out.length < maxCount && stepBudget > 0) {
|
|
381
|
+
stepBudget -= 1;
|
|
382
|
+
if (t > untilMs) break;
|
|
383
|
+
if (toMs !== null && t > toMs) break;
|
|
384
|
+
if (_matchesBy(t)) {
|
|
385
|
+
if (fromMs === null || t >= fromMs) {
|
|
386
|
+
out.push(_msToIsoZ(t));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
t = _advance(t, freq, interval);
|
|
390
|
+
if (t === null) {
|
|
391
|
+
throw new CalendarError("calendar/bad-recurrence",
|
|
392
|
+
"b.calendar.expandRecurrence: unsupported frequency '" + freq + "'");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- Internal helpers ----------------------------------------------------
|
|
399
|
+
|
|
400
|
+
function _veventToJsCalEvent(ve) {
|
|
401
|
+
var props = (ve && ve.properties) || {};
|
|
402
|
+
var jsCal = {
|
|
403
|
+
"@type": "Event",
|
|
404
|
+
uid: _firstValue(props.UID) || "",
|
|
405
|
+
updated: _icalDateTimeToUtc(_firstValue(props.DTSTAMP) || ""),
|
|
406
|
+
};
|
|
407
|
+
var summary = _firstValue(props.SUMMARY);
|
|
408
|
+
if (summary) jsCal.title = _unescapeText(summary);
|
|
409
|
+
var description = _firstValue(props.DESCRIPTION);
|
|
410
|
+
if (description) jsCal.description = _unescapeText(description);
|
|
411
|
+
var dtstart = _firstValue(props.DTSTART);
|
|
412
|
+
if (dtstart) jsCal.start = _icalDateTimeToLocal(dtstart);
|
|
413
|
+
var duration = _firstValue(props.DURATION);
|
|
414
|
+
if (duration) jsCal.duration = duration;
|
|
415
|
+
var tzid = _firstParamValue(props.DTSTART, "TZID");
|
|
416
|
+
if (tzid) {
|
|
417
|
+
jsCal.timeZone = tzid;
|
|
418
|
+
} else if (typeof dtstart === "string" && /Z$/.test(dtstart)) {
|
|
419
|
+
// Codex P1 — RFC 8984 §1.4.4: a UTC-suffix DTSTART (`...Z`) in
|
|
420
|
+
// iCalendar maps to a JSCalendar Event with `timeZone: "Etc/UTC"`.
|
|
421
|
+
// Without this, round-tripping `fromIcal` → `toIcal` would drop
|
|
422
|
+
// the UTC anchor + emit floating time, shifting the absolute
|
|
423
|
+
// instant for viewers in different timezones.
|
|
424
|
+
jsCal.timeZone = "Etc/UTC";
|
|
425
|
+
}
|
|
426
|
+
var location = _firstValue(props.LOCATION);
|
|
427
|
+
if (location) {
|
|
428
|
+
jsCal.locations = { L1: { "@type": "Location", name: _unescapeText(location) } };
|
|
429
|
+
}
|
|
430
|
+
var rrule = _firstValue(props.RRULE);
|
|
431
|
+
if (rrule) jsCal.recurrenceRules = [_icalRruleToJscal(rrule)];
|
|
432
|
+
return jsCal;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function _firstValue(prop) {
|
|
436
|
+
if (!prop) return null;
|
|
437
|
+
if (Array.isArray(prop)) {
|
|
438
|
+
var first = prop[0];
|
|
439
|
+
return first && first.value !== undefined ? first.value : null;
|
|
440
|
+
}
|
|
441
|
+
if (prop.value !== undefined) return prop.value;
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function _firstParamValue(prop, paramName) {
|
|
446
|
+
if (!prop) return null;
|
|
447
|
+
var first = Array.isArray(prop) ? prop[0] : prop;
|
|
448
|
+
if (!first || !first.params) return null;
|
|
449
|
+
return first.params[paramName] || null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _icalRruleToJscal(rrule) {
|
|
453
|
+
var out = { "@type": "RecurrenceRule", frequency: "daily" };
|
|
454
|
+
var parts = String(rrule).split(";"); // allow:bare-split-on-quoted-header — RFC 5545 RRULE grammar has no quoted-string members; values are token-only
|
|
455
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
456
|
+
var kv = parts[i].split("=");
|
|
457
|
+
if (kv.length !== 2) continue;
|
|
458
|
+
var key = kv[0].toUpperCase();
|
|
459
|
+
var val = kv[1];
|
|
460
|
+
if (key === "FREQ") out.frequency = val.toLowerCase();
|
|
461
|
+
else if (key === "INTERVAL") out.interval = parseInt(val, 10);
|
|
462
|
+
else if (key === "COUNT") out.count = parseInt(val, 10);
|
|
463
|
+
else if (key === "UNTIL") out.until = _icalDateTimeToUtc(val);
|
|
464
|
+
else if (key === "BYDAY") out.byDay = val.split(",").map(function (d) { // allow:bare-split-on-quoted-header — RFC 5545 BYDAY values are token-only
|
|
465
|
+
return { "@type": "NDay", day: d.slice(-2).toLowerCase() };
|
|
466
|
+
});
|
|
467
|
+
else if (key === "BYMONTH") out.byMonth = val.split(","); // allow:bare-split-on-quoted-header — RFC 5545 BYMONTH values are integer-only
|
|
468
|
+
else if (key === "BYMONTHDAY") out.byMonthDay = val.split(",").map(function (n) { return parseInt(n, 10); }); // allow:bare-split-on-quoted-header — RFC 5545 BYMONTHDAY values are integer-only
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function _recurrenceRuleToIcal(rr) {
|
|
474
|
+
var parts = ["FREQ=" + (rr.frequency || "daily").toUpperCase()];
|
|
475
|
+
if (rr.interval && rr.interval !== 1) parts.push("INTERVAL=" + rr.interval);
|
|
476
|
+
if (rr.count) parts.push("COUNT=" + rr.count);
|
|
477
|
+
if (rr.until) parts.push("UNTIL=" + _utcDateTimeToIcal(rr.until));
|
|
478
|
+
if (Array.isArray(rr.byDay) && rr.byDay.length > 0) {
|
|
479
|
+
parts.push("BYDAY=" + rr.byDay.map(function (d) { return (d.day || "").toUpperCase(); }).join(","));
|
|
480
|
+
}
|
|
481
|
+
if (Array.isArray(rr.byMonth)) parts.push("BYMONTH=" + rr.byMonth.join(","));
|
|
482
|
+
if (Array.isArray(rr.byMonthDay)) parts.push("BYMONTHDAY=" + rr.byMonthDay.join(","));
|
|
483
|
+
return parts.join(";");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function _icalDateTimeToUtc(s) {
|
|
487
|
+
// VALUE=DATE-TIME UTC form: 20260522T100000Z → 2026-05-22T10:00:00Z
|
|
488
|
+
var m = String(s).match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
|
|
489
|
+
if (!m) return "";
|
|
490
|
+
return m[1] + "-" + m[2] + "-" + m[3] + "T" + m[4] + ":" + m[5] + ":" + m[6] + "Z";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function _icalDateTimeToLocal(s) {
|
|
494
|
+
var m = String(s).match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/);
|
|
495
|
+
if (!m) return "";
|
|
496
|
+
return m[1] + "-" + m[2] + "-" + m[3] + "T" + m[4] + ":" + m[5] + ":" + m[6];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function _utcDateTimeToIcal(s) {
|
|
500
|
+
// JSCalendar UTCDateTime "2026-05-22T10:00:00.123Z" →
|
|
501
|
+
// "20260522T100000Z" (RFC 5545 §3.3.5 form 2 has NO fractional
|
|
502
|
+
// seconds; strict ICS consumers reject `T100000.123Z`).
|
|
503
|
+
return String(s).replace(/\.\d+/, "").replace(/[-:]/g, ""); // allow:bare-split-on-quoted-header — not a header split
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function _localDateTimeToIcal(s) {
|
|
507
|
+
// JSCalendar LocalDateTime "2026-05-22T09:00:00.123" →
|
|
508
|
+
// "20260522T090000" (same fractional-second strip as the UTC form).
|
|
509
|
+
return String(s).replace(/\.\d+/, "").replace(/[-:]/g, ""); // allow:bare-split-on-quoted-header — not a header split
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function _isUtcDateTime(s) {
|
|
513
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(s);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function _isLocalDateTime(s) {
|
|
517
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(s);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _isDuration(s) {
|
|
521
|
+
return /^-?P(\d+Y)?(\d+M)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?$/.test(s);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function _localToUtc(localStr) {
|
|
525
|
+
// Naive — treats LocalDateTime as already-UTC for the no-tz case.
|
|
526
|
+
return localStr.endsWith("Z") ? localStr : localStr + "Z";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function _msToIsoZ(ms) {
|
|
530
|
+
return time.toIso8601NoMs(new Date(ms));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function _advance(ms, freq, interval) {
|
|
534
|
+
var d = new Date(ms);
|
|
535
|
+
switch (freq) {
|
|
536
|
+
case "daily": d.setUTCDate(d.getUTCDate() + interval); break;
|
|
537
|
+
case "weekly": d.setUTCDate(d.getUTCDate() + 7 * interval); break; // allow:raw-byte-literal — 7 days/week
|
|
538
|
+
case "monthly": d.setUTCMonth(d.getUTCMonth() + interval); break;
|
|
539
|
+
case "yearly": d.setUTCFullYear(d.getUTCFullYear() + interval); break;
|
|
540
|
+
case "hourly": d.setUTCHours(d.getUTCHours() + interval); break;
|
|
541
|
+
case "minutely": d.setUTCMinutes(d.getUTCMinutes() + interval); break;
|
|
542
|
+
case "secondly": d.setUTCSeconds(d.getUTCSeconds() + interval); break;
|
|
543
|
+
default: return null;
|
|
544
|
+
}
|
|
545
|
+
return d.getTime();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function _escapeText(s) {
|
|
549
|
+
return String(s).replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function _unescapeText(s) {
|
|
553
|
+
return String(s)
|
|
554
|
+
.replace(/\\n/g, "\n").replace(/\\,/g, ",")
|
|
555
|
+
.replace(/\\;/g, ";").replace(/\\\\/g, "\\");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function _foldLine(s) {
|
|
559
|
+
// RFC 5545 §3.1 — content lines SHOULD NOT exceed 75 octets; fold
|
|
560
|
+
// with CRLF + leading space. We let the joining code add the
|
|
561
|
+
// trailing CRLF; this helper only inserts the intra-line fold.
|
|
562
|
+
if (s.length <= 75) return s; // allow:raw-byte-literal — RFC 5545 §3.1 line-length cap
|
|
563
|
+
var out = "";
|
|
564
|
+
for (var i = 0; i < s.length; i += 73) { // allow:raw-byte-literal — 73 = 75 minus the CR/LF wrap
|
|
565
|
+
out += (i === 0 ? "" : "\r\n ") + s.slice(i, i + 73); // allow:raw-byte-literal — same cap
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
module.exports = {
|
|
571
|
+
validate: validate,
|
|
572
|
+
fromIcal: fromIcal,
|
|
573
|
+
toIcal: toIcal,
|
|
574
|
+
expandRecurrence: expandRecurrence,
|
|
575
|
+
CalendarError: CalendarError,
|
|
576
|
+
JSCAL_TYPES: JSCAL_TYPES,
|
|
577
|
+
JSCAL_FREQUENCIES: JSCAL_FREQUENCIES,
|
|
578
|
+
JSCAL_ALERT_ACTIONS: JSCAL_ALERT_ACTIONS,
|
|
579
|
+
MAX_EXPAND_INSTANCES: MAX_EXPAND_INSTANCES,
|
|
580
|
+
MAX_EXPAND_SPAN_MS: MAX_EXPAND_SPAN_MS,
|
|
581
|
+
};
|
package/lib/mail-crypto-pgp.js
CHANGED
|
@@ -1190,10 +1190,38 @@ function wkdComputeUrl(email, opts) {
|
|
|
1190
1190
|
throw new MailCryptoError("mail-crypto/pgp/bad-email",
|
|
1191
1191
|
"wkd.computeUrl: email must be a 'local@domain' string");
|
|
1192
1192
|
}
|
|
1193
|
+
// RFC 5321 §4.5.3.1 — practical email-length cap. 320 octets is the
|
|
1194
|
+
// upper bound (64 local + 1 @ + 255 domain). Refuse beyond that BEFORE
|
|
1195
|
+
// any further processing to defend tokenisation paths against
|
|
1196
|
+
// adversarial-length inputs.
|
|
1197
|
+
if (email.length > 320) { // allow:raw-byte-literal — RFC 5321 max email length
|
|
1198
|
+
throw new MailCryptoError("mail-crypto/pgp/bad-email",
|
|
1199
|
+
"wkd.computeUrl: email length " + email.length + " exceeds RFC 5321 max 320 octets");
|
|
1200
|
+
}
|
|
1193
1201
|
var at = email.indexOf("@");
|
|
1194
1202
|
var localRaw = email.slice(0, at);
|
|
1195
1203
|
var localLower = localRaw.toLowerCase();
|
|
1196
1204
|
var domain = email.slice(at + 1).toLowerCase();
|
|
1205
|
+
// IDN-homograph defense — refuse domains with bytes outside the
|
|
1206
|
+
// LDH+dot ASCII subset (RFC 952 / RFC 1123 §2). Operators with IDN
|
|
1207
|
+
// (internationalised) domains MUST Punycode-encode upstream
|
|
1208
|
+
// (RFC 3492 `xn--` form). Cyrillic / Greek / Han homograph attacks
|
|
1209
|
+
// (`paypa1` lookalike etc.) are the threat model — the WKD URL has
|
|
1210
|
+
// to be an unambiguous host string, and the framework's b.httpClient
|
|
1211
|
+
// already refuses non-ASCII hostnames at the SSRF guard, so this
|
|
1212
|
+
// surface is the canonical refusal point.
|
|
1213
|
+
if (!/^[a-z0-9.-]+$/.test(domain)) {
|
|
1214
|
+
throw new MailCryptoError("mail-crypto/pgp/bad-domain",
|
|
1215
|
+
"wkd.computeUrl: domain must be ASCII LDH+dot (Punycode-encode IDN domains upstream; RFC 3492 xn-- form)");
|
|
1216
|
+
}
|
|
1217
|
+
if (domain.indexOf("..") !== -1 || domain.charAt(0) === "." || domain.charAt(domain.length - 1) === ".") {
|
|
1218
|
+
throw new MailCryptoError("mail-crypto/pgp/bad-domain",
|
|
1219
|
+
"wkd.computeUrl: domain must not contain empty labels");
|
|
1220
|
+
}
|
|
1221
|
+
if (domain.length > 253) { // allow:raw-byte-literal — RFC 1035 §2.3.4 max domain length
|
|
1222
|
+
throw new MailCryptoError("mail-crypto/pgp/bad-domain",
|
|
1223
|
+
"wkd.computeUrl: domain length " + domain.length + " exceeds RFC 1035 max 253 octets");
|
|
1224
|
+
}
|
|
1197
1225
|
var hashed = bCrypto.kdf(Buffer.from(localLower, "utf8"), 20); // allow:raw-byte-literal — 20-byte hash per draft-koch §3.1
|
|
1198
1226
|
var encoded = _zbase32Encode(hashed);
|
|
1199
1227
|
var advancedHost = opts.advancedHost || ("openpgpkey." + domain);
|
|
@@ -1230,6 +1258,19 @@ function _zbase32Encode(buf) {
|
|
|
1230
1258
|
module.exports = {
|
|
1231
1259
|
sign: sign,
|
|
1232
1260
|
verify: verify,
|
|
1261
|
+
// v0.11.32 — encrypt / decrypt / wkd promoted to stable top-level
|
|
1262
|
+
// surface. The framework-private envelope (BJ-PGP-PQ magic + version)
|
|
1263
|
+
// is the same one the experimental namespace shipped at v0.10.16;
|
|
1264
|
+
// the IANA-pending RFC 9580bis ML-KEM PKESK codepoints will be
|
|
1265
|
+
// wired as an alternate-encoding option in a follow-up slice. Until
|
|
1266
|
+
// then the `experimental` alias keeps the v0.10.16 import paths
|
|
1267
|
+
// working — operators migrate at their own pace.
|
|
1268
|
+
encrypt: experimentalEncrypt,
|
|
1269
|
+
decrypt: experimentalDecrypt,
|
|
1270
|
+
wkd: {
|
|
1271
|
+
computeUrl: wkdComputeUrl,
|
|
1272
|
+
fetch: wkdFetch,
|
|
1273
|
+
},
|
|
1233
1274
|
experimental: {
|
|
1234
1275
|
encrypt: experimentalEncrypt,
|
|
1235
1276
|
decrypt: experimentalDecrypt,
|
|
@@ -83,6 +83,17 @@ var JMAP_METHODS = Object.freeze({
|
|
|
83
83
|
"EmailSubmission/query": 1, "EmailSubmission/queryChanges": 1,
|
|
84
84
|
"EmailSubmission/set": 1,
|
|
85
85
|
"VacationResponse/get": 1, "VacationResponse/set": 1,
|
|
86
|
+
// v0.11.31 — JMAP Calendars (RFC 8984 + draft-ietf-jmap-calendars).
|
|
87
|
+
"Calendar/get": 1, "Calendar/changes": 1, "Calendar/set": 1,
|
|
88
|
+
"Calendar/query": 1, "Calendar/queryChanges": 1,
|
|
89
|
+
"CalendarEvent/get": 1, "CalendarEvent/changes": 1,
|
|
90
|
+
"CalendarEvent/query": 1, "CalendarEvent/queryChanges": 1,
|
|
91
|
+
"CalendarEvent/set": 1, "CalendarEvent/copy": 1,
|
|
92
|
+
"CalendarEventNotification/get": 1, "CalendarEventNotification/changes": 1,
|
|
93
|
+
"CalendarEventNotification/query": 1, "CalendarEventNotification/queryChanges": 1,
|
|
94
|
+
"CalendarEventNotification/set": 1,
|
|
95
|
+
"ParticipantIdentity/get": 1, "ParticipantIdentity/changes": 1,
|
|
96
|
+
"ParticipantIdentity/set": 1,
|
|
86
97
|
});
|
|
87
98
|
|
|
88
99
|
var CATALOGUE = Object.freeze({
|
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:3c063952-23ab-48f4-9274-e30b6d7bed0d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-21T17:46:32.271Z",
|
|
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.32",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.32",
|
|
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.32",
|
|
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.32",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|