openvibers/openvibe.today README.md

README.md

main 19.6 KB · 274 lines · markdown

1# OpenVibe.Today
2
3> The network's time bus and calendar authority.
4
5**Live site:** https://openvibe.today · **API docs:** https://openvibe.services · **For AI:** https://openvibe.today/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
9**Domain:** `openvibe.today` · **Port:** 5100 · **Service id:** `today` · **Env prefix:** `TODAY`
10**License:** AGPL-3.0-only (same as every OpenVibe service).
11
12OpenVibe.Today is where a person puts a stream or an event on the network's calendar. It serves the public schedule,
13one channel's schedule, a private calendar feed per person, reminders through OpenVibe.Network and a daily brief. It
14is also the authority for those events over OpenVibe.Events: a producer writes an event to Today's outbox in the same
15transaction as the change, and the calendar moves from scheduled to live to done as OpenVibe.Live says so.
16
17## Purpose
18
19One schedule for the whole network:
20
21- **Public events** — `GET /api/v1/events` and the home page, soonest first. A stream belongs to the caller's Live
22 channel; an event is a plain appointment. A followers-only event is visible to its owner and absent from public
23 lists.
24- **Calendars** — RFC 5545. `/ics/@<username>.ics` is a channel's public calendar; `/ics/<token>.ics` is one person's
25 private agenda. Any calendar app can subscribe; rotation invalidates the old URL immediately.
26- **Reminders** — one per person and event, sent once through OpenVibe.Network's notification push when due, and never
27 for a cancelled event.
28- **The brief** — later today, your reminders, and up to three things OpenVibe.Live's public Rundown talked about
29 yesterday; one stored brief per person per day.
30- **The bus** — `today.event.scheduled|updated|cancelled` published through a transactional outbox with a fixed `evt_`
31 id, and `live.stream.started|ended` consumed to move events live and done exactly once.
32
33## Owns
34
35| Table | Holds | Migration |
36|---|---|---|
37| `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` |
38| `today_idempotency` | (`subject`, `key`) → the first event; a repeated `Idempotency-Key` never makes a second one | `0001_initial.sql` |
39| `today_reminders` | (`subject`, `event_id`), `remind_at`, `sent_at`, `created_at` | `0001_initial.sql` |
40| `today_feeds` | one private feed token per subject (32 random bytes, base64url) | `0001_initial.sql` |
41| `today_outbox` | the fixed `evt_` id, topic, JSON payload, attempts, last error and a claim/backoff lease | `0001_initial.sql` |
42| `today_processed` | inbound Events ids already handled, so a replay is a no-op | `0001_initial.sql` |
43| `today_briefs` | (`subject`, `day`) → the sections, one per person per day | `0001_initial.sql` |
44| `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` |
45| `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` |
46| `account_data_events` | the receipts openvibe-sdk/account-data keeps for export and deletion deliveries | `0001_initial.sql` |
47| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
48
49## Does not own
50
51- **Identity, follows and notifications** — OpenVibe.Network. Today verifies its session tokens and JWKS, reads a
52 person's follows with their own token (`GET /api/v1/me/follows`, cached 60 s), and pushes reminders through
53 `/internal/notifications/push` with its own client-credentials token.
54- **The live stream and its lifecycle** — OpenVibe.Live. Today consumes `live.stream.started|ended`; it never starts or
55 stops a stream.
56- **The Rundown** — OpenVibe.Live's public `/api/rundown`; Today reads it for the brief and links back to it.
57- **Event delivery** — OpenVibe.Events. Today publishes its outbox and subscribes to the live and account topics.
58- **Account export and deletion** — OpenVibe.Network (ADR-033). Today answers its part and confirms it.
59- **The host, database and Valkey** — OpenVibe.Host.
60
61## Depends on
62
63Environment variable names are in [.env.example](.env.example); no value is committed.
64
65- **OpenVibe.Network** — sign-in (OAuth client `today`, PKCE S256), JWKS, token exchange/refresh/revoke, follows,
66 identity resolution and notifications: `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`,
67 `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `TODAY_AUDIENCE`.
68- **OpenVibe.Events** — publishing and subscriptions: `TODAY_EVENTS_SECRET`, `TODAY_EVENTS_URL`,
69 `TODAY_EVENTS_ENDPOINT`, `TODAY_EVENTS_INTERVAL_MS`, `TODAY_EVENTS_SCOPE` (the product scope `events.publish`;
70 the publisher falls back to the capability `events.event.publish` if the token endpoint refuses it).
71- **OpenVibe.Live** — the public Rundown API: `OV_LIVE_INTERNAL_URL`, `TODAY_LIVE_TIMEOUT_MS`.
72- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in
73 `TODAY_PGLITE_DIR` for development.
74- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (the readiness check lists it; the app's per-address limits do
75 not require it).
76- **Packages** — `openvibe-contracts`, `openvibe-sdk`, `openvibe-shared` (pinned to the same versions as
77 OpenVibe.Help).
78
79## API
80
81Everything is JSON under `/api/v1`. A person is the `today_at` session cookie or `Authorization: Bearer <Network user
82token>`, verified offline with the Network JWKS. A write made with the session cookie must come from
83`https://openvibe.today` (403 `today.cross_site`).
84
85| Route | What it does |
86|---|---|
87| `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` |
88| `GET /events/:id` | one event; a cancelled one answers `status: "cancelled"`; a followers-only one is 404 unless the caller owns it |
89| `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 |
90| `PATCH /events/:id` | owner; title, description, starts_at, ends_at, tz, visibility; `seq` + 1; `today.event.updated`; refused once cancelled or done |
91| `DELETE /events/:id` | owner; status `cancelled`, `seq` + 1, `today.event.cancelled`, reminders dropped; idempotent (twice is 200) |
92| `POST /events/:id/remind` | signed in; `{ minutes_before?: 0–1440 }` (default 10); upsert |
93| `DELETE /events/:id/remind` | remove it (idempotent) |
94| `GET /me/reminders` | the caller's upcoming reminders |
95| `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 |
96| `GET /me/feed` | `{ url: "https://openvibe.today/ics/<token>.ics" }`, creating the token on first call |
97| `POST /me/feed/rotate` | replace the token; the old URL is 404 at once |
98| `GET /me/brief` | today's brief, generated on demand when missing (`?tz=` optional IANA zone) |
99
100Validation refusals are `400 { code: "today.invalid", field, message }`. Other codes:
101`today.too_many`, `today.cross_site`, `today.locked`, `today.not_found`, `today.sign_in_required`.
102
103### Validation
104
105- title 1–120 characters; description ≤ 1000
106- `starts_at` an ISO time no earlier than 5 minutes ago and no later than 366 days ahead
107- `ends_at` after `starts_at` and within 24 hours of it
108- `tz` a valid IANA zone (checked with `Intl`)
109- `kind` `stream` or `event`; `visibility` `public` or `followers`
110
111## ICS
112
113`GET /ics/<token>.ics` is the token owner's agenda (60 days ahead, 7 back), `private, max-age=300`.
114`GET /ics/@<username>.ics` is a channel's public events, `public, max-age=300`.
115
116The calendars follow RFC 5545: CRLF line endings, lines folded at 75 octets without splitting a UTF-8 sequence, TEXT
117escaping (`\\`, `\;`, `\,`, newline as `\n`), `PRODID:-//OpenVibe//Today//EN`, `VERSION:2.0`, `CALSCALE:GREGORIAN`,
118`X-WR-CALNAME`, and a VEVENT per event with `UID:<id>@openvibe.today`, `DTSTAMP`, UTC `DTSTART`/`DTEND` (no end means
119one hour), `SUMMARY`, `DESCRIPTION`, `URL`, `SEQUENCE`, `STATUS:CONFIRMED|CANCELLED` and `LAST-MODIFIED`. A cancelled
120event is emitted with `STATUS:CANCELLED` for 7 days so calendars remove it, then dropped.
121
122A token feed carries the owner's own and reminded events. When this process has read the person's follows (visiting
123`/me` or `/api/v1/me/feed` warms the 60 s cache), it also carries their followed channels' public or followers-only
124events. The feed request itself carries no person's Network token, so a freshly started process cannot read the follow
125graph for that one URL.
126
127## Events
128
129**Publish.** Every change writes a `today_outbox` row in the same transaction, with the `evt_` id fixed at that
130moment. A job every 5 s claims pending rows with a lease, posts each envelope to OpenVibe.Events `POST /api/v1/events`
131with this service's client-credentials token (audience `openvibe.events`), and records the attempt. A failure backs
132off (5 s doubling to 5 minutes), stores `last_error`, and never crashes the process. Because the id is fixed and Events
133is idempotent on it, a retry or a restart cannot make a second event.
134
135Envelope: `source: "today"`, `subject: { type: "user", id: <owner_subject> }`, `actor` the same person,
136`event_type` `today.event.scheduled|updated|cancelled`, payload
137`{ event_id, kind, channel, title, starts_at, ends_at, tz, status, visibility, url, seq }`.
138
139**Consume.** `POST /internal/events` is loopback only (nginx answers 404 for `/internal`). Deliveries are verified with
140the v2 signature over the raw body and the ±300 s replay window (openvibe-sdk/events `parseDelivery`, `requireV2`).
141`live.stream.started` makes the channel's scheduled event whose start is within 2 hours of now live;
142`live.stream.ended` makes a live event done. The inbound event id is recorded in `today_processed` in the same
143transaction as the change, so a replay does nothing. Account export/deletion events go to openvibe-sdk/account-data
144over every table that holds a subject. `startSubscriptions()` creates the two live and two account subscriptions at
145boot, idempotently, with `events.subscription.manage`.
146
147## Reminders
148
149Every 60 s, due reminders (`remind_at <= now`, `sent_at` null, event scheduled or live) are claimed with
150`UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. The Network
151user id is resolved in batches via `/internal/identity/resolve-batch` (cached), then one notification is pushed:
152type `TODAY_REMINDER`, category `stream` for a stream else `system`, title `"<title> starts in N min"` (or
153`"<title> is live now"`), `service: "today"`, URL `https://openvibe.today/e/<id>`. A failed push releases the claim
154for the next tick.
155
156## The brief
157
158`server/brief.js` builds `{ day, sections: [{ id, title, items: [{ text, url, action }] }] }` from:
159
1601. **Later today** — events in the person's agenda that start today in their zone (action `remind`).
1612. **Your reminders** — today's reminders.
1623. **Yesterday on OpenVibe** — up to three items from OpenVibe.Live's public Rundown API, omitted when it fails.
163
164Sections with no items are left out. The body (the sections) is stored in `today_briefs`, one row per subject per day.
165A daily job at 06:00 UTC builds briefs for subjects active in the last 30 days (a reminder, an own event or a feed);
166v1 does not notify.
167
168## Pages
169
170All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with semantic
171HTML and stable `td-` ids/classes:
172
173| Page | What it is |
174|---|---|
175| `/` | 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 |
176| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` |
177| `/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`) |
178| `/me` | agenda, reminders, the calendars you imported (a form and a list, with a Remove button), the feed URL and its rotate form |
179| `/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) |
180| `/brief` | today's brief |
181| `/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`) |
182| `/developers` | the API |
183| 404 | the not-found page |
184
185Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in person is `private, no-store`.
186The 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.
187`robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list the public pages only.
188
189## Run and test
190
191```sh
192npm install
193npm test # PGlite; every test file in its own process, no network needed
194npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
195OV_ALLOW_SUITE=1 npm test
196npm start # PORT 5100, loopback; DATABASE_URL or an embedded PGlite in development
197```
198
199The tests boot the app with a mock Network and Events: events (validation, idempotency, cap, followers, cross-site,
200transactional outbox), ICS (folding, escaping, cancellation window, token rotation), the consumer (signed, replay, bad
201signature), the outbox (fixed id, backoff, restart), reminders (one notification, concurrency, cancelled), agenda
202(follows and Network down), imports (idempotent, time zones, source links, retirement, no private fields), brief
203(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery. The imported calendars
204are tested on their own: reading a calendar (a zone, an all-day event, a weekly rule, an excluded day, a cancelled
205event, a clock change), refusing a private address and a body that is too big, the week that shows them, the five
206addresses a person may keep, and a form post from another site refused. The public holidays are tested on their
207own too: the rules for several years (Thanksgiving, Memorial Day, a July 4 on a weekend, Easter Monday, the August
208bank holiday, Pfingstmontag, a fixed Japanese holiday, Carnaval), the country a zone says, the `?country=` and the
209cookie, an unknown code ignored, and the week with the holiday at the top of its day.
210
211## Imports
212
213`server/imports/` fills the public calendar from the services around the network: the streams on now on Live, public
214Fun plans and accepted IRL quests, Nager.Date holidays per country, and (when `TODAY_IMPORTS_EVENTS_TOPICS` names
215public people-facing topics) public events from OpenVibe.Events. Upcoming streams are not imported: the schedule card
216in Live's studio writes them into this service's own table. The job runs at boot and every
217`TODAY_IMPORTS_INTERVAL_MS`, is off under `TODAY_DRILL`, and `npm run imports:run` runs one pass by hand. Each row
218keeps its `source`/`source_id`, its source URL and its time zone; a second run updates the same row, and a source
219item that disappears retires its row.
220
221## Imported calendars
222
223A person can add up to five calendar addresses on their agenda (`POST /me/calendars`, `POST /me/calendars/:id/remove`):
224`https://…` or the `webcal://` form of one, which becomes `https://`. The address is read through the shared egress
225rule (`openvibe-shared/egress`): https only, a public host only, a name that resolves to a private or link-local
226address refused before a byte is sent and again at connect time, a redirect followed only after the same check, a 10 s
227timeout, at most 2 MB, and only `text/calendar` or `text/plain` is read. Each address is read at most once every
228half hour, and the copy is what the week is drawn from, so an address that cannot be reached for a moment does not
229empty the week.
230
231`server/ical.js` reads RFC 5545 far enough and no further: `SUMMARY`, `DTSTART`/`DTEND` with a `TZID` or an
232all-day `DATE`, `DURATION`, `EXDATE`, `STATUS:CANCELLED`, and a `RRULE` that is `DAILY`, `WEEKLY` with
233`BYDAY` or `MONTHLY` by the day of the month, with `INTERVAL`, `COUNT` and `UNTIL`. A rule with anything else
234in it (`BYMONTH`, `BYSETPOS`, an ordinal `BYDAY` such as `2FR`), a zone this server does not know and an event
235with no `DTSTART` are left out and counted, never guessed at. The address belongs to the person: it is never
236exported and it goes with the account.
237
238## Public holidays
239
240Your week shows the public holidays of a country: an all-day line at the top of its day, with the country code in
241small letters. `server/holidays.js` works them out by rule, with no network call and no dependency: a fixed date
242(1 January), the nth weekday of a month (the fourth Thursday of November is Thanksgiving Day), the last weekday of a
243month (the last Monday of May is Memorial Day) and Easter with the days counted from it (Good Friday is two days
244before Easter, Whit Monday fifty after). A holiday that lands on a weekend is moved the way that country moves it:
245the United States takes the Friday before a Saturday holiday and the Monday after a Sunday one; Britain, Canada,
246Ireland and Australia take the Monday; New Zealand the Monday or the Tuesday.
247
248The country is the one the person asks for: the `?country=` of the week, else the `td_country` cookie, else the one
249their time zone says (`Europe/Berlin` → Germany). The little select on the week page sets the cookie with a plain
250form (`POST /me/week/country`, same-origin, no JavaScript); a code this service does not know is ignored, and a
251zone that says nothing (`UTC`) leaves the person without holidays until they choose.
252
253The lists are the countries' own: the United States' federal holidays, Canada's statutory ones, the England and
254Wales bank holidays, Ireland's public holidays, Australia's national eight, New Zealand's Mondayised ones,
255Germany's, France's, Spain's, Italy's, the Netherlands', Brazil's, Mexico's días de descanso obligatorio, India's
256three gazetted ones and Japan's. Each country keeps the comment that says where its list comes from, and a country
257whose list cannot be stated is left out rather than guessed at.
258
259## Deploy
260
261`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and
262`deploy/nginx/openvibe.today.conf` (TLS, www → apex, sign-in/API/ICS limits, `/auth/me` on the API zone,
263`/ics/` GET only, `/internal` and `/metrics` 404).
264
265## License
266
267AGPL-3.0-only. See [LICENSE](LICENSE).
268
269<!-- versions:start -->
270- openvibe-contracts: v0.419.0
271- openvibe-sdk: v0.161.0
272- openvibe-shared: v4.241.0
273<!-- versions:end -->
274