openvibers/openvibe.fun README.md
README.md
| 1 | # OpenVibe.Fun |
| 2 | |
| 3 | > Something to do, alone, together, or live. |
| 4 | |
| 5 | **Live site:** https://openvibe.fun · **API docs:** https://openvibe.services · **For AI:** https://openvibe.fun/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.fun` · **Port:** 5140 · **Service id:** `fun` · **Env prefix:** `FUN` |
| 10 | **License:** AGPL-3.0-only (the same as every OpenVibe service). |
| 11 | |
| 12 | OpenVibe.Fun is the Fun room. V1 has three things: |
| 13 | |
| 14 | - **IRL quests on Live.** A streamer opens a board for their public Live channel. Viewers suggest safe real-world |
| 15 | challenges, chat votes with `!quest vote <slug>`, the streamer accepts one on stream, marks it live and completed, |
| 16 | and the chat or a moderator verifies it. A badge is paid locally; OpenCoins are loyalty points, never money, and |
| 17 | only when the wallet is configured. |
| 18 | - **The local guide.** "What's fun near me?" — places from OpenStreetMap (Nominatim + Overpass), local events from |
| 19 | Ticketmaster only when a key is configured, a tile-free map, times, links and an accessibility mark. It is |
| 20 | deterministic in v1: no model call, no new AI capability, no paid provider. |
| 21 | - **Group plans.** Two or three options, one link, one vote each, a tie refused, the owner decides, and the winner |
| 22 | can be added to the viewer's own Today with the viewer's own Network token. |
| 23 | |
| 24 | **No social posting.** Fun reads OpenVibe.Chat's public history for votes and reads OpenVibe.Live's public channel |
| 25 | for the streamer/live state. It never posts a chat message, a stream announcement or a social entry. The one |
| 26 | external write is a plan added to *your own* Today. |
| 27 | |
| 28 | ## Owns |
| 29 | |
| 30 | | Table | Holds | Migration | |
| 31 | |---|---|---| |
| 32 | | `fun_quest_boards` | `qbd_` + ULID, streamer subject (nullable after deletion), public Live/Chat username, title, city/point, accessible-only, max suggestions, status, accepted quest, window | `migrations/0001_initial.sql` | |
| 33 | | `fun_quests` | `qst_` + ULID, board, unique slug (`/q/<slug>`), title, brief, category, source, status `suggested\|accepted\|live\|completed\|verified\|rejected\|hidden`, visibility, place, accessible, streamer, revision, reward badge/coins, timestamps | `0001_initial.sql` | |
| 34 | | `fun_quest_votes` | one (`quest`, voter) row; voter is `usr_…` or `chat:<username>`; via web/chat, chat message id | `0001_initial.sql` | |
| 35 | | `fun_quest_checks` | one verification check per voter; chat or moderator, chat message id | `0001_initial.sql` | |
| 36 | | `fun_badges` | one local badge per (`quest`, subject) after verification | `0001_initial.sql` | |
| 37 | | `fun_quest_rewards` | `qrw_` + ULID, the OpenCoin ledger per quest/streamer: pending/credited/skipped/failed, attempts | `0001_initial.sql` | |
| 38 | | `fun_plans` | `pln_` + ULID, slug (`/plans/<slug>`), owner (nullable after deletion), title, city/point, timezone, start/close, status `voting\|decided\|cancelled`, winner, visibility, revision | `0001_initial.sql` | |
| 39 | | `fun_plan_options` | `pop_` + ULID, plan, title/details/place/point, accessible, price note, position 1–10 | `0001_initial.sql` | |
| 40 | | `fun_plan_votes` | one (`plan`, voter) row; option, via web/chat, chat message id | `0001_initial.sql` | |
| 41 | | `fun_reports` | `frp_` + ULID, quest/plan target, reporter, kind `safety\|spam\|other`, reason, status | `0001_initial.sql` | |
| 42 | | `fun_chat_seen`, `fun_chat_cursors` | the read-only chat page's idempotency and cursor | `0001_initial.sql` | |
| 43 | | `fun_geo_cache` | public OSM/Ticketmaster JSON, fetched/expires (never a person, never a secret) | `0001_initial.sql` | |
| 44 | | `fun_outbox` | Search Fabric documents/tombstones (Today/Meme shape) | `0001_initial.sql` | |
| 45 | | `account_data_events` | `openvibe-sdk/account-data` export/deletion receipts | `0001_initial.sql` | |
| 46 | | `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` | |
| 47 | |
| 48 | ## Does not own |
| 49 | |
| 50 | - **Live, Chat and Today** — OpenVibe.Live, OpenVibe.Chat and OpenVibe.Today. Fun uses their public reads and, for |
| 51 | Today, the person's own token on the public event API. It never posts to Live or Chat and never writes to anyone |
| 52 | else's calendar. |
| 53 | - **Identity and roles** — OpenVibe.Network. Fun is OAuth client `fun` (PKCE S256) and verifies session tokens |
| 54 | against the Network JWKS; staff means Network role `global_mod` or above. |
| 55 | - **OpenStreetMap and Ticketmaster** — external public sources, cached and credited; never a paid provider. |
| 56 | - **Search** — OpenVibe.Search receives `search.index-document@1` documents from `fun_outbox`: |
| 57 | `openvibe-publishing/search-relay` delivers them to its owner API with Fun's `search.document.write` grant |
| 58 | (`FUN_SEARCH_URL`; unset, they wait). |
| 59 | - **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host. |
| 60 | |
| 61 | ## Capabilities and grants |
| 62 | |
| 63 | **Inbound, v1: none.** No Fun route requires a service, app or agent capability. A person is the `fun_at` session |
| 64 | cookie or a Network user token; a signed-in write from the cookie must come from `https://openvibe.fun` |
| 65 | (`fun.cross_site`). |
| 66 | |
| 67 | **Outbound, v1:** |
| 68 | |
| 69 | - `events.subscription.manage` — at boot, with `svc:fun`, to create the two account export/deletion subscriptions at |
| 70 | OpenVibe.Events. The delivery itself is verified by the `FUN_EVENTS_SECRET` signature, not a token. |
| 71 | - `network.account.export.contribute` and `network.account.deletion.confirm` — the `createNetworkSender` calls that |
| 72 | push the person's export part and the deletion confirmation to OpenVibe.Network's internal routes. |
| 73 | - `identity.subject.resolve` and `network.coins.credit` — **only when `FUN_COINS=on`**, with `svc:fun`, app id `fun`, |
| 74 | to credit OpenCoins through OpenVibe.Network after a quest is verified. Off by default; the badge is still paid. |
| 75 | |
| 76 | **Not needed in v1:** no Live, Chat, Today, Quest, Media or AI service token. The Today write uses the signed-in |
| 77 | person's own Network token, and the guide reads public OSM/Ticketmaster endpoints. The Search relay uses |
| 78 | `search.document.write` (held); a future streamer-ownership proof could use a Live capability, but v1 checks the Network |
| 79 | username against the public Live channel. |
| 80 | |
| 81 | ## API |
| 82 | |
| 83 | Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `fun_at` |
| 84 | session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network JWKS. A write |
| 85 | made with the session cookie must come from `https://openvibe.fun` (403 `fun.cross_site`). A signed-in account also |
| 86 | gets per-actor minute/hour limits (`FUN_LIMITS_MINUTE`, `FUN_LIMITS_HOUR`). |
| 87 | |
| 88 | | Route | What it does | |
| 89 | |---|---| |
| 90 | | `GET /guide?q=&kind=&accessible=&rainy=&kids=&cheap=&radius=&limit=` | OSM places with attribution/map links, plus Ticketmaster events only when configured; cached | |
| 91 | | `GET /quests?limit=&cursor=&status=&city=` | public IRL quests, newest first, opaque keyset cursor | |
| 92 | | `GET /quests/ideas` | the safe starter deck (curated in code, not model output) | |
| 93 | | `GET /quests/boards/:id` | one board and its suggestions (the share link; noindex) | |
| 94 | | `GET /quests/:slug` | one quest, its vote count, chat-check count and the viewer's vote | |
| 95 | | `POST /quests/boards` | a streamer opens a board; the Network username must match the public Live channel | |
| 96 | | `POST /quests/boards/:id/suggest` | a signed-in suggestion, safety-filtered before it is stored | |
| 97 | | `POST /quests/boards/:id/idea` | owner/staff adds one unused safe idea from the deck | |
| 98 | | `POST /quests/:slug/vote` | one vote per person; a repeat is idempotent | |
| 99 | | `POST /quests/:slug/accept\|live\|complete\|verify` | the streamer (or staff) moves the quest on; `verify` takes `{ via: "chat"\|"moderator" }` | |
| 100 | | `POST /quests/:slug/hide` | staff hides a public quest and writes a Search tombstone | |
| 101 | | `POST /quests/:slug/report` | signed-in safety/spam/other report | |
| 102 | | `GET /plans?limit=&cursor=&city=` | public group plans | |
| 103 | | `GET /plans/:slug` | one plan with options, vote counts and the winner | |
| 104 | | `POST /plans` | start a plan: title, start, timezone, 1–10 options | |
| 105 | | `POST /plans/:slug/options\|vote\|decide\|today` | add an option, cast one vote, choose the winner (tie refused), add the winner to your own Today | |
| 106 | | `POST /plans/:slug/hide` | staff hides a plan and writes a Search tombstone | |
| 107 | | `POST /plans/:slug/report` | signed-in report | |
| 108 | | `GET /reports` | staff: open reports | |
| 109 | |
| 110 | Validation refusals are `400 { code: "fun.invalid" }` (or `fun.quest.invalid`, `fun.plan.invalid`, |
| 111 | `fun.report.invalid`). Other codes: `fun.sign_in_required`, `fun.person_required`, `fun.staff_required`, |
| 112 | `fun.streamer_required`, `fun.streamer_username`, `fun.cross_site`, `fun.not_found`, `fun.quest_not_found`, |
| 113 | `fun.plan_not_found`, `fun.board_not_found`, `fun.board_closed`, `fun.board_full`, `fun.quest_closed`, |
| 114 | `fun.quest_state`, `fun.quest_quorum`, `fun.plan_owner`, `fun.plan_closed`, `fun.plan_tie`, |
| 115 | `fun.plan_no_votes`, `fun.plan_not_decided`, `fun.rate_limited`, `fun.guide.invalid`, `fun.guide.not_found`, |
| 116 | `fun.guide.upstream`, `fun.channel_not_found`, `fun.live.unavailable`, `fun.today.*`. |
| 117 | |
| 118 | ## IRL quests and safety |
| 119 | |
| 120 | The state machine is `suggested → accepted → live → completed → verified`, with `rejected` for the suggestions the |
| 121 | streamer did not choose and `hidden` for a staff takedown. Before any suggestion is stored, `server/quests/safety.js` |
| 122 | checks it against a small, explainable list of dangerous, illegal, trespassing, harassment and adult patterns. The |
| 123 | plan's own playful example ("get a stranger to beatbox") is allowed; "climb a bridge and jump off" is not. The |
| 124 | streamer can make a board accessible-only and cap the number of suggestions. The public page carries a report form; |
| 125 | staff see the open reports and can hide the quest. |
| 126 | |
| 127 | Chat voting is read-only. A timer reads one page of the public `GET /api/chat/global/history?username=<channel>` and |
| 128 | applies at most one command per message id (`fun_chat_seen`): |
| 129 | |
| 130 | - `!quest vote <slug>` |
| 131 | - `!quest suggest <text>` (safety-filtered; stored unlisted) |
| 132 | - `!quest verify <slug>` (after the streamer marks it completed) |
| 133 | - `!plan vote <slug> <number>` |
| 134 | |
| 135 | No message text beyond a bounded command, no account and no token is stored; a chat voter is a one-way key of the public username, not a copy of it. A re-read of the same public page |
| 136 | changes nothing. Under `FUN_DRILL` the timer is not started; tests call `runOnce()`. |
| 137 | |
| 138 | Verification by `via: "chat"` needs `FUN_QUEST_VERIFY_VOTES` distinct chat checks (default 3); a Network moderator |
| 139 | can verify directly with `via: "moderator"`. Verification always writes a local `fun_badges` row; it writes a |
| 140 | `fun_quest_rewards` row for OpenCoins and settles it through the optional wallet adapter. |
| 141 | |
| 142 | ## The local guide |
| 143 | |
| 144 | `server/guide/upstream.js` is the only outbound host list: Nominatim, Overpass and Ticketmaster. It follows their |
| 145 | usage policies: |
| 146 | |
| 147 | - **Identify:** every request carries `FUN_OSM_USER_AGENT` (an `OpenVibeFun/0.1 (+https://openvibe.fun; …)` contact |
| 148 | string). A browser User-Agent is never used. |
| 149 | - **Pace:** `createPacer` serialises calls per host, at least `FUN_OSM_MIN_INTERVAL_MS` (1000 ms) apart; Ticketmaster |
| 150 | has its own interval. |
| 151 | - **Cache:** every answer is in `fun_geo_cache` for `FUN_GEO_CACHE_TTL_MS` (7 days OSM, 15 minutes Ticketmaster); the |
| 152 | last answer is served stale if the host is down. |
| 153 | - **Bound:** one `around:<radius>,<lat>,<lon>` query with a small set of tags, `out center tags`, never a bulk |
| 154 | download. The radius is capped by `FUN_GUIDE_MAX_RADIUS_KM`. |
| 155 | - **Credit:** every API answer and page shows © OpenStreetMap contributors, ODbL 1.0, and links back. |
| 156 | |
| 157 | Ticketmaster is **off by default**. It is read only when `TICKETMASTER_API_KEY` is set; the key is a query parameter |
| 158 | at the edge, is never in the cache key, is never returned and is never logged. Events show name, venue, local times |
| 159 | and a public link; Fun never sells a ticket, takes a payment or calls a paid provider. |
| 160 | |
| 161 | The guide is deterministic: `kind` (`all`, `outside`, `inside`, `arts`, `food`) plus `accessible`, `rainy`, `kids` |
| 162 | and `cheap` filter the same OSM answer. There is no model call and no new AI capability. |
| 163 | |
| 164 | ## Group plans and Today |
| 165 | |
| 166 | A plan has 1–10 options and one vote per person (or public chat username); a vote can change until `closes_at`. |
| 167 | The owner or staff decides; a tie is `409 fun.plan_tie`. A decided plan's winner can be added to the viewer's own |
| 168 | Today: `POST /plans/:slug/today` calls Today's public `POST /api/v1/events` with the viewer's Network token and |
| 169 | `Idempotency-Key: fun-plan-<plan id>`. Fun never fans out to other people's calendars; each person adds it to |
| 170 | their own Today. A hidden plan is 404 to the public. |
| 171 | |
| 172 | ## Pages |
| 173 | |
| 174 | All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with |
| 175 | semantic HTML and stable `fu-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn |
| 176 | for a signed-in person is `private, no-store`; a board share page is `no-store`. |
| 177 | |
| 178 | | Page | What it is | |
| 179 | |---|---| |
| 180 | | `/` | the room, featured quests, plans, the guide teaser | |
| 181 | | `/guide` | the guide form and its OSM/Ticketmaster answer | |
| 182 | | `/quests` | public IRL quests and the safe idea deck | |
| 183 | | `/quests/new` | open a board (streamer) | |
| 184 | | `/quests/boards/:id` | one board, suggestions, vote and accept forms | |
| 185 | | `/q/:slug` | one quest, votes/checks, streamer actions, report form | |
| 186 | | `/live` | a streamer's boards and quests | |
| 187 | | `/plans`, `/plans/new` | public plans and the form | |
| 188 | | `/plans/:slug` | options, votes, decide, add to Today | |
| 189 | | `/developers` | the API and Search document | |
| 190 | | `/overlay/q/:slug` | a minimal OBS browser source, meta-refresh, noindex | |
| 191 | | 404 | the not-found page | |
| 192 | |
| 193 | `public/css/fun.css` and `public/js/fun.js` are one-comment placeholder files: the pages are complete without them |
| 194 | and the main session designs them. `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list the public pages |
| 195 | and every public quest/plan. |
| 196 | |
| 197 | ## Search |
| 198 | |
| 199 | Each public quest is one `search.index-document@1` (owner `fun`, type `quest`, page `/q/<slug>`); each public plan is |
| 200 | type `plan` (`/plans/<slug>`). The document carries the public title/brief/body, facets (`status`, `category`, |
| 201 | `city`, `accessible`, `streamer`, `source`), human authorship and a Live channel provenance reference for a quest. |
| 202 | Publishing, accepting, moving or deciding writes/updates the document in the same transaction; hiding or |
| 203 | cancelling writes a tombstone. Rows live in `fun_outbox`; the relay is deliberately not in v1 and needs the |
| 204 | `search.document.write` grant. |
| 205 | |
| 206 | ## The first proof (covered by the tests) |
| 207 | |
| 208 | `test/chat-sync.test.js` and `test/quests.test.js` cover the plan's first proof end to end: a chat message suggests |
| 209 | a quest, a public chat vote counts, the streamer accepts it on their board, marks it live and completed, three |
| 210 | distinct chat checks (or a moderator) verify it, and a badge is paid. `test/plans.test.js` covers the second proof: |
| 211 | a group votes, the winner is decided and goes to the viewer's own Today. |
| 212 | |
| 213 | ## Run and test |
| 214 | |
| 215 | ```sh |
| 216 | npm install |
| 217 | npm test # PGlite; every test file in its own process, no network needed |
| 218 | npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up) |
| 219 | OV_ALLOW_SUITE=1 npm test |
| 220 | npm start # PORT 5140, loopback; DATABASE_URL or an embedded PGlite in development |
| 221 | ``` |
| 222 | |
| 223 | The tests boot the app with a mock Network, a Live/Chat/Today/OSM/Ticketmaster fixture server, and a temporary |
| 224 | database: the migration, the guide's cache/pacer/User-Agent and Ticketmaster off-by-default, quest safety and the |
| 225 | full state machine, chat suggestion/vote/verify idempotency, plan voting/tie/Today, Search document validation, |
| 226 | SSO, pages/cache, coins opt-in, and account export/deletion. |
| 227 | |
| 228 | ## Deploy |
| 229 | |
| 230 | `deploy/systemd/openvibe-fun.service` (127.0.0.1:5140, `/etc/openvibe/fun.env`) and |
| 231 | `deploy/nginx/openvibe.fun.conf` (TLS, www → apex, `/auth/me` on the API zone, a forms zone for the POST routes, |
| 232 | `/internal` and `/metrics` 404). Not deployed by this change. |
| 233 | |
| 234 | ## License |
| 235 | |
| 236 | AGPL-3.0-only. See [LICENSE](LICENSE). |
| 237 | |
| 238 | <!-- versions:start --> |
| 239 | - openvibe-contracts: v0.419.0 |
| 240 | - openvibe-sdk: v0.161.0 |
| 241 | - openvibe-shared: v4.241.0 |
| 242 | - openvibe-publishing: v1.7.0 |
| 243 | <!-- versions:end --> |
| 244 |