Skip to content

caldav: move or cancel an event as the account that holds it - #56

Merged
shockalotti merged 3 commits into
Calnode:mainfrom
distronode-corporation:up/caldav-event-account
Sep 21, 2026
Merged

shockalotti merged 3 commits into
Calnode:mainfrom
distronode-corporation:up/caldav-event-account

Conversation

@distronode-com

Copy link
Copy Markdown
Contributor

The problem

caldav.Client.UpdateEvent and CancelEvent called loadConn(ctx, userID, -1, 1), which is the user's current destination connection, and sent its Basic credentials to the stored event URL. A host can connect several CalDAV accounts (saveConnection keys by account), so:

After the booking was made, the destination... What happened on reschedule / cancel
stayed on the same CalDAV account Worked.
moved to another CalDAV account on another server Account B's username and app password were sent to server A. If server A refuses them (401), the event stays put, and the reconciler retries: every sweep for a cancelled booking, until the end time for a moved one. The password is re-sent each time.
moved to another CalDAV account on the same server Same cross-send. If the server answers 404 for another user's resource, UpdateEvent and CancelEvent treat that as "already gone", so Calnode clears the flag or id and the event silently stays.
moved to Google or Microsoft Service routes update/cancel by the destination's provider, so the CalDAV event URL went to Google/Graph, which cannot find it. The event on the CalDAV server was never moved or deleted.
every calendar disconnected Service finds no destination and returns nil; the event stays on the CalDAV server (there are no credentials left to remove it with).

booking_hosts.external_calendar_id (00055) was meant to cover a change of destination, but the CalDAV provider ignored it. It also can't help with a move between accounts on its own, and ARCHITECTURE §10 lists that as a known limit.

The fix

Owning account, not destination (internal/caldav/events.go). eventConn loads every CalDAV connection the user has, with its bound calendar_id and saved connection_calendars ids, then pickEventOwner picks one account:

  1. An account qualifies only if the event URL is inside one of its collections: same scheme, host and port, and a path under the collection's at a segment boundary. So credentials only go to a server that account is already set up to use.
  2. Of the qualifying accounts, one whose collection is the recorded calendar id wins. Otherwise the most specific collection wins. That covers rows from before 00055 and reassign.go, which passes "".
  3. No match or a tie: nothing is sent and an error is returned for the caller to log. It never falls back to the destination.
  4. If the user has no CalDAV connection at all, it returns nil and logs a warning, following the Provider contract's "zero values when the user has no matching connection".

Routing by event, not destination (internal/calendar/calendar.go). There's a new optional calendar.EventRecognizer interface. Service.UpdateEvent/CancelEvent send an event to the provider that recognizes its id, and otherwise to the destination as before. CalDAV recognizes absolute http(s) URLs. Google ids (base32hex) and Microsoft ids are never URLs, so their routing is unchanged.

A single CalDAV account that is still the destination sends exactly the requests it did before: GET, then PUT with If-Match, then DELETE, all with the same credentials. A test pins that sequence, with and without a recorded calendar id.

Tests

internal/caldav/events_test.go runs two httptest CalDAV servers. Each one accepts only its own account and records the credentials on every request.

  • Cross-account move and cancel: server A only ever sees A's credentials, B gets no requests, both calls succeed.
  • Empty calendar id (reassign, legacy rows): resolved by URL.
  • Event in a picked sub-calendar (connection_calendars): resolved by recorded id and by URL.
  • Destination moved to another provider: the CalDAV event goes to CalDAV, the other provider is never asked about it, and that provider's own events still route to it.
  • Owning account disconnected: nil, zero requests anywhere.
  • No match or ambiguous, zero requests to any server: event on an unconnected server; a calendar id naming account B with the event on another server; same server but outside every collection; a prefix that stops short of a segment boundary; a non-URL id; two accounts bound to one collection, with and without a calendar id.
  • TestPickEventOwner table: scheme, default port, host case, trailing slash, nesting, and the two tie kinds.

