Skip to content

Commit b97f8c0

Browse files
caldav: free-busy reporting (RFC 4791 free-busy-query REPORT)
Walks the collection over loopback with the caller's forwarded auth, parses events via a minimal vendored RFC 5545 layer (DAILY/WEEKLY RRULE expansion; other shapes count the master occurrence, documented), merges overlapping periods into a VFREEBUSY. TRANSP/CANCELLED are free; supported-report-set advertises the report; plus a non-standard GET /caldav/freebusy/<pod>?start&end extension. Findings: WAC-filtered busy time is correct by construction — unreadable events are invisible rather than busy, and it can't drift from server policy because it IS server policy (the flip side, 'busy without details', needs a weaker-than-Read mode — api.authorize territory); free-busy is read-time O(N), another face of caldav's existing api.events gap, with the wrinkle that a shared write-time index must answer 'indexed under whose authority?'; and RFC 6638 scheduling needs cross-user delivery loopback structurally cannot express (Outbox→Inbox is a write the sender has no WAC right to make).
1 parent 97a2ed1 commit b97f8c0

3 files changed

Lines changed: 521 additions & 8 deletions

File tree

caldav/README.md

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ that can send one.
5353
VEVENT `.ics` (ETag returned) → `PROPFIND Depth 1` lists it with that ETag +
5454
`text/calendar``GET` returns the VEVENT → `REPORT calendar-multiget` returns
5555
it inside `<CAL:calendar-data>``DELETE` → it's gone from the next `PROPFIND`.
56-
All driven with a real pod Bearer through real WAC in `test.js` (15 tests).
56+
Plus the free-busy slice: seed overlapping/adjacent/out-of-range/transparent/
57+
recurring/WAC-hidden events → `REPORT free-busy-query` → exactly the merged
58+
busy set comes back. All driven with a real pod Bearer through real WAC in
59+
`test.js` (22 tests).
5760

5861
## What maps
5962

@@ -64,24 +67,70 @@ All driven with a real pod Bearer through real WAC in `test.js` (15 tests).
6467
| `PROPFIND` discovery | derives pod from `api.auth.getAgent` | `current-user-principal`, `principal-URL`, `calendar-home-set` |
6568
| `REPORT` `calendar-multiget` | `GET` per `<D:href>` | returns `getetag` + `<CAL:calendar-data>` |
6669
| `REPORT` `calendar-query` | lists the collection | returns **all** events (filter not evaluated — below) |
70+
| `REPORT` `free-busy-query` | lists the collection, `GET` per event | 200 `text/calendar` VFREEBUSY; merged busy periods from the VEVENTs in range (below) |
71+
| `GET <prefix>/freebusy/<pod>?start=…&end=…` | same as free-busy-query | **non-standard extension**; same VFREEBUSY without XML |
6772
| `GET`/`HEAD` `.ics` | `GET`/`HEAD`, body passthrough | `text/calendar`, content-hash `ETag` header |
6873
| `PUT` `.ics` | `PUT` (auto-creates the container) | stores the VEVENT, returns content-hash `ETag` |
6974
| `DELETE` `.ics` | `DELETE` | 204 |
7075
| `MKCALENDAR` / `MKCOL` / extended MKCOL | `PUT` to the trailing-slash URL | 201; host 409 "exists" → 405; body accepted but its props dropped |
7176
| `/.well-known/caldav` | 301 → `<prefix>/` (guarded attempt) | PROPFIND there serves discovery |
7277

