Repository navigation
v0.10.0
Security
-
Moving or cancelling a CalDAV booking no longer sends another account's app password
to the server holding the event. A host can connect several CalDAV accounts, and
rescheduling or cancelling authenticated as whichever account was the destination at
the time, not the one the event was written to. After a host moved their destination
from an account on one server to an account on another, every reschedule or cancel of
an older booking sent the new account's username and app password to the old account's
server. A server that refused them left the calendar unchanged and the reconciler
retried, sending them again every sweep for a cancelled booking and until the end time
for a moved one. A server that answered 404 instead was taken at its word: the event
counted as already gone and stayed where it was.Update and cancel now authenticate as the account that holds the event, found from what
the booking stored: the calendar recorded at creation, or failing that the connected
calendar whose URL contains the event's URL (same scheme, host and port). If no single
account can be established, nothing is sent, and the reconciler logs one warning and stops
retrying that event rather than refusing it again every sweep; the event stays where it
is. Hosts who moved a
CalDAV destination between accounts on different servers should consider rotating the
app password of the account they moved to. -
A booker's email address is validated where it enters, and is never written into an
email header unparsed. TheTo:header was the one header field assembled from
caller-supplied input with no encoder in front of it:buildRawparsed each recipient
withnet/mailand, when that failed, appended the raw string anyway, so a CR/LF inside
an address would have ended theTo:line and started a header of the sender's
choosing. Subject and theFromdisplay name already go throughmime.QEncoding
(which renders CR/LF as=0D/=0A) and attachment filenames through%q.Not exploitable as shipped:
Sendissuesc.Rcpt(to)beforeDATA, andnet/smtp
runsvalidateLineinsideRcpt, refusing any CR or LF - so a CRLF-bearing address
aborted the exchange atRCPT TOand never reached the body. That protection is
incidental, lives one call away in the standard library, and covers only this
transport.buildRawnow refuses an unparsed address (and an empty recipient list)
outright, returningmailer.ErrInvalidRecipientbefore anything is dialed; the
rejected value is kept out of the error, which is logged.The public booking paths validate at intake rather than relying on the mailer: the REST
handler (POST /v1/bookings) answers 400 "email must be a valid email address", and
the shared core behind the conversational assistant'sbooktool and the MCP
create_bookingtool checks the address before it persists anything. Both store the
parsed bare address, so a pastedBob <bob@example.com>is recorded as
bob@example.com- a small deliberate behaviour change, matching what the hourly
throttle, the per-invitee cap and theTo:header already assume they hold.
Added
-
Sign out everywhere.
POST /v1/auth/sessions/revoke-allends every session you
have except the one you asked from, so losing a laptop no longer means waiting out a
30-day cookie. Pass{"user_id": "..."}and an admin can do the same for someone
else: an admin may revoke a member, only the owner may revoke another admin, and the
owner's own sessions can only be ended by the owner.It also revokes that person's MCP OAuth tokens, which is the part that makes it an
offboarding tool rather than a convenience. A connected agent authenticates with a
bearer token and not the session cookie, so ending the sessions alone would have left
it holding exactly the access that was just withdrawn. -
Canadian French (
fr-CA) on the booker-facing surfaces. A visitor whose browser asks
forfr-CAnow gets Canadian French rather than the France copy;frandfr-FRare
unaffected. It is the first regional locale, and a separate file rather than a fallback
because the differences are real:courrielrather thane-mail,reporter/report
rather thanreprogrammer,renseignements personnels(the Quebec statutory term) rather
thandonnées personnelles, no space before!?;where France puts one, and CLDR
itself spells Julyjuill.here againstjuil.in France.⚠️ The wording is an unreviewed draft, like every non-English locale in this
repository: the structure is verified by the same three guards (same keys, printf-verb
parity, date tables cross-checked against CLDR), but no native Canadian French speaker has
read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING. -
FRAME_ANCESTORS: embed the admin UI in your own console. Space-separated origins
(https://console.example.com 'self'); when set,/admin/sends
Content-Security-Policy: frame-ancestors <list>. The public booking pages are
untouched and still deny framing outright — this is about the console, not the pages
that take card details.Two deliberate refusals. An entry that is not
https://host[:port]or'self'stops
the app booting rather than being ignored, because a browser drops a source list it
cannot parse, which would leave the admin UI more embeddable than the setting being
unset. And noX-Frame-Optionsis sent beside it: that header has no allow-list form,
so the only value it could carry isSAMEORIGIN, which browsers honour instead of the
CSP and would break the embedding this exists for. -
Per-person and team booking pages. A handle set in profile settings buys
/u/{handle}: every public, active event type the person owns or hosts, with
duration, location and price per type. Teams get/team/{slug}with their types
plus a member roster linking to each member's page. Archived users 404, and handles
are slugified, unique, and cleared by blanking the field. Answers
#94. -
Custom-hours date overrides take several blocks. A date override used to hold a
single window while weekly rules take as many as you like, so a split day (open
morning and afternoon around a mid-day appointment) had no honest expression. Dates
now hold any number of custom blocks; a blocking override replaces the customs on
its date, an exact duplicate still 409s, and the list flags blocks that overlap
and merge. Answers
#95. -
SMTP through a separate relay address. Some providers require the SMTP session
to open against a relay host distinct from the mail domain: the relay address is
now configured explicitly instead of derived. Thanks
@marijnbent (#65).
Fixed
-
SMTP email works with servers that offer AUTH LOGIN but not AUTH PLAIN.
Calnode previously used PLAIN for every authenticated SMTP connection, which
failed against servers such as Microsoft 365 that advertiseLOGIN XOAUTH2
after STARTTLS. It now prefers PLAIN when offered and uses LOGIN otherwise.
LOGIN requires TLS, and servers offering neither supported method return a
clear error. -
Rescheduling on the manage page works again. Its slot list called an
esc()
helper that only ever existed on the booking page, so any day with availability
threw before rendering and could not be rescheduled. The helper is now defined
on both surfaces. -
Removing a member no longer 500s on booking history, and no longer leaves
their MCP tokens valid.booking_hosts.user_idhad noON DELETEaction;
both it and the token tables now cascade. The upcoming-booking guard covers
group seats too, and primary-host history (past or present) blocks with a 409
naming reassignment instead of failing in SQL. -
Cancelling a paid booking cannot double-refund. The refund row is claimed
with a conditional update before Stripe is called, the call carries an
idempotency key, and a Stripe failure reverts the claim so a later cancel
retries instead of silently keeping the money. -
A lost confirmation email is retried once and then flagged, not just logged.
Confirmation sends get one retry; a final failure setsconfirm_failedon the
booking, surfaced on the booking JSON for follow-up. -
The slots response says when its busy data is incomplete. A provider outage
used to read as free time while booking stayed fail-closed, offering slots that
could not be sold. Slots now carrydegraded: true, and all three booking
surfaces show a warning. -
Rescheduling or cancelling after a destination change acts on the provider that
holds the event. The booking stored which calendar its event was written to but
not which provider wrote it, so a destination move handed old event ids to a
provider that never issued them: silent orphans one way, endless reconciler
retries the other. Each host event now stamps its provider at creation; updates
and cancels prefer the stamp, then id recognition (CalDAV URLs), then the current
destination for pre-stamp rows. Answers
#58. -
The calendar picker no longer drops calendars past the first page. Microsoft
requested$top=100calendars once and GooglemaxResults=250once, so anything
beyond silently vanished: unreachable for conflict checks and unchoosable as the
destination. Both listings now follow@odata.nextLink/nextPageTokento the
end. Answers #59. -
The Zoom setup text no longer promises that an unpublished app works for "your own
team". Zoom only lets users inside the Zoom account that owns an unpublished app
authorize it, so a member with their own Zoom account was refused on a Zoom error page
that Calnode never sees. Settings → Zoom now says so, andDEPLOY.mdgains a Zoom
section with Zoom's three ways around it (same account, beta sharing, publishing) and
the link-only fallback that needs no Zoom app. Answers
#35. -
A CalDAV event is moved or deleted even after the destination moves to Google or
Microsoft. The event was handed to the new destination's provider, which could not
find an id it never issued, so the event stayed on the CalDAV calendar at its old time,
or after its booking was cancelled. A CalDAV event id is the event's URL, which is now
enough to route it back to the CalDAV provider whatever the destination is. -
Microsoft calendars can be chosen as the one bookings are written into. Since 0.5.0
the calendar picker marked every Microsoft calendar "(read-only)" and disabled its Book
option. The calendar list read Graph'scanEditbut left it out of$select, so Graph
never returned it and every calendar decoded as not writable. It is now requested, and a
test fails if that request omits any property the response decodes. -
Cancellations name who cancelled. The bookings page sent a hardcoded "cancelled by
admin" reason that the server stored verbatim, so a member cancelling their own booking
emailed as an admin cancel. The server now composes "Cancelled by {name}" from the
authenticated caller and ignores the client string; booker manage-link and system
cancels keep their own reasons. Answers
#90. -
The dashboard lists every booking link instead of crowning one. With several event
types it showed a single "Your booking link" pointing at whichever came first. One
event type keeps the single-link box; several render a per-type list with names, copy
buttons, and the profile page link once a handle is set. Answers
#93. -
A rescheduled LiveKit meeting gets fresh join links. Room names are stable but the
signed URLs expire past the meeting end, so rescheduling past the original date left
every stored link dead. Reschedule now re-mints both links on the same room with an
expiry past the new end before the emails go out. Answers
#98. -
A second Microsoft account no longer replaces the first. Entra tenants omit the
preferred_username/emailclaims by default, so the account identifier came back
empty and saving the new connection deleted the old one. Calnode now requests the
profileandemailscopes, falls back to the stabletid:oidpair, and refuses the
connection with an actionable error rather than storing an empty dedup key. Answers
#99. -
External calendars are rechecked before a booking is created. Availability was
computed, then the booking was written without re-reading the providers, so an event
landing in the gap double-booked. Creation now rechecks and fails closed with
provider failures distinguished. Thanks
@marijnbent (#66).