Repositories openvibers openvibe.today official
openvibers/openvibe.today
Open source daily planner: stream schedules, reminders that find you, calendar feeds and the morning brief for everything happening today.
Get it
Get this repository
A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.
git clone https://git.openvibe.codes/openvibers/openvibe.today.git
OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.
Clone to my computerA Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.
Open in CodesREADME.md
View fileOpenVibe.Today
The network's time bus and calendar authority.
Live site: https://openvibe.today · API docs: https://openvibe.services · For AI: https://openvibe.today/llms.txt
Run it: npm ci && npm run dev (Node 22 or newer) · Tests: npm test
Status: v1 written and tested (see [STATUS.json](STATUS.json)).
Domain: openvibe.today · Port: 5100 · Service id: today · Env prefix: TODAY
License: AGPL-3.0-only (same as every OpenVibe service).
OpenVibe.Today is where a person puts a stream or an event on the network's calendar. It serves the public schedule,
one channel's schedule, a private calendar feed per person, reminders through OpenVibe.Network and a daily brief. It
is also the authority for those events over OpenVibe.Events: a producer writes an event to Today's outbox in the same
transaction as the change, and the calendar moves from scheduled to live to done as OpenVibe.Live says so.
Purpose
One schedule for the whole network:
- Public events —
GET /api/v1/eventsand the home page, soonest first. A stream belongs to the caller's Live
channel; an event is a plain appointment. A followers-only event is visible to its owner and absent from public
lists. - Calendars — RFC 5545.
/ics/@<username>.icsis a channel's public calendar;/ics/<token>.icsis one person's
private agenda. Any calendar app can subscribe; rotation invalidates the old URL immediately. - Reminders — one per person and event, sent once through OpenVibe.Network's notification push when due, and never
for a cancelled event. - The brief — later today, your reminders, and up to three things OpenVibe.Live's public Rundown talked about
yesterday; one stored brief per person per day. - The bus —
today.event.scheduled|updated|cancelledpublished through a transactional outbox with a fixedevt_
id, andlive.stream.started|endedconsumed to move events live and done exactly once.
Owns
| Table | Holds | Migration |
|---|---|---|
| today_events | tev_ + 20 base32 characters, owner_subject (usr_…), kind stream\|event, the owner's Live channel for a stream, title, description, starts_at, ends_at, IANA tz, status scheduled\|live\|done\|cancelled, visibility public\|followers, url, seq (the ICS SEQUENCE), timestamps | migrations/0001_initial.sql |
| today_idempotency | (subject, key) → the first event; a repeated Idempotency-Key never makes a second one | 0001_initial.sql |
| today_reminders | (subject, event_id), remind_at, sent_at, created_at | 0001_initial.sql |
| today_feeds | one private feed token per subject (32 random bytes, base64url) | 0001_initial.sql |
| today_outbox | the fixed evt_ id, topic, JSON payload, attempts, last error and a claim/backoff lease | 0001_initial.sql |
| today_processed | inbound Events ids already handled, so a replay is a no-op | 0001_initial.sql |
| today_briefs | (subject, day) → the sections, one per person per day | 0001_initial.sql |
| today_calendar_imports | the addresses a person imported: cal_ + ULID, subject (usr_…), the https url, its short name, when it was last read and why the last read failed; at most five per person | migrations/0004_calendar_imports.sql |
| today_calendar_import_cache | the copy of each imported calendar, read at most once every half hour; it goes with the address | 0004_calendar_imports.sql |
| account_data_events | the receipts openvibe-sdk/account-data keeps for export and deletion deliveries | 0001_initial.sql |
| telemetry_samples | aggregated request telemetry, pruned after 14 days | 0001_initial.sql |
Does not own
- Identity, follows and notifications — OpenVibe.Network. Today verifies its session tokens and JWKS, reads a
person's follows with their own token (GET /api/v1/me/follows, cached 60 s), and pushes reminders through
/internal/notifications/pushwith its own client-credentials token. - The live stream and its lifecycle — OpenVibe.Live. Today consumes
live.stream.started|ended; it never starts or
stops a stream. - The Rundown — OpenVibe.Live's public
/api/rundown; Today reads it for the brief and links back to it. - Event delivery — OpenVibe.Events. Today publishes its outbox and subscribes to the live and account topics.
- Account export and deletion — OpenVibe.Network (ADR-033). Today answers its part and confirms it.
- The host, database and Valkey — OpenVibe.Host.
Depends on
Environment variable names are in [.env.example](.env.example); no value is committed.
- OpenVibe.Network — sign-in (OAuth client
today, PKCE S256), JWKS, token exchange/refresh/revoke, follows,
identity resolution and notifications:OV_NETWORK_URL,OV_NETWORK_INTERNAL_URL,OV_NETWORK_ISSUER,
OV_OAUTH_CLIENT_ID,OV_OAUTH_CLIENT_SECRET,OV_OAUTH_REDIRECT_URI,OV_SESSION_AUDIENCE,TODAY_AUDIENCE. - OpenVibe.Events — publishing and subscriptions:
TODAY_EVENTS_SECRET,TODAY_EVENTS_URL,
TODAY_EVENTS_ENDPOINT,TODAY_EVENTS_INTERVAL_MS,TODAY_EVENTS_SCOPE(the product scopeevents.publish;
the publisher falls back to the capabilityevents.event.publishif the token endpoint refuses it). - OpenVibe.Live — the public Rundown API:
OV_LIVE_INTERNAL_URL,TODAY_LIVE_TIMEOUT_MS. - PostgreSQL —
DATABASE_URL(through PgBouncer) andDATABASE_DIRECT_URL(migrations); embedded PGlite in
TODAY_PGLITE_DIRfor development. - Valkey — optional
VALKEY_URL,VALKEY_PREFIX(the readiness check lists it; the app's per-address limits do
not require it). - Packages —
openvibe-contracts,openvibe-sdk,openvibe-shared(pinned to the same versions as
OpenVibe.Help).
API
Everything is JSON under /api/v1. A person is the today_at session cookie or Authorization: Bearer <Network user
token>, verified offline with the Network JWKS. A write made with the session cookie must come from
https://openvibe.today (403 today.cross_site).
| Route | What it does |
|---|---|
| GET /events?channel=&from=&to=&limit= | public events (visibility=public, not cancelled), soonest first; from is now − 2 h, to now + 60 days, limit ≤ 100; Cache-Control: public, max-age=60 |
| GET /events/:id | one event; a cancelled one answers status: "cancelled"; a followers-only one is 404 unless the caller owns it |
| POST /events | signed in; { kind, title, starts_at, ends_at?, tz, description?, visibility? }; a stream's channel and url come from the token, never the body; Idempotency-Key (≤ 100 characters) honoured; at most 60 scheduled future events per owner (409 today.too_many); the event and its today.event.scheduled outbox row commit together |
| PATCH /events/:id | owner; title, description, starts_at, ends_at, tz, visibility; seq + 1; today.event.updated; refused once cancelled or done |
| DELETE /events/:id | owner; status cancelled, seq + 1, today.event.cancelled, reminders dropped; idempotent (twice is 200) |
| POST /events/:id/remind | signed in; { minutes_before?: 0–1440 } (default 10); upsert |
| DELETE /events/:id/remind | remove it (idempotent) |
| GET /me/reminders | the caller's upcoming reminders |
| GET /me/agenda?days=14 | own events, reminded events and the public or followers-only events of followed channels; follows_unavailable: true when Network is down |
| GET /me/feed | { url: "https://openvibe.today/ics/<token>.ics" }, creating the token on first call |
| POST /me/feed/rotate | replace the token; the old URL is 404 at once |
| GET /me/brief | today's brief, generated on demand when missing (?tz= optional IANA zone) |
Validation refusals are 400 { code: "today.invalid", field, message }. Other codes:
today.too_many, today.cross_site, today.locked, today.not_found, today.sign_in_required.
Validation
- title 1–120 characters; description ≤ 1000
starts_atan ISO time no earlier than 5 minutes ago and no later than 366 days aheadends_atafterstarts_atand within 24 hours of ittza valid IANA zone (checked withIntl)kindstreamorevent;visibilitypublicorfollowers
ICS
GET /ics/<token>.ics is the token owner's agenda (60 days ahead, 7 back), private, max-age=300.
GET /ics/@<username>.ics is a channel's public events, public, max-age=300.
The calendars follow RFC 5545: CRLF line endings, lines folded at 75 octets without splitting a UTF-8 sequence, TEXT
escaping (\\, \;, \,, newline as \n), PRODID:-//OpenVibe//Today//EN, VERSION:2.0, CALSCALE:GREGORIAN,
X-WR-CALNAME, and a VEVENT per event with UID:<id>@openvibe.today, DTSTAMP, UTC DTSTART/DTEND (no end means
one hour), SUMMARY, DESCRIPTION, URL, SEQUENCE, STATUS:CONFIRMED|CANCELLED and LAST-MODIFIED. A cancelled
event is emitted with STATUS:CANCELLED for 7 days so calendars remove it, then dropped.
A token feed carries the owner's own and reminded events. When this process has read the person's follows (visiting
/me or /api/v1/me/feed warms the 60 s cache), it also carries their followed channels' public or followers-only
events. The feed request itself carries no person's Network token, so a freshly started process cannot read the follow
graph for that one URL.
Events
Publish. Every change writes a today_outbox row in the same transaction, with the evt_ id fixed at that
moment. A job every 5 s claims pending rows with a lease, posts each envelope to OpenVibe.Events POST /api/v1/events
with this service's client-credentials token (audience openvibe.events), and records the attempt. A failure backs
off (5 s doubling to 5 minutes), stores last_error, and never crashes the process. Because the id is fixed and Events
is idempotent on it, a retry or a restart cannot make a second event.
Envelope: source: "today", subject: { type: "user", id: <owner_subject> }, actor the same person,
event_type today.event.scheduled|updated|cancelled, payload
{ event_id, kind, channel, title, starts_at, ends_at, tz, status, visibility, url, seq }.
Consume. POST /internal/events is loopback only (nginx answers 404 for /internal). Deliveries are verified with
the v2 signature over the raw body and the ±300 s replay window (openvibe-sdk/events parseDelivery, requireV2).
live.stream.started makes the channel's scheduled event whose start is within 2 hours of now live;
live.stream.ended makes a live event done. The inbound event id is recorded in today_processed in the same
transaction as the change, so a replay does nothing. Account export/deletion events go to openvibe-sdk/account-data
over every table that holds a subject. startSubscriptions() creates the two live and two account subscriptions at
boot, idempotently, with events.subscription.manage.
Reminders
Every 60 s, due reminders (remind_at <= now, sent_at null, event scheduled or live) are claimed with
UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING, so two processes never send twice. The Network
user id is resolved in batches via /internal/identity/resolve-batch (cached), then one notification is pushed:
type TODAY_REMINDER, category stream for a stream else system, title "<title> starts in N min" (or
"<title> is live now"), service: "today", URL https://openvibe.today/e/<id>. A failed push releases the claim
for the next tick.
The brief
server/brief.js builds { day, sections: [{ id, title, items: [{ text, url, action }] }] } from:
- Later today — events in the person's agenda that start today in their zone (action
remind). - Your reminders — today's reminders.
- Yesterday on OpenVibe — up to three items from OpenVibe.Live's public Rundown API, omitted when it fails.
Sections with no items are left out. The body (the sections) is stored in today_briefs, one row per subject per day.
A daily job at 06:00 UTC builds briefs for subjects active in the last 30 days (a reminder, an own event or a feed);
v1 does not notify.
Pages
All server-rendered through the OpenVibe Frame (openvibe-shared/shell), readable without JavaScript, with semantic
HTML and stable td- ids/classes:
| Page | What it is |
|---|---|
| / | the room's front door: the day's ring (today's events on the reader's clock), what is coming up by day, three scenes |
| /@:username | a channel's schedule, with Subscribe (webcal://) and the .ics |
| /e/:id | one event, with application/ld+json Event (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (POST /e/:id/remind, /remind/remove), Add to calendar (/e/:id.ics), Watch on Live, and Cancel for its owner (POST /e/:id/cancel) |
| /me | agenda, reminders, the calendars you imported (a form and a list, with a Remove button), the feed URL and its rotate form |
| /me/week | your week: seven days from Monday in your own zone — your reminders at each time they fire, your events, the streams of the channels you follow, the events of the calendars you imported, and the public holidays of the country you choose (a small select sets it) |
| /brief | today's brief |
| /schedule | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (POST /schedule → 303 /e/:id) |
| /developers | the API |
| 404 | the not-found page |
Public pages are public, max-age=60 with Vary: Cookie; a page drawn for a signed-in person is private, no-store.
The pages are built on the shared room kit (kit.css, room.css, showcase.css, motion.js; Today's amber from openvibe-shared/rooms) in server/views/. public/js/today.js adds the reader's own time, the device's zone on the form, copy and confirm; everything works without it. scripts/seed-dev.js puts a few events in the local development database.
robots.txt, sitemap.xml, llms.txt and llms-full.txt list the public pages only.
Run and test
npm install
npm test # PGlite; every test file in its own process, no network needed
npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
OV_ALLOW_SUITE=1 npm test
npm start # PORT 5100, loopback; DATABASE_URL or an embedded PGlite in development
The tests boot the app with a mock Network and Events: events (validation, idempotency, cap, followers, cross-site,
transactional outbox), ICS (folding, escaping, cancellation window, token rotation), the consumer (signed, replay, bad
signature), the outbox (fixed id, backoff, restart), reminders (one notification, concurrency, cancelled), agenda
(follows and Network down), imports (idempotent, time zones, source links, retirement, no private fields), brief
(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery. The imported calendars
are tested on their own: reading a calendar (a zone, an all-day event, a weekly rule, an excluded day, a cancelled
event, a clock change), refusing a private address and a body that is too big, the week that shows them, the five
addresses a person may keep, and a form post from another site refused. The public holidays are tested on their
own too: the rules for several years (Thanksgiving, Memorial Day, a July 4 on a weekend, Easter Monday, the August
bank holiday, Pfingstmontag, a fixed Japanese holiday, Carnaval), the country a zone says, the ?country= and the
cookie, an unknown code ignored, and the week with the holiday at the top of its day.
Imports
server/imports/ fills the public calendar from the services around the network: the streams on now on Live, public
Fun plans and accepted IRL quests, Nager.Date holidays per country, and (when TODAY_IMPORTS_EVENTS_TOPICS names
public people-facing topics) public events from OpenVibe.Events. Upcoming streams are not imported: the schedule card
in Live's studio writes them into this service's own table. The job runs at boot and every
TODAY_IMPORTS_INTERVAL_MS, is off under TODAY_DRILL, and npm run imports:run runs one pass by hand. Each row
keeps its source/source_id, its source URL and its time zone; a second run updates the same row, and a source
item that disappears retires its row.
Imported calendars
A person can add up to five calendar addresses on their agenda (POST /me/calendars, POST /me/calendars/:id/remove):
https://… or the webcal:// form of one, which becomes https://. The address is read through the shared egress
rule (openvibe-shared/egress): https only, a public host only, a name that resolves to a private or link-local
address refused before a byte is sent and again at connect time, a redirect followed only after the same check, a 10 s
timeout, at most 2 MB, and only text/calendar or text/plain is read. Each address is read at most once every
half hour, and the copy is what the week is drawn from, so an address that cannot be reached for a moment does not
empty the week.
server/ical.js reads RFC 5545 far enough and no further: SUMMARY, DTSTART/DTEND with a TZID or an
all-day DATE, DURATION, EXDATE, STATUS:CANCELLED, and a RRULE that is DAILY, WEEKLY with
BYDAY or MONTHLY by the day of the month, with INTERVAL, COUNT and UNTIL. A rule with anything else
in it (BYMONTH, BYSETPOS, an ordinal BYDAY such as 2FR), a zone this server does not know and an event
with no DTSTART are left out and counted, never guessed at. The address belongs to the person: it is never
exported and it goes with the account.
Public holidays
Your week shows the public holidays of a country: an all-day line at the top of its day, with the country code in
small letters. server/holidays.js works them out by rule, with no network call and no dependency: a fixed date
(1 January), the nth weekday of a month (the fourth Thursday of November is Thanksgiving Day), the last weekday of a
month (the last Monday of May is Memorial Day) and Easter with the days counted from it (Good Friday is two days
before Easter, Whit Monday fifty after). A holiday that lands on a weekend is moved the way that country moves it:
the United States takes the Friday before a Saturday holiday and the Monday after a Sunday one; Britain, Canada,
Ireland and Australia take the Monday; New Zealand the Monday or the Tuesday.
The country is the one the person asks for: the ?country= of the week, else the td_country cookie, else the one
their time zone says (Europe/Berlin → Germany). The little select on the week page sets the cookie with a plain
form (POST /me/week/country, same-origin, no JavaScript); a code this service does not know is ignored, and a
zone that says nothing (UTC) leaves the person without holidays until they choose.
The lists are the countries' own: the United States' federal holidays, Canada's statutory ones, the England and
Wales bank holidays, Ireland's public holidays, Australia's national eight, New Zealand's Mondayised ones,
Germany's, France's, Spain's, Italy's, the Netherlands', Brazil's, Mexico's días de descanso obligatorio, India's
three gazetted ones and Japan's. Each country keeps the comment that says where its list comes from, and a country
whose list cannot be stated is left out rather than guessed at.
Deploy
deploy/systemd/openvibe-today.service (127.0.0.1:5100, /etc/openvibe/today.env) and
deploy/nginx/openvibe.today.conf (TLS, www → apex, sign-in/API/ICS limits, /auth/me on the API zone,
/ics/ GET only, /internal and /metrics 404).
License
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.419.0
- openvibe-sdk: v0.161.0
- openvibe-shared: v4.237.0
<!-- versions:end -->