78+
## Free-busy
79+
80+
`REPORT` with a `<CAL:free-busy-query>` body on the calendar collection
81+
(RFC 4791 §7.10) answers **200 `text/calendar`** with a VFREEBUSY whose
82+
`FREEBUSY` lines are the merged busy periods computed from the collection's
83+
VEVENTs:
84+
85+
```xml
86+
REPORT /caldav/alice/calendar/ HTTP/1.1
87+
Content-Type: application/xml
88+
89+
<CAL:free-busy-query xmlns:CAL="urn:ietf:params:xml:ns:caldav">
90+
<CAL:time-range start="20260720T000000Z" end="20260725T000000Z"/>
91+
</CAL:free-busy-query>
92+
```
93+
94+
Semantics (all exercised in `test.js`):
95+
96+
- overlapping and adjacent busy periods are **merged**; everything is clamped
97+
to the requested range; periods are emitted sorted;
98+
- `TRANSP:TRANSPARENT` and `STATUS:CANCELLED` events are free (per §7.10);
99+
- an event without `DTEND` uses `DURATION`, else the RFC 5545 defaults
100+
(one day for all-day `DTSTART`s, zero — i.e. not busy — for date-times);
101+
- **recurrence**: `RRULE` with `FREQ=DAILY` or `FREQ=WEEKLY` (plus
102+
`INTERVAL`/`COUNT`/`UNTIL`) is expanded within the range. Any other `FREQ`
103+
or any `BY*` part is **not expanded** — only the master occurrence is
104+
counted, an honest under-report (see "What doesn't map");
105+
- **privacy**: every event is fetched over loopback with the *caller's*
106+
credentials, so an event WAC hides from the caller contributes **no** busy
107+
time — unreadable events are invisible, not busy (Findings, below);
108+
- a missing/malformed/non-UTC/inverted `<CAL:time-range>`**400**.
109+
110+
**Extension (non-standard):** `GET <prefix>/freebusy/<pod>?start=…&end=…`
111+
returns the same VFREEBUSY over `<pod>/<calendar>/` — handy for dashboards
112+
and scripts that don't speak REPORT. Stamps accept iCalendar basic format
113+
(`20260720T000000Z`) or ISO 8601. The route only intercepts GETs that carry a
114+
`start`/`end` query param, so a pod actually named `freebusy` keeps its plain
115+
GETs — but it *is* a shadowed name; pick a different pod name if you need
116+
both. The collection's `<D:supported-report-set>` advertises
117+
`calendar-query`, `calendar-multiget` and `free-busy-query`.
118+
73119
## What doesn't map
74120