On main, every integration test above fails except the single-account test and the non-URL-id case (the table tests use new functions, so they were not run there) (B's password shows up on server A; the CalDAV URL goes to the other provider). Restoring loadConn(ctx, userID, -1, 1) on top of the fix fails five tests, and in three of them server A receives pw-b. Reverting just the routing change fails the two provider-move tests.

gofmt -l . is clean, go vet ./... and go build ./... pass, and go test ./... is green.

Notes for review

  • Security, not just Fixed, in the CHANGELOG, because it discloses an app password to a server run by someone else (for example, an employer's Nextcloud receiving a personal Fastmail password), and the reconciler repeats it. The entry suggests hosts who made such a move rotate that app password.
  • Only a strict "inside the collection" match. No looser same-origin match. The cost: if a reconnect rediscovers a different collection URL, or an event's calendar is no longer saved on the account, its events now return an error (logged, retried) where the old code would have used the destination's credentials, which happened to be the right ones only when a single account was connected.
  • EventRecognizer doesn't help with the reverse case, a Google or Microsoft event after the destination moves to CalDAV. That still routes by destination and fails without sending anything, as before. Fixing it would mean recording the provider per event.

🤖 Generated with Claude Code

UpdateEvent and CancelEvent loaded the user's current destination
connection and sent its Basic credentials to the stored event URL. A
host can connect several CalDAV accounts, so once the destination moved
from an account on server A to one on server B, rescheduling or
cancelling an older booking sent B's username and app password to
server A. A refusal left the event in place and the reconciler retried,
resending them every sweep for a cancelled booking and until the end
time for a moved one. A 404 was taken as "already gone".

Update and cancel now resolve the owning connection from what the
booking stored. An account can hold the event only if the event URL lies
inside one of its collections (the bound calendar_id or a saved
connection_calendars id): same scheme, host and port, path under it at a
segment boundary. Among those, the one whose collection is the recorded
calendar id wins, else the most specific collection. No match or a tie
sends nothing and returns an error for the caller to log. With no CalDAV
connection at all it returns nil, as the Provider contract says, and
logs a warning. A single account that is still the destination sends
exactly the requests it did before.

The Service routed update and cancel by the current destination's
provider, so after a move to Google or Microsoft the CalDAV event URL
went to that provider, which cannot find it, and the event on the CalDAV
server was never touched. calendar.EventRecognizer lets a provider claim
its own ids; CalDAV claims absolute http(s) URLs, which Google and
Microsoft ids never are, and everything else still routes by
destination.

Tests use two httptest servers that record the credentials on every
request: the cross-account move and cancel, an empty calendar id (as
reassign passes), an event in a picked sub-calendar, a destination moved
to another provider, a disconnected owner, and the ambiguous and
no-match cases sending zero requests. Restoring loadConn(ctx, userID,
-1, 1) fails three of them with B's password seen on server A.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@shockalotti shockalotti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The credential-safety core is right and I want this merged: eventConn resolves the account that holds the event instead of the current destination, pickEventOwner is a pure, testable function with segment-boundary containment and tie refusal, the single-connection cursor discipline is observed, and the EventRecognizer interface keeps Google/Microsoft routing untouched with a stated reason. It closes the password-to-the-wrong-server leak.

What blocks me is the second half of the problem statement — the reconciler retry storm. I checked internal/handler/calendar_reconcile.go: both reconcileReschedules (line ~98) and reconcileCancellations (line ~135) treat ANY UpdateEvent/CancelEvent error as 'log, leave the flag/id in place, retry next sweep'. Your new fail-closed errors (errNoEventOwner, errManyEventOwner) therefore loop forever: every sweep, an error log, no resolution. The credential re-send is gone (eventConn errors before anything is sent), but the storm shape remains as log spam over a permanently stranded event.

Ask: make the terminal errors terminal. When eventConn reports no account holds the event (or several could), the reconciler should clear needs_sync / null the orphaned event id with a warn log, not retry — nothing about a future sweep can change which accounts hold that URL. A tie (errManyEventOwner) arguably deserves to stay flagged for a human, but even then 'retry the identical pick every sweep' resolves nothing; at minimum it should log at most once or carry a distinct dead-end marker. Happy with any shape that stops the infinite loop — the current one just moves it.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Owning-account CalDAV credentials and event-id routing for update/cancel, plus the tests and docs that pin the old destination leak.

  • Owning account, not destination — eventConn / pickEventOwner send Basic auth only for a collection that contains the event URL (origin + segment-boundary path); no match or a tie sends nothing.
  • Route by event id — calendar.EventRecognizer sends absolute http(s) ids to CalDAV even after the destination moves to Google or Microsoft.
  • Tests — two-server httptest fixtures assert which password hits which host, including empty calendar id, picked sub-calendars, provider move, disconnect, and ambiguous/unknown owner.
  • Changelog / ARCHITECTURE — security disclosure plus the CalDAV exception to the known account-move limit; reassign comment matches the empty-calendar-id path.

Pullfrog  | View workflow run | Using Grok | 𝕏

distronode-com and others added 2 commits September 16, 2026 22:14
CHANGELOG conflict only: kept both [Unreleased] entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eventConn refuses to send anything when the stored ids do not pick out
exactly one CalDAV account: no account holds the event URL, two tie, or
the id is not a URL at all. The reconciler treated that refusal like any
other failure, leaving needs_sync set (reschedules) or the event id in
place (cancellations) and trying again next sweep. Nothing is sent now,
but a retry re-reads the same connections and refuses the same way, so
the loop only moved: an error line every two minutes until the booking
ended, and forever for a cancellation, whose sweep has no date bound.

The three refusals now match calendar.ErrEventUnreachable. On it the
reconciler logs one warning and lets go: it clears needs_sync for a
reschedule (the event keeps its old time and its id) and clears the id
for a cancellation (the event stays on its calendar). The cancel warning
names the event, since clearing the id drops the only record of it, and
logs it redacted because a CalDAV id is a URL that can carry userinfo.

A tie is treated the same. needs_sync is surfaced nowhere a person would
see it, so leaving it set would reach nobody but the log, and only a
change to the host's connections could break the tie.

Every other error stays retryable, including a server that is down or
refuses the stored password, since reconnecting or waiting fixes those.

Tests: the reconciler, with a provider that claims the event, over two
sweeps: an unreachable refusal is called once, clears the flag or id and
warns exactly once; a transient error is called twice and keeps both.
The warning does not log a password embedded in the event URL. On the
CalDAV side, all seven no-owner and tie cases now assert the sentinel,
and a refused password and an unreachable server assert that they do
not match it. Restoring the old reconciler branch fails both reconciler
tests; removing the sentinel match fails 14 CalDAV assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Terminal handling of CalDAV owner-refusal in the reconciler, since the prior pullfrog review on 7a14a5b.

  • Sentinel for a refused send — errEventNotURL, errNoEventOwner, and errManyEventOwner now match calendar.ErrEventUnreachable via a typed Is, so a stored-state dead end is distinct from a failed request.
  • Reconciler stops retrying — On that sentinel it warns once and clears needs_sync (reschedule; event keeps its old time) or the event id (cancel; id logged through url.Redacted()). Other errors still retry.
  • Tests — Two-sweep handler tests pin give-up versus 503, cancel-log redaction of URL userinfo, and CalDAV 401 / unreachable server staying retryable.

Pullfrog  | View workflow run | Using Grok | 𝕏

@distronode-com

Copy link
Copy Markdown
Contributor Author

Done in 3c7e662, on top of a merge of main.

The three refusals (no account holds the event, a tie, and an id that is not a URL) now match a new calendar.ErrEventUnreachable. On it the reconciler logs one warning and lets go. A reschedule clears needs_sync and keeps the event id. A cancellation clears the id, and logs it first because that drops the only record of which event was left behind. It is logged redacted, since a CalDAV id is a URL and a server URL typed with userinfo would carry a password into it. Every other error stays retryable, including a server that is down or refuses the stored password, because waiting or reconnecting fixes those.

I treated a tie the same as no owner rather than keeping it flagged. needs_sync is not shown anywhere a person would see it, so a kept flag would only reach the log, and a tie changes only when the host changes their connections. If you would rather keep ties flagged with a distinct dead-end marker, that needs a column, and I'm happy to add it.

Tests drive the reconciler over two sweeps with a provider that claims the event. An unreachable refusal is called once, clears the flag or id, and warns exactly once. A transient error is called twice and keeps both. On the CalDAV side, all seven no-owner and tie cases now assert the sentinel, and a refused password and an unreachable server assert that they do not match it. Putting back the old reconciler branch fails both reconciler tests, and removing the sentinel match fails 14 CalDAV assertions. CHANGELOG and ARCHITECTURE §10 say the same.

shockalotti added a commit that referenced this pull request Sep 21, 2026
Merge-resolution fix for #56: the branch predates the 6-arg
saveConnection (caldav_home_url). Empty home URL preserves the
PR's tested semantics; homeURL is only read by ListCalendars.
@shockalotti
shockalotti merged commit dab1c64 into Calnode:main Sep 21, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants