Repository navigation
Releases: Calnode/calnode
Release list
v0.10.1
Added
- NethServer 8 module (
packaging/ns8). Calnode ships as a one-click NS8
app: host-based Traefik route with cluster TLS, SQLite on a persistent
volume, generated encryption key preserved across reconfigures, and release
tags that pin module and app to the same version. Preview-grade: installed
and configured paths are covered by robot tests but no live node has run
one end to end yet — seepackaging/ns8/README.md.
Fixed
- Public booking lookup is now rate-limited.
GET /v1/bookings/{id}needs no
auth by design (it carries no PII), but it was the one public route outside
any rate limiter — an enumeration free-for-all. It shares the manage-token
bucket now. - CalDAV connect failures no longer distinguish error classes. Refused vs
timeout vs TLS vs auth failures were surfaced verbatim to the member form, a
usable LAN-scan oracle. The form returns one generic message and logs the
detail server-side; timing side-channels are accepted as residual. - CalDAV no longer sends Basic credentials on cross-origin redirects. A
redirect to another origin now drops the Authorization header instead of
forwarding the app password to a server the user never configured. - A short
GOOGLE_CLIENT_IDno longer panics at boot. The startup log
sliced the first 20 characters unconditionally; unset-or-short values
crashed the process instead of logging the usual "not configured" warning. - Video room explains host takeover instead of silently dropping controls.
Sharing the host link lets anyone take over as host, and the demoted side
just lost its controls with no explanation. Host-link holders are now warned
pre-join not to share it, and a demotion names who took over with a reclaim
hint for owners.
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 wi...
v0.9.0
Added
-
Duplicate an event type.
POST /v1/event-types/{slug}/duplicate, and a Duplicate
action on each row of the event-types list. Closes
#17.The copy carries everything that hangs off the original - intake questions, host
assignments, event-type-specific availability rules, the reminder schedule, and the
custom email subjects and notes - as a single transaction, so a half-built copy can
never be left behind. It is created inactive, under a generated<slug>-copy(then
-copy-2,-copy-3, …) slug, and keepsprice_cents/currencyverbatim: zeroing a
copied price is how a paid meeting quietly starts selling for nothing. Bookings are not
copied. -
Empty days and minimum-notice gaps now explain themselves on all three booking
surfaces (booking page, manage/reschedule page, embed widget). Closes
#20.A day with nothing on it names the day, and the host when the event type has exactly
one, instead of the bare "No available times." that never said whether another day would
help. And whenmin_notice_minutesis what removed the nearest starts, the surfaces say
so rather than leaving the visitor to guess - the most common "why can't I see those
times".The engine decides that, not the front ends:
GET /slotsgains
min_notice: {minutes, dates}listing the booker-local days the policy actually cost
something. A start that is simply in the past, one a booking took away, and one no host
pool could satisfy are all excluded, so the explanation never appears attached to the
wrong cause. Three new/changed keys in all eight locales.
Fixed
-
Constraint violations are recognised by SQLite's error code rather than by its
English message. Thirteen call sites askedstrings.Contains(err.Error(), "UNIQUE constraint failed"), and SQLite reports a PRIMARY KEY collision
(SQLITE_CONSTRAINT_PRIMARYKEY, 1555) with that exact message while giving it a
different code from an ordinary unique violation (SQLITE_CONSTRAINT_UNIQUE, 2067).
The text could not tell the two apart, so nothing that needed to distinguish them
could.db.IsUniqueViolation,db.IsCheckViolationanddb.IsForeignKeyViolationanswer
from the driver's code, falling back to the message only for an error that arrives
without its driver type still attached. A driver error whose code does not match is a
definite no rather than a fall-through, so an error cannot be classified by whether
its text happened to contain an English phrase.Each class is provoked against the real schema in a test rather than constructed by
hand, including the primary-key case, which is the one a code match written from the
message alone would get wrong. -
TRUSTED_PROXY_CIDRS: per-IP rate limits that work behind a CDN. Rate limits key
on the TCP peer, which is right for a directly-reachable instance and useless behind a
fronting CDN, where every visitor arrives from the same handful of addresses and shares
one bucket. List the networks you control, a fronting CDN's own ranges included, and
the client IP is taken fromX-Forwarded-Forwalked right to left past those hops.Nothing changes if you do not set it: a header from a peer you have not listed is still
not read at all, because it is a value the client chose. Within the header the leftmost
entry is likewise client-chosen, so the walk stops at the rightmost address one of your
proxies actually observed, and a malformed hop ends the walk on the peer rather than
being stepped over. RepeatedX-Forwarded-Forfield lines are joined in order rather
than only the first being read, so a client's own line in front of a proxy that adds a
second one cannot hide the hop that matters. Single-value vendor headers (CF-Connecting-IP,X-Real-IP,
True-Client-IP) are never read, from any peer: the setting names networks rather than
CDNs, and a plain reverse proxy in the list forwards whatever the client sent.Follow-ups on the above: the empty-day message keeps its call to action as well as
naming the day ("No available times on Monday, 14 September. Try another date."), in
all eight locales; the minimum-notice line gets its own.notice-hintstyle, a shade
darker than the placeholder text it used to be indistinguishable from; and the notice
gap is now computed only for callers that asked for it, so the MCP tool and the booking
assistant stop paying for a presentation aid they never render. -
An event type's booking link can be renamed until its first booking.
PATCH /v1/event-types/{slug}now acceptsslug, and the editor exposes it as "Booking link".
Refused with 409 once bookings exist, because by then the link is in circulation and
somebody's manage link resolves through it. Mainly this is what makes a duplicate
usable: it arrives as<slug>-copyand there was previously no way to give it a real
name short of deleting and recreating it. -
An event type can no longer be created in a state the editor refuses to save. Three
related fixes: creating one without a location defaulted to Zoom without checking whether
the owner had connected Zoom (it now falls back to in-person, which needs nothing);
PATCHvalidated the location whenever the request mentioned it, which the editor does on
every save, so a stored value the current rules reject locked the operator out of every
other field (it now validates only when the location actually changes); and the demo
seeder wrotelinkwith no URL, so a demo visitor's first edit failed on a field they had
never touched.
Removed
BookingLogic.bookableDayKeysandbook.html'sbookableDates, which were written in
0.8.0 and never read by anything.
v0.8.0
Added
-
Booked times can be shown struck through instead of hidden. Off by default, and
enabled per event type under Visibility. Requested in
#14, tracked as
#19.For a public-hours use case - an intro call, a clinic, a tutor - a visibly busy
calendar communicates demand, and an empty-looking list reads as "nothing here". It
stays off by default because the slots endpoint is public and unauthenticated, so
turning it on makes a host's booked hours legible to anyone with the link. That is a
fair trade when the hours are already public and a privacy regression when they are
not, so it is never inherited by upgrading.Only starts a booking or calendar conflict removed are shown. Times outside the host's
working hours are never rendered, and times withheld by the minimum-notice rule are
never shown as taken - nobody booked those, and saying so would corrupt the signal the
feature exists to send. Booked times cannot be selected on any surface, and agents
using the MCP tools or the booking assistant continue to see only bookable times.GET /v1/event-types/{slug}/slotsgains atakenarray for opted-in event types,
absent otherwise. Event types gainshow_taken_slots(migration 00057).
v0.7.0
Added
-
Filter and page the bookings list. The bookings page now filters by event type,
host, team and status alongside the existing Upcoming/Past and Mine/All toggles, and
pages through results 25 at a time instead of rendering everything at once. Requested
in #15, tracked as
#18.GET /v1/bookingsgainedevent_type,host,team,status,when,from,
to,order,limitandoffsetquery parameters, and its response now carries
total,countsand the activelimit/offsetbesideitems. MCPlist_bookings
gainedteam_id,limitandoffset, and returnstotal.
Fixed
- A running instance now reports which commit it is.
/versionreported
commit: unknownon every container, because the image is built from a copied
source tree with no.gitfor the Go toolchain to read VCS metadata from. That was
survivable while only tagged releases were deployed; it is not now that branch
images can be, since those reportversion: devand nothing else identified the
build. The commit is stamped explicitly at build time instead. - Webhook deliveries are no longer kept forever. Nothing ever purged
webhook_deliveries, so on a busy instance the table grew for the life of the
deployment, inside the SQLite file Litestream replicates offsite. The worker now
sweeps finished deliveries after 30 days, alongside the five other tables it already
purged. Only rows that reachedsuccessorfailedare removed: a pending delivery
still has a job pointing at it, and deleting one would turn a deliverable webhook
into a permanent failure. The deliveries view only ever showed the 50 most recent, so
nothing visible changes. status=cancelledreturned nothing, on every surface. Both booking list queries
hardcoded an exclusion of cancelled bookings and then filtered on top of that result,
so asking for cancelled bookings could never match anything - including through the
MCP tool whose own schema advertisescancelledas a valid value. There was no way at
all to view a cancelled booking. An explicit status now replaces the default exclusion
instead of being applied after it; omitting it still hides cancelled bookings.- Filtering by host missed the meetings that person attends but doesn't lead. The
host filter comparedbookings.host_idonly, while visibility has always counted a
user as hosting a booking if they are the primary host or an assigned host. Group
meetings someone was on were therefore invisible when filtering to them.
Changed
- Bookings are selected in SQL rather than in the browser.
GET /v1/bookingsand
MCPlist_bookingspreviously loaded every booking the caller could see and then
filtered and sorted the result in Go or in Svelte, running follow-up queries whose
INclause held every booking id returned, against a single-connection pool. Both now
share one filtered, ordered, paginated query. - Indexed the bookings list. The only indexes on
bookingsboth led onhost_id
and were partial, so every listing planned as a full scan plus a temporary B-tree
sort of the whole matching set to return one page. Paginating the API alone would
have made the response smaller without making the work smaller.(start_at, id)and
(event_type_id, start_at, id)(migration 00056) turn the page query into an index
walk that stops at the limit. The Upcoming/Past counts are an aggregate and still
scan by design.
v0.6.0
Fixed
- Slot interval is now configurable, and defaults to the meeting length. Reported as
"bookable timeslots are always 30 minutes apart regardless of duration" (#13). Interval
and duration are deliberately separate settings - interval is how often a booking may
start, duration is how long it runs - but the interval was not exposed anywhere in
the admin UI, so every event type was stuck on the schema default of 30 unless you drove
the REST API by hand. It now appears in the event-type editor, and new event types
default it to their duration instead of a fixed 30, which was the wrong guess in both
directions: a 15-minute event offered slots every 30 minutes, and a 90-minute one offered
starts it could not honour. Existing event types keep their stored value until edited. slot_interval_minutesis validated on create and update. Slot generation refuses a
non-positive interval, so a0previously left an event type with no bookable times and
nothing explaining why.
Added
- The Connected apps page now shows the MCP connector URL, with a copy button. It
listed what was connected but never said how to connect anything: the only guidance was
in the empty state, referred to "its URL" without showing one, and vanished once the
first app was approved.
v0.5.0
Fixed
- "calendar connection not found" when choosing where bookings are written. The
destination endpoint looked the account up by itscalendar_connectionsrow id, but that
id is recreated on every OAuth token refresh - and opening the calendar picker can trigger
one - so a page loaded moments earlier held a dead id. Now keyed on the account identity,
as the calendar endpoints already were. - Disconnecting a calendar could silently do nothing. The same stale-id lookup, but its
miss branch returned success, so the API answered204having deleted nothing and the
account simply stayed on the page with no error. Now keyed on account identity, and a
genuinely unknown account is reported rather than swallowed. - Disconnecting left the account's calendar selections behind.
connection_calendars
has no foreign key on purpose (one would cascade-delete a user's selections on every token
refresh), so disconnect flows have to clear the rows themselves - and none did, despite
migration 00049 stating they did. Reconnecting the same address silently inherited stale
picks, including a write target pointing at a calendar the user may no longer have. - The public booking page rendered blank for any event type with a dropdown
question.book.htmlbuilt the dropdown's placeholder with.Tinside the questions
range, where the dot is the question rather than the page, so the template aborted
partway through writing the response. The result was a 200 with correct headers and a
truncated body: everything up to the dropdown was present and the calendar, the slot
picker and every script were silently missing, so the event type could not be booked at
all. Introduced in 0.3.0 with the i18n work and not caught because no test rendered a
select question.
v0.4.0
Added
- Email can now be delivered over Resend's HTTPS API instead of SMTP. Set a Resend
API key under Settings → Email and mail goes out over port 443. This exists because
several hosting platforms block outbound SMTP on their cheaper plans (Railway below
Pro among them) by dropping the packets rather than refusing the connection - which
looks like a hang, then like a wrong password, and cannot be fixed by changing any SMTP
setting. Ports 25/465/587/2525 are all affected and it is not provider-specific. - The transport follows the credentials you supply: an API key selects HTTPS, otherwise
SMTP, otherwise nothing. It does not probe and silently switch. Settings → Email
badges which path is actually live, so filled-in SMTP fields are never mistaken for SMTP
delivery, and "Remove key" switches back.
Security
- All three image uploads now check dimensions before decoding. The 5 MB body limit
bounds bytes on the wire, not pixels: a highly compressed PNG of 30000x30000 is a few
hundred KB and decodes to gigabytes. Both the logo and banner endpoints now read the
image header first and reject anything over 25 megapixels. The branding logo and banner
are admin-only, but the user avatar upload is not - any authenticated member could
send a ~160 KB file that decoded to hundreds of megabytes, and an out-of-memory kill
takes down the process holding the single SQLite connection. It did not need a malicious
user either: a genuine large camera photo is well under 5 MB compressed.
Fixed
- Checkbox answers in the admin bookings list are matched liberally. Answers are
canonicalised toyes/noon the way in, but rows created before that landed hold
whatever the surface sent (the embed widget sentYes), and a strict comparison rendered
those as No - the opposite of what the guest ticked, which matters for consent
checkboxes. Historic rows now display correctly without rewriting stored data. - Branding uploads read the content-type sniff buffer with
io.ReadFull. A short read
could hand the sniffer a truncated prefix and reject a valid image. - A failed SMTP dial could hang for ~2 minutes.
defaultSMTPTimeoutwas applied only
after the connection was established, so the dial itself fell back to the OS SYN-retry
limit. Against a host that drops SMTP packets this stalled the background job queue,
which shares a single SQLite connection, delaying every queued email behind it; the
email test button also appeared to hang rather than fail. - The email test button now explains failures instead of reporting "failed to send test
email". An unreachable server names the platform-block possibility and points at the API
key; a timeout after connecting points at the port/TLS mode; provider rejections are
shown verbatim.
v0.3.0
Added
- Multi-language public surfaces (8 locales). The booking page, the manage
(reschedule/cancel) page, the embed widget, all four emails, the calendar invite
title/description, and the conversational booking assistant are now translated into
English, Spanish, French, German, Italian, Portuguese, Dutch and Swedish. The
locale is negotiated fromAccept-Language(sode-ATresolves tode), overridable
by a footer language switcher (?lang=), with an operator-configurable fallback
language in Settings → Branding for visitors whose language is not shipped. - The booker's locale is stored on the booking (migration 00051), so later emails -
reminders, cancellations, reschedule notices - arrive in the language they booked in
rather than the language of whoever triggered the send. Host-facing sends stay English. - Editable assistant greeting (migration 00052) and fallback-language setting
(migration 00053). - Adding a language requires no code change - dropping
internal/i18n/locales/<code>.jsonin place is the entire task; the switcher, the
fallback dropdown and the public API payload all readSupportedLocales(). See
docs/ARCHITECTURE.md§23.
Fixed
- Paid (Stripe) bookings always sent English email regardless of the language the booker
used, because the confirmation query did not select the stored attendee locale. - Required checkboxes were not enforced. A custom question of type
checkboxmarked
required could be submitted unticked, on both the booking page and the embed widget.
Now enforced client- and server-side, and the stored answer is canonicalised to
yes/noinstead of varying by surface. - The public event-type endpoint returned language-dependent content without a
Vary
header, so a shared cache could serve one visitor's language to another.
Notes
- Non-English translations are LLM drafts without native review. Structure is
verified in CI (key parity, printf-verb parity, and a CLDR cross-check of the date
tables againstIntl); wording is not. Corrections via PR are welcome. - The built-in video room and the admin UI remain English-only.
v0.2.3
Security
- Bumped the Go toolchain from 1.26.5 to 1.26.6, closing 8 known stdlib CVEs
(net/http,encoding/xml,encoding/asn1,golang.org/x/net/idna) that were
reachable from Calnode's own code paths (CalDAV free/busy parsing, DB schema
version checks, Zoom/Google HTTP clients). - Bumped
golang.org/x/imageto v0.45.0, closing a VP8L (WebP) decode
memory-exhaustion CVE (GO-2026-6222) reachable through the branding logo/banner
upload endpoints, which accept WebP images.
Added
- Banner option on the Branding settings page. Same upload/crop/opacity flow
as the logo, shown full width below the logo (matching the email content
container and the public booking form's width) on the booking page, manage
page, and confirmation emails. Hidden entirely when not set; independent of the
logo (either, both, or neither can be shown). - A small link to the GitHub releases page in the admin sidebar footer, so
self-hosted operators always have an easy way to check what version they're
running against. The released Docker image now stamps its actual version at
build time (-ldflags -X buildinfo.Version=...), which it previously didn't -
every image, including past tagged releases, reported "dev".