75121
- **`calendar-query` filters** — the `<CAL:filter>` grammar (`comp-filter`,
76122
`time-range`, `prop-filter`, `text-match`) is **not evaluated**; a query
77123
returns every VEVENT in the collection and the client filters locally. Correct
78124
but not selective — the filter/time-range engine is a sensible follow-up, not
79125
"minimum usable sync".
80-
- **Recurrence, free-busy, scheduling**`RRULE` expansion, `calendar-data`
81-
with `<CAL:expand>`, `free-busy-query` REPORT, and the iTIP/iMIP scheduling
82-
inbox/outbox (RFC 6638) are all **out of scope**. Events are stored and served
83-
as opaque `.ics` bodies; a recurring VEVENT round-trips verbatim but the bridge
84-
does not expand or compute occurrences.
126+
- **Full recurrence & scheduling**`free-busy-query` is now in (above), but
127+
`calendar-data` with `<CAL:expand>`, the general RRULE grammar (`MONTHLY`/
128+
`YEARLY`, any `BY*` part, `EXDATE`/`RDATE`, `RECURRENCE-ID` overrides) and
129+
the iTIP/iMIP scheduling inbox/outbox (RFC 6638) remain **out of scope**.
130+
Events are stored and served as opaque `.ics` bodies; a recurring VEVENT
131+
round-trips verbatim, and free-busy expands only the simple
132+
`DAILY`/`WEEKLY` shapes. Likewise free-busy treats `TZID`-qualified and
133+
floating times as UTC — VTIMEZONE is not evaluated.
85134
- **`sync-collection` / CTag** — no incremental sync token. Clients fall back to
86135
a full `PROPFIND` + ETag diff each poll, which works but is chattier. A real
87136
CTag/sync-token needs a change feed the plugin api doesn't expose
@@ -166,3 +215,67 @@ All driven with a real pod Bearer through real WAC in `test.js` (15 tests).
166215
`carddav/`, covered by the public auth seam with no reach into `src/`. (A
167216
`did:…` WebID has no pod path segment; there the discovery props fall back to
168217
echoing the requested path.)
218+
219+
7. **Free-busy is read-time O(N) over the whole collection, per query — the
220+
`api.events` seam again, not a new consumer.** Every `free-busy-query` (and
221+
the `/freebusy/` GET) lists the calendar and loopback-`GET`s **every**
222+
`.ics` in it, parses it, and recomputes the merge from scratch — even for a
223+
one-hour range over a ten-year calendar. The obvious fix is a write-time
224+
index (per-event `[start, end)` intervals maintained on PUT/DELETE, queried
225+
in O(log N)), and that is precisely `api.events.onResourceChange` — the
226+
same seam this plugin already wants for CTag/`sync-token` (finding in "What
227+
doesn't map") and that `sparql/`, `rss/`, `search/` et al. document. Count
228+
this as **another face of the existing caldav consumer**, sharpening the
229+
seam, not a new entry in the tally: one plugin, two features (sync tokens
230+
and free-busy), both blocked on the same missing hook. A wrinkle an index
231+
design must face that plain staleness doesn't: the index is computed under
232+
*whose* authority? A shared index leaks WAC-hidden events into busy time
233+
unless it's per-agent or re-filtered at query time — the read-time walk
234+
gets that for free (next finding).
235+
236+
8. **WAC-filtered busy time is correct by construction — a free emergent
237+
property of the loopback data plane.** The busy walk fetches each event
238+
with the *caller's* forwarded `Authorization`, so an event the caller
239+
cannot read returns 403 over loopback and is silently skipped: **unreadable
240+
events are invisible, not busy**. Nobody wrote a privacy policy for
241+
free-busy; the composition of "loopback with forwarded creds" and "skip
242+
what you can't read" *is* the policy, and it can't drift from the server's
243+
WAC because it is the server's WAC. (`test.js` proves it: a resource `.acl`
244+
granting Read to a different WebID makes even the owner's token skip that
245+
event's period.) Note the classic CalDAV deployment choice this makes:
246+
hiding an event hides its busy time too. "Show as busy but not details" —
247+
what Schedule-Inbox deployments often want — would need a *weaker-than-
248+
Read* WAC mode or an owner-authority query, which is `api.authorize`
249+
territory again.
250+
251+
9. **Scheduling (RFC 6638 iTIP/iMIP, Schedule-Inbox/Outbox) needs cross-user
252+
delivery — a seam this api genuinely does not have.** Free-busy *reporting*
253+
(this feature) is the read half of scheduling; the write half is: Alice
254+
POSTs an invitation to her Schedule-Outbox and the server **delivers** a
255+
copy into Bob's Schedule-Inbox — a write into *another user's* pod that
256+
Alice herself has no WAC right to make. The loopback pattern is structurally
257+
unable to express it: forwarding Alice's credentials to write into Bob's
258+
inbox correctly 403s. What would cover it, honestly ranked: (a) the
259+
**issuer/owner-authority case of `api.authorize`** — the plugin acts under
260+
the *recipient's* standing grant ("scheduling may append to my inbox"), the
261+
exact shape `capability/` and `corsproxy/` already document, plus an
262+
append-with-that-authority primitive; or (b) **server-mediated delivery**
263+
core owns a deliver-to-inbox operation (the LDP inbox, `ldp:inbox` /
264+
Solid notifications) and the plugin merely requests it, keeping all
265+
authority in core. (b) is the smaller grant and matches how `activitypub/`
266+
already treats inbox delivery; either way it is the first CalDAV feature
267+
where the bridge cannot borrow the caller's authority, because the whole
268+
point is acting on someone who isn't the caller.
269+
270+
10. **First read of the stored bytes — the "opaque body" boundary crossed with
271+
no new seam, but two honest lies documented.** Until free-busy, the bridge
272+
never parsed iCalendar; ~90 lines of vendored RFC 5545 (unfold, VEVENT
273+
props, DATE/DATE-TIME, DURATION, DAILY/WEEKLY RRULE) sufficed, with no new
274+
import and no internal reach — the import rule held. The two places the
275+
minimal parser is honestly wrong rather than incomplete: `TZID`/floating
276+
times are treated as UTC (no VTIMEZONE evaluation), and non-`DAILY`/
277+
`WEEKLY` or `BY*` recurrences count only their master occurrence — both
278+
**under-report** busy time, which for free-busy is the bad direction (a
279+
scheduler may book over a hidden occurrence). A real deployment wants a
280+
vendored full RRULE/timezone engine; the plugin api itself was not the
281+
limit here.

0 commit comments

Comments
 (0)