openvibers/openvibe.sdk Commits

0.64.0: parseDelivery requires v2; v1 signatures deprecated; each 1.0 core name has a tier; a JWKS client with no key retries by itself (#77)

OP OpenVibers committed 19 days ago · 07a3cd0b3c · 27 files, +395 −43

* 0.64.0: parseDelivery requires v2 by default; v1 signatures deprecated; each name of the five 1.0 core entries has a tier (frozen, platform, leaving)

* A JWKS client with no key retries by itself on its backoff, so a service that started before Network recovers without waiting for a sign-in

* no-looks knows nudity words (naked, half-naked, nude, undressed): hasLooks rejects such titles, noLooks repairs such sentences

* hasLooks skips character names (Iron Man, Pac-Man, Spider-Man), as noLooks already does

* no-looks gains isExplicit(...texts): AI text that says a scene shows sexual content is not published at all (the looks rules only remove an adjective)

* no-looks knows clothes below the waist after "in" ("The streamer in shorts kneels…" survived Community's repairs); a shop word or a pocket keeps the phrase

* openvibe-sdk/front-page: frontPageOk and containsCoreSlur, what may lead a front page (Chat's built-in slur categories, strong profanity in five languages, hate topics)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8ac2124..542d826 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,45 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.64.0 (2026-10-25)
++
++- **`parseDelivery` requires a v2 signature by default.** A delivery without `X-OpenVibe-Signature-V2` is refused
++ unless the caller passes `{ requireV2: false }`, which is deprecated. A v1 signature (`X-OpenVibe-Signature`) signs no
++ time, so a captured delivery verified forever. OpenVibe.Events sends only v2, and every production receiver
++ already passed `requireV2: true`, so nothing that runs changes. A test that signs with `signDelivery` and parses
++ without options now gets `null`: sign with `signDeliveryHeaders` instead.
++- **`signDelivery` and `verifyDelivery` are deprecated** and leave in 1.0, with `createCodeVerifier` and
++ `pkceChallenge` (the halves of `createPkcePair`, unused anywhere).
++- **What 1.0 freezes, name by name.** [docs/one-point-oh.md](docs/one-point-oh.md) puts each name of the five core
++ entries in a tier (frozen, platform or leaving), with the reasons and how many repositories use it.
++ `docs/one-point-oh.json` holds the lists. `test/surface.test.js` fails when a name there has no tier, and
++ `scripts/surface.js --check` marks a breaking change with its tier.
++- `types/events.d.ts`: `signDeliveryHeaders` returns the timestamp and the v2 header. The type had also listed a v1
++ header that the function never set.
++- **no-looks knows nudity words** (`naked`, `half-naked`, `nude`, `undressed` and their variants), and `hasLooks` skips
++ character names (Iron Man, Pac-Man, Spider-Man), as `noLooks` already did. Live's AI moments
++ job titled a clip "A Naked Man Lying On Their Back With Legs" on Live's home (2026-10-24), and a Community paste
++ read "Noteworthy Half-Naked Moment". `hasLooks` now rejects both, and `noLooks` repairs sentences as it does for
++ "shirtless". `RULES_VERSION` changes, so services that repair stored rows run their repair again.
++- **no-looks knows clothes below the waist after "in"** (`shorts`, `jeans`, `pants`, `trousers`, `leggings`, `skirts`,
++ `sweatpants`, `swimwear` and the like): "The streamer in shorts kneels on grass…" had survived every one of
++ Community's repairs since April 2026. A shop word after it ("in the pants department") or a pocket keeps the phrase.
++ Team kits and uniforms are not touched.
++- **`openvibe-sdk/front-page`** (new): `frontPageOk(text)` says whether a title or line may lead a front page (no
++ built-in slur category, no strong profanity in five languages, no hate topic), and `containsCoreSlur(text)` is the
++ slur check alone. The categories are OpenVibe.Chat's built-in ones, ported unchanged. An anonymous screenshot titled
++ with a slur sat in OpenVibe.Community's "Most viewed" (2026-10-24), and Live had its own copy of the rule for its
++ quote strip; front pages now share one.
++- **`no-looks` gains `isExplicit(...texts)`**: true when AI text says a scene shows sexual content (body parts, sex
++ acts, pornography, nudity). The looks rules only remove an adjective ("a man … reaching toward their genitals"), so a
++ service asks before it publishes AI text at all. Live's AI auto-clip 483 went public on openvibe.media with such a
++ description on 2026-10-23. Live#344 keeps its own copy of the pattern until it pins this release. `RULES_VERSION`
++ includes it.
++- **A JWKS client with no key retries by itself** on its backoff (1 s up to 5 min) until a fetch succeeds, instead of
++ only when a token next needs checking. On 2026-10-23, Trade and the Tools gateway started before Network after the
++ reboot and reported "no key" on `/status` for six hours, until a restart; their first sign-in would have waited for
++ the fetch.
++
## 0.63.0 (2026-10-24)
- **The public surface is pinned.** `docs/api-surface.json` lists every name each of the 66 entries exports and what it
ddiff --git a/README.md b/README.md
iindex e59c258..719a7a3 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.63.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.64.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.63.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.64.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -199,6 +199,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/space` | server | `createSpaceClient(client)`: OpenVibe.Space, static sites for apps and CI. `deploys.uploadArchive(site, tarGz, {activate, preview, root})`, `uploadFiles(site, [{path, data}], …)`, `uploadSourceBuild(site, archive, {ref, commit_sha})`, `list/get/log/activate/rollback(site, {deploy_id})/remove`; `projects.list/create/get/remove/quota/sites/members.set(p, principal, role)/remove`; `sites.create/get/remove`, `sites.config.get/set/reset` (`{ headers, redirects, spa }`), `sites.source.get/set/remove`; `domains.list/add/verify/remove`. An app reaches only the projects a person added it to (deployer, maintainer or owner). Public capabilities since openvibe-contracts 0.209.0. Audience `openvibe.space` |
| `openvibe-sdk/host` | server | `createHostClient(client)`: OpenVibe.Host's public hosting catalog, sent without a token. `providers.list/get(slug)`, `plans.list({kind, min_vcpu, min_ram_mb, min_disk_gb, max_price, currency, location, provider, sort, cursor})/get(id)`, `promos.list`, `offers.list({sort, currency, max_monthly, source, priced, cursor})`, `domains.list/tld('dev')/check('example.dev')`, `benchmarks.list/get`. Any other filter is refused (422). Editing the catalog is first-party |
| `openvibe-sdk/no-looks` | any | Public AI text never describes a person: `noLooks(text)` removes looks, body, clothing, hair, face, pose, age and guessed gender ("a shirtless man sits" → "the streamer sits", "two men" → "two people"; quoted speech and names stay), `noLooksTags(tags)` drops tags that name them, `hasLooks(title)` says a title should be replaced, and `RULES_VERSION` gates a one-time repair of stored rows. Every service that stores or serves public AI text runs it |
++| `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page |
| `openvibe-sdk/news` | server | `createNewsClient(client)`: OpenVibe.News' published stories, sent without a token. `topics()`, `stories.list({topic, limit, offset})` (published and retracted, newest first) and `stories.get(id)` (sources, timeline, perspectives). Writing and the editorial reads stay first-party |
| `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` |
| `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` |
@@@ -477,7 +478,7 @@ Install the drivers the service uses: `pg` and `iovalkey` (production), `@electr
## Versioning
- `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag.
--- [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …). `test/surface.test.js` fails when the exports and the file disagree, so a removal or a change of kind is a reviewed line in a diff, never a surprise on a service's next pin round; `node scripts/surface.js --check` names each change as breaking or added, and `node scripts/surface.js` updates the file. 1.0 freezes `core`, `auth`, `events`, `db` and `media` first: a breaking change there is marked `[1.0 core]`.
++- [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …). `test/surface.test.js` fails when the exports and the file disagree, so a removal or a change of kind is a reviewed line in a diff, never a surprise on a service's next pin round; `node scripts/surface.js --check` names each change as breaking or added, and `node scripts/surface.js` updates the file. 1.0 freezes `core`, `auth`, `events`, `db` and `media` first: a breaking change there is marked `[1.0 core]`. Inside them each name is frozen, platform (exported, not frozen) or leaving before 1.0: [docs/one-point-oh.md](docs/one-point-oh.md).
- Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release, the `openvibe-contracts` devDependency (a release tarball pin, v0.86.0), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency.
- A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed.
ddiff --git a/STATUS.json b/STATUS.json
iindex 1a5f921..fc802a5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.63.0",
++ "version": "0.64.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d5d2e3b..4cd0eb8 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.63.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.64.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.63.0';
++const SDK_VERSION = '0.64.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 5271b95..5a6b93b 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -473,6 +473,10 @@
"mountFrame": "function",
"scriptUrls": "function"
},
++ "openvibe-sdk/front-page": {
++ "containsCoreSlur": "function",
++ "frontPageOk": "function"
++ },
"openvibe-sdk/geo": {
"createGeoClient": "function"
},
@@@ -532,6 +536,7 @@
"openvibe-sdk/no-looks": {
"RULES_VERSION": "string",
"hasLooks": "function",
++ "isExplicit": "function",
"noLooks": "function",
"noLooksTags": "function"
},
ddiff --git a/docs/one-point-oh.json b/docs/one-point-oh.json
nnew file mode 100644
iindex 0000000..5a4115e
---- /dev/null
++++ b/docs/one-point-oh.json
@@@ -0,0 +1,27 @@
++{
++ "openvibe-sdk/core": {
++ "frozen": ["CONTRACTS_RANGE", "DEFAULT_NETWORK", "OpenVibeError", "SDK_VERSION", "compareVersions", "createClient", "isOpenVibeError", "newIdempotencyKey", "offsetPager", "paginate", "satisfiesRange", "ulid"],
++ "platform": ["contextFromHeaders", "isActingSubjectId", "newEventId", "parseTraceparent", "startSpan"],
++ "leaving": []
++ },
++ "openvibe-sdk/auth": {
++ "frozen": ["buildAuthorizeUrl", "createPkcePair", "createServiceTokenClient", "createState", "decodeUnverified", "exchangeCode", "jwksClient", "jwksStatus", "readCallback", "refreshUserToken", "startAuthorization", "unverifiedClaims", "verifyAppToken", "verifyServiceToken", "verifyUserToken"],
++ "platform": ["TOKEN_VALID_AFTER", "base64url", "createJwksClient", "createNetworkKeys", "createPgRevocationStore", "revocationSchema"],
++ "leaving": ["createCodeVerifier", "pkceChallenge"]
++ },
++ "openvibe-sdk/events": {
++ "frozen": ["appSource", "createAppEvents", "createEventsClient", "parseDelivery", "projectKey", "signDeliveryHeaders", "signDeliveryV2", "verifyDeliveryV2"],
++ "platform": ["createPgInbox", "createPgOutbox", "createServiceOutbox", "inboxSchema", "outboxSchema"],
++ "leaving": ["signDelivery", "verifyDelivery"]
++ },
++ "openvibe-sdk/db": {
++ "frozen": ["DbError", "ISOLATION", "PARSERS", "createDb", "sql"],
++ "platform": [],
++ "leaving": []
++ },
++ "openvibe-sdk/media": {
++ "frozen": ["DEFAULT_PUBLIC_ORIGIN", "createMediaClient", "createObjectsClient", "mediaUrls"],
++ "platform": [],
++ "leaving": []
++ }
++}
ddiff --git a/docs/one-point-oh.md b/docs/one-point-oh.md
nnew file mode 100644
iindex 0000000..68865f0
---- /dev/null
++++ b/docs/one-point-oh.md
@@@ -0,0 +1,68 @@
++# What 1.0 freezes
++
++1.0 freezes five entries first: `core`, `auth`, `events`, `db` and `media` ([README, "Versioning"](../README.md#versioning)).
++Inside them, a name is in one of three tiers. [one-point-oh.json](one-point-oh.json) holds the lists, and
++`test/surface.test.js` fails when a name in those entries has no tier. `node scripts/surface.js --check` marks a
++breaking change with the tier of the name it breaks.
++
++| Tier | Meaning |
++|---|---|
++| **frozen** | Semver from 1.0: removing it or changing its kind or shape needs a major release. This is what an app or a service written outside OpenVibe builds on: signing in, verifying tokens, calling the API, events in and out, PostgreSQL, media. |
++| **platform** | Exported, and OpenVibe's own services use it, but not frozen: its shape follows the platform, such as storage schemas, trace propagation and key handling a service needs only inside OpenVibe. It may change in a minor release, and CHANGELOG.md says so. |
++| **leaving** | Deprecated now and gone in 1.0. Each has a replacement already in the frozen tier. |
++
++Usage is counted from every OpenVibers repository's `main` on 2026-10-23 (repositories other than the SDK that
++import the entry and name the export). A count of 0 does not mean leaving: much of the frozen tier exists for
++developers outside the organisation, who are not in the count.
++
++## core
++
++- **frozen:**
++ - `createClient` (21 repositories);
++ - `OpenVibeError` (4) and `isOpenVibeError` (2);
++ - `paginate` and `offsetPager` (list pages for app code);
++ - `ulid`, `newIdempotencyKey`, `compareVersions`, `satisfiesRange`;
++ - `SDK_VERSION`, `DEFAULT_NETWORK`, `CONTRACTS_RANGE`.
++- **platform:**
++ - `startSpan`, `parseTraceparent` and `contextFromHeaders`: the platform's trace propagation, with 0 users outside the SDK. Its header format may still follow OpenTelemetry changes.
++ - `isActingSubjectId`: identity internals.
++ - `newEventId`: producers inside the platform. Apps publish through `createAppEvents`, which makes the id.
++
++## auth
++
++- **frozen:**
++ - sign-in for an app: `buildAuthorizeUrl`, `startAuthorization`, `readCallback`, `exchangeCode`, `refreshUserToken`, `createPkcePair`, `createState`;
++ - verification on a server: `verifyUserToken` (31), `verifyServiceToken` (36), `verifyAppToken` (13), `jwksClient` (23), `jwksStatus` (17);
++ - `createServiceTokenClient` (22);
++ - display only: `decodeUnverified`, `unverifiedClaims`.
++- **platform:**
++ - `createPgRevocationStore`, `revocationSchema` and `TOKEN_VALID_AFTER`: storage for Network's revocation events, on the platform's schema;
++ - `createNetworkKeys` (12): service key handling;
++ - `createJwksClient`: the uncached constructor behind `jwksClient`, which is what code should call;
++ - `base64url`: an encoding helper, not an auth API.
++- **leaving:** `createCodeVerifier` and `pkceChallenge`. They are the two halves of `createPkcePair`, and no repository uses them.
++
++## events
++
++- **frozen:**
++ - `createEventsClient` (11), `createAppEvents`, `appSource`, `projectKey`;
++ - receiving: `parseDelivery` (15), `verifyDeliveryV2`;
++ - signing in tests and senders: `signDeliveryHeaders`, `signDeliveryV2`.
++- **platform:** `createPgOutbox`, `createServiceOutbox`, `createPgInbox`, `outboxSchema`, `inboxSchema`. They are the PostgreSQL outbox and inbox a service keeps beside its own tables. Their schema and relay behaviour change with the platform; 0.63.0 added a 429 pause and pruning.
++- **leaving:** `signDelivery` and `verifyDelivery`, the v1 signature.
++ - v1 signs no time, so a captured delivery verifies forever.
++ - OpenVibe.Events sends only v2 (`X-OpenVibe-Signature-V2`, over the timestamp and the body, within five minutes).
++ - Every production receiver already calls `parseDelivery(..., { requireV2: true })`.
++ - From 0.64.0, `parseDelivery` requires v2 by default. `{ requireV2: false }` still accepts v1, deprecated.
++
++## db and media
++
++All frozen:
++- `createDb` (42), `sql` (43), `DbError`, `ISOLATION`, `PARSERS`;
++- `createMediaClient`, `createObjectsClient`, `mediaUrls`, `DEFAULT_PUBLIC_ORIGIN`.
++
++## Before 1.0
++
++1. The leaving names go: remove them, update `docs/api-surface.json`, and the test lists them as breaking.
++2. Publish to npm (owner's account), so that `npm i openvibe-sdk` works without a tarball pin.
++3. From then on, a breaking change marked `[1.0 core]` needs a major release.
ddiff --git a/esm/front-page.mjs b/esm/front-page.mjs
nnew file mode 100644
iindex 0000000..2525538
---- /dev/null
++++ b/esm/front-page.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/front-page.js. Do not edit.
++import cjs from '../src/front-page.js';
++
++export const {
++ frontPageOk, containsCoreSlur,
++} = cjs;
++export default cjs;
ddiff --git a/esm/no-looks.mjs b/esm/no-looks.mjs
iindex 22dd7be..b3f2a77 100644
---- a/esm/no-looks.mjs
++++ b/esm/no-looks.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/no-looks.js';
export const {
-- noLooks, noLooksTags, hasLooks, RULES_VERSION,
++ noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex a392b78..a22fc30 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.63.0",
++ "version": "0.64.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.63.0",
++ "version": "0.64.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 206110b..f502af6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.63.0",
++ "version": "0.64.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -367,6 +367,11 @@
"import": "./esm/no-looks.mjs",
"default": "./src/no-looks.js"
},
++ "./front-page": {
++ "types": "./types/front-page.d.ts",
++ "import": "./esm/front-page.mjs",
++ "default": "./src/front-page.js"
++ },
"./vip": {
"types": "./types/vip.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 2be5bc2..4e2fc37 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -68,6 +68,7 @@ const ENTRIES = {
'host.mjs': '../src/host.js',
'news.mjs': '../src/news.js',
'no-looks.mjs': '../src/no-looks.js',
++ 'front-page.mjs': '../src/front-page.js',
'food.mjs': '../src/food.js',
'actor.mjs': '../src/actor.js',
'run.mjs': '../src/run.js',
ddiff --git a/scripts/surface.js b/scripts/surface.js
iindex 58c8b50..ad53ab9 100644
---- a/scripts/surface.js
++++ b/scripts/surface.js
@@@ -22,6 +22,10 @@ const ROOT = path.join(__dirname, '..');
const FILE = path.join(ROOT, 'docs', 'api-surface.json');
// What the 1.0 freeze covers first (README, "Versioning"): a change there is marked on its own.
const CORE = ['openvibe-sdk/core', 'openvibe-sdk/auth', 'openvibe-sdk/events', 'openvibe-sdk/db', 'openvibe-sdk/media'];
++// Inside those entries, each name's tier (docs/one-point-oh.md): frozen in 1.0, platform (exported, not frozen) or
++// leaving before 1.0. test/surface.test.js fails when a name there has no tier.
++const TIERS_FILE = path.join(ROOT, 'docs', 'one-point-oh.json');
++const TIERS = JSON.parse(fs.readFileSync(TIERS_FILE, 'utf8'));
function kindOf(v) {
if (typeof v === 'function') return /^class[\s{]/.test(Function.prototype.toString.call(v)) ? 'class' : 'function';
@@@ -66,7 +70,13 @@ const kindName = (k) => (typeof k === 'string' ? k : 'object');
function diff(before, after) {
const breaking = [];
const added = [];
-- const tag = (spec) => (CORE.includes(spec) ? ' [1.0 core]' : '');
++ const tag = (spec, name) => {
++ if (!CORE.includes(spec)) return '';
++ const t = (name && TIERS[spec]) || {};
++ if ((t.platform || []).includes(name)) return ' [1.0 core entry, platform tier]';
++ if ((t.leaving || []).includes(name)) return ' [1.0 core entry, leaves before 1.0]';
++ return ' [1.0 core]';
++ };
for (const spec of Object.keys(before)) {
if (!(spec in after)) { breaking.push(`${spec}: the entry is gone${tag(spec)}`); continue; }
const was = before[spec];
@@@ -74,10 +84,10 @@ function diff(before, after) {
for (const name of Object.keys(was)) {
const a = was[name];
const b = now[name];
-- if (b === undefined) breaking.push(`${spec}: ${name} is gone${tag(spec)}`);
-- else if (a !== 'lazy' && b !== 'lazy' && kindName(a) !== kindName(b)) breaking.push(`${spec}: ${name} was a ${kindName(a)}, now a ${kindName(b)}${tag(spec)}`);
++ if (b === undefined) breaking.push(`${spec}: ${name} is gone${tag(spec, name)}`);
++ else if (a !== 'lazy' && b !== 'lazy' && kindName(a) !== kindName(b)) breaking.push(`${spec}: ${name} was a ${kindName(a)}, now a ${kindName(b)}${tag(spec, name)}`);
else if (typeof a !== 'string') {
-- for (const k of a.object) if (!b.object.includes(k)) breaking.push(`${spec}: ${name}.${k} is gone${tag(spec)}`);
++ for (const k of a.object) if (!b.object.includes(k)) breaking.push(`${spec}: ${name}.${k} is gone${tag(spec, name)}`);
for (const k of b.object) if (!a.object.includes(k)) added.push(`${spec}: ${name}.${k}`);
}
}
@@@ -109,4 +119,4 @@ function main(argv = process.argv.slice(2)) {
if (require.main === module) process.exitCode = main();
--module.exports = { current, describe, diff, render, CORE, FILE };
++module.exports = { current, describe, diff, render, CORE, TIERS, FILE };
ddiff --git a/src/auth/jwks.js b/src/auth/jwks.js
iindex 8ce3614..1258dee 100644
---- a/src/auth/jwks.js
++++ b/src/auth/jwks.js
@@@ -39,7 +39,7 @@ function keysFromJwks(doc) {
}
function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null, ttlMs = TTL_MS, minRefetchMs = MIN_REFETCH_MS, timeoutMs = 5000, now = () => Date.now() } = {}) {
-- const st = { keys: null, fetchedAt: 0, attemptAt: 0, kidAt: 0, failures: 0, nextTryAt: 0, lastError: null, inflight: null, timer: null };
++ const st = { keys: null, fetchedAt: 0, attemptAt: 0, kidAt: 0, failures: 0, nextTryAt: 0, lastError: null, inflight: null, timer: null, retry: null };
const warn = (msg) => { try { (log && (log.warn || log.error) || console.warn).call(log || console, msg); } catch { /* never throw from logging */ } };
const info = (msg) => { try { (log && (log.info || log.log) || (() => {})).call(log || console, msg); } catch { /* */ } };
@@@ -61,6 +61,13 @@ function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null
st.nextTryAt = now() + BACKOFF_MS[Math.min(st.failures - 1, BACKOFF_MS.length - 1)];
if (st.failures === 1) warn(`[openvibe-sdk] JWKS ${url} fetch failed (${st.lastError}); ${st.keys ? 'serving the last good keys' : 'no keys yet'}, retrying with backoff`);
if (st.keys) return st.keys;
++ // No key at all: retry on the backoff by itself, not only when a token next needs checking. A service
++ // that started before Network (a reboot) otherwise reported "no key" until its first sign-in: Trade and
++ // the Tools gateway showed degraded on /status for six hours on 2026-10-23.
++ if (!st.retry) {
++ st.retry = setTimeout(() => { st.retry = null; if (!st.keys) refresh().catch(() => { /* logged */ }); }, Math.max(0, st.nextTryAt - now()));
++ if (st.retry.unref) st.retry.unref();
++ }
throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url}: ${st.lastError}` });
} finally { st.inflight = null; }
})();
@@@ -106,7 +113,7 @@ function createJwksClient(url, { fetch: fetchImpl = globalThis.fetch, log = null
if (st.timer.unref) st.timer.unref();
return api;
}
-- function stop() { if (st.timer) clearInterval(st.timer); st.timer = null; }
++ function stop() { if (st.timer) clearInterval(st.timer); st.timer = null; if (st.retry) clearTimeout(st.retry); st.retry = null; }
const api = { url, keys, keysForKid, refresh, status, start, stop };
return api;
ddiff --git a/src/core/index.js b/src/core/index.js
iindex b2e68b6..954809f 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.63.0';
++const SDK_VERSION = '0.64.0';
module.exports = {
createClient,
ddiff --git a/src/events.js b/src/events.js
iindex e43c02b..4e70f8a 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -20,12 +20,19 @@ const { startSpan } = require('./core/trace');
// ── Webhook signatures ───────────────────────────────────────
--/** `sha256=<hex HMAC-SHA256 of the raw body>`, the value of X-OpenVibe-Signature. */
++/**
++ * `sha256=<hex HMAC-SHA256 of the raw body>`, the value of X-OpenVibe-Signature (v1).
++ * @deprecated v1 signs no time, so a captured delivery verifies forever; OpenVibe.Events sends only v2. Leaves in 1.0
++ * (docs/one-point-oh.md): use signDeliveryHeaders / signDeliveryV2.
++ */
function signDelivery(rawBody, secret) {
return `sha256=${crypto.createHmac('sha256', String(secret)).update(toBuffer(rawBody)).digest('hex')}`;
}
--/** Constant-time check of a delivery's X-OpenVibe-Signature against the RAW request body. */
++/**
++ * Constant-time check of a delivery's X-OpenVibe-Signature (v1) against the RAW request body.
++ * @deprecated replayable (no time in it); leaves in 1.0 (docs/one-point-oh.md): use verifyDeliveryV2 / parseDelivery.
++ */
function verifyDelivery(rawBody, signatureHeader, secret) {
if (rawBody == null || typeof signatureHeader !== 'string' || !secret) return false;
const expected = Buffer.from(signDelivery(rawBody, secret));
@@@ -116,11 +123,12 @@ function verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = V2_TOLERANC
*
* X-OpenVibe-Signature-V2 present it must verify and be within ±toleranceSec (default 300) of
* `now`; a bad or stale v2 is null, never a fallback to v1
-- * no v2 header v1 (X-OpenVibe-Signature) is accepted unless requireV2
++ * no v2 header null; v1 (X-OpenVibe-Signature) only with requireV2: false (deprecated)
*
-- * Set requireV2: true once OpenVibe.Events sends v2 to you: then a replayed v1-only delivery fails.
++ * v2 is required by default since 0.64.0: OpenVibe.Events sends only v2, every service already asked for it, and a v1
++ * signature carries no time, so a captured v1-only delivery would verify forever.
*/
--function parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now } = {}) {
++function parseDelivery(rawBody, headers, secret, { requireV2 = true, toleranceSec, now } = {}) {
const get = (k) => headerValue(headers, k);
if (get('x-openvibe-signature-v2') !== undefined) {
if (!verifyDeliveryV2(rawBody, headers, secret, { toleranceSec, now })) return null;
ddiff --git a/src/front-page.js b/src/front-page.js
nnew file mode 100644
iindex 0000000..933cac5
---- /dev/null
++++ b/src/front-page.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * openvibe-sdk/front-page — what may lead a site's front page, read by everyone who arrives: a list of the latest or
++ * most viewed things, a quote strip, a feed. A title or line with one of the built-in slur categories, strong
++ * profanity (English, Portuguese, Spanish, French, German) or a hate topic out of context is left off the front
++ * page and the next one shown; the thing itself stays where it is (its own page, its own room).
++ *
++ * 2026-10-24: an anonymous screenshot paste titled with a slur sat in OpenVibe.Community's "Most viewed"; OpenVibe.Live
++ * applied the same rule to its "Lately on OpenVibe" quotes (Live#345/#346, server/home/front-page-text.js).
++ *
++ * The slur categories and their normalization are OpenVibe.Chat's built-in ones (server/chat/moderation-utils.js:
++ * leet mapping, accents stripped, letters spaced out or run together), ported unchanged. Defensive moderation: this
++ * file adds no term; tests build their cases at run time.
++ */
++
++const LEET_MAP = {
++ '0': 'o', '1': 'i', '2': 'z', '3': 'e', '4': 'a',
++ '5': 's', '6': 'g', '7': 't', '8': 'b', '9': 'g',
++ '@': 'a', '$': 's', '!': 'i', '|': 'i', '+': 't',
++};
++
++const CORE_SLUR_CATEGORIES = [
++ { key: 'n_word', patterns: ['\\bn+i+g+g+(?:a+[sz]?|e+r+[sz]?)\\b', '\\b[kn]*n+h?i+c?k+e+r+s?\\b'] },
++ {
++ key: 'antisemitic',
++ patterns: [
++ '\\bk+\\s*y+\\s*k+\\s*e+\\b',
++ '\\bj+\\s*e+\\s*w+\\s*s?\\s+w+\\s*i+\\s*l+\\s*l+\\s+n+\\s*o+\\s*t+\\s+r+\\s*e+\\s*p+\\s*l+\\s*a+\\s*c+\\s*e+\\b',
++ ],
++ },
++ { key: 'homophobic', patterns: ['\\bf+\\s*a+\\s*g+(?:o+\\s*t+)?[sz]?\\b'] },
++ { key: 'racial', patterns: ['\\bs+\\s*p+\\s*i+\\s*c+[sz]?\\b', '\\bc+\\s*h+\\s*i+\\s*n+\\s*k+[sz]?\\b'] },
++].map((c) => ({ ...c, compiled: c.patterns.map((p) => new RegExp(p, 'i')) }));
++
++/** Lowercase, leet mapped, accents stripped, every run of other characters one space. */
++function _normalize(input) {
++ const mapped = String(input || '').toLowerCase().split('').map((ch) => LEET_MAP[ch] || ch).join('');
++ return mapped.normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
++}
++
++/** True when the text has one of the built-in slur categories, spaced out or run together included. */
++function containsCoreSlur(text) {
++ const normalized = _normalize(text);
++ if (!normalized) return false;
++ const candidates = [normalized, normalized.replace(/\s+/g, '')];
++ return CORE_SLUR_CATEGORIES.some((c) => candidates.some((x) => c.compiled.some((re) => re.test(x))));
++}
++
++const _ROUGH = /\b(?:f+u+c+k\w*|motherf\w*|sh[i1]t(?:s|ty|ting|ted)?|bullshit|cunts?|bitch\w*|whores?|sluts?|pussy|retard\w*|nazis?|hitler|holocaust|anti-?semit\w*|kkk|rap(?:e|ed|es|ist|ists|ing))\b/i;
++// Letter boundaries, not \b: \b does not know ç, ñ or ß.
++const _ROUGH_INTL = /(?<!\p{L})(?:caralho|porra|foda(?:-se)?|fodase|merda|putas?|viado|buceta|mierda|joder|coño|pendejos?|cabr[oó]n(?:es)?|verga|chinga\p{L}*|putain|merde|connard|salope|scheiße|scheisse|hurensohn|ficken|fick)(?!\p{L})/iu;
++
++/** True when the text may lead a front page: not empty, no slur, no strong profanity, no hate topic. */
++function frontPageOk(text) {
++ const t = String(text == null ? '' : text);
++ if (!t.trim() || _ROUGH.test(t) || _ROUGH_INTL.test(t)) return false;
++ return !containsCoreSlur(t);
++}
++
++module.exports = { frontPageOk, containsCoreSlur };
ddiff --git a/src/no-looks.js b/src/no-looks.js
iindex ba81213..b344162 100644
---- a/src/no-looks.js
++++ b/src/no-looks.js
@@@ -12,13 +12,15 @@
* and so do names (Iron Man, Pac-Man) and things a person holds or reviews (a headset on a desk).
* - noLooksTags(tags): drops tags that name clothing, hair, face, age or a person by gender ("red-beanie", "man").
* - hasLooks(text): true when a short text names looks at all; such a title is replaced rather than repaired.
++ * - isExplicit(...texts): true when any text says a scene shows sexual content (body parts, sex acts, pornography,
++ * nudity). That is not a looks rule and nothing repairs it: such a moment, clip or paste is not published at all.
* - RULES_VERSION: a short hash of the rules. A service that repairs stored rows records it and runs the repair again
* when it changes.
*
* Pure functions, no dependencies: safe in Node and in browsers.
*/
--const _LOOKS = 'shirtless|topless|bare-chested|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
++const _LOOKS = 'shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';
const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
@@@ -33,7 +35,10 @@ const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasse
const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds?|earphones?|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;
const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;
const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
--const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
++// Clothes below the waist say what someone wears as much ("The streamer in shorts kneels on grass…", a Community
++// summary from 2026-04 that every repair left), but "in the pants department" is a place: a shop word after it keeps it.
++const _LOWER = 'shorts|jeans|pants|trousers|leggings|skirts?|sweatpants|joggers|pajamas|pyjamas|swimsuits?|swimwear|bikinis?|underwear|lingerie|overalls|tights|dungarees';
++const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN}|(?:${_LOWER})(?!\\s+(?:department|store|shop|section|aisle|pockets?|drawer)))\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');
const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');
@@@ -68,7 +73,10 @@ function _noClothing(text) {
const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');
// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.
const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');
--const _hasLooks = (t) => _LOOKS_WORD.test(String(t || ''));
++// Names are not people (as noLooks spares them): "Iron Man Speedrun", "Pac-Man Marathon", "Spider-Man 2" are titles
++// about a game or a character, so a hyphenated name and the few two-word ones are skipped before the word test.
++const _NAMED = /\b(?:iron|mega|pac|ant|spider|bat|he|ray)[\s-]+(?:man|men|woman|women)\b|-(?:man|men|woman|women|boy|girl)\b/gi;
++const _hasLooks = (t) => _LOOKS_WORD.test(String(t || '').replace(_NAMED, ' '));
function _noLooksTags(tags) {
return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
}
@@@ -130,12 +138,12 @@ function _noLooksWords(text) {
// Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
.replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
.replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
-- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\s+and\s+/gi, ' ')
-- .replace(/\b(?:shirtless|topless|bare-chested)\s+and\s+/gi, '')
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, ' ')
++ .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, '')
.replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')
-- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\b/gi, ' is on camera')
-- .replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')
-- .replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b/gi, ' is on camera')
++ .replace(/\s*,\s*(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s*,/gi, '')
++ .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b,?\s*/gi, '');
}
// FNV-1a over the rules' source, twice with different offsets: a stable 16-hex-digit version.
@@@ -148,7 +156,15 @@ function _hash(text) {
}
return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
}
--const RULES_VERSION = _hash([_LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
++// What a scene shows, not how someone looks: the looks rules take out an adjective ("a naked man … reaching toward
++// their genitals" → "a man … reaching toward their genitals"), so a service asks isExplicit() before it publishes AI text
++// at all. Live's AI auto-clip 483 went public with such a description on 2026-10-23 (Live#344 had its own copy first).
++const _EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;
++function _isExplicit(...texts) {
++ return texts.some((t) => t != null && _EXPLICIT.test(String(t)));
++}
++
++const RULES_VERSION = _hash([_EXPLICIT, _NAMED, _LOOKS, _PEOPLE, _WORN, _LOWER, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
_noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));
--module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION };
++module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, isExplicit: _isExplicit, RULES_VERSION };
ddiff --git a/test/events.test.js b/test/events.test.js
iindex 50caeaf..c333dbf 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -161,7 +161,7 @@ run([
await assert.rejects(events.subscribe({ topicPattern: 'media.vod.*', endpoint: 'http://127.0.0.1:3000/internal/events' }), { status: 409, code: 'events.subscription_exists' });
}],
-- ['verifyDelivery / parseDelivery check X-OpenVibe-Signature over the raw body', async () => {
++ ['verifyDelivery / parseDelivery (requireV2: false, deprecated) check X-OpenVibe-Signature over the raw body', async () => {
const secret = `whsec_${'ab'.repeat(32)}`;
const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' } }));
const sig = signDelivery(raw, secret);
@@@ -174,9 +174,9 @@ run([
assert.equal(verifyDelivery(raw, 'sha256=00', secret), false);
assert.equal(verifyDelivery(raw, undefined, secret), false);
assert.equal(verifyDelivery(raw, sig, ''), false);
-- const d = parseDelivery(raw, { 'x-openvibe-signature': sig, 'x-openvibe-subscription-id': 'sub_1', 'x-openvibe-delivery-attempt': '2' }, secret);
++ const d = parseDelivery(raw, { 'x-openvibe-signature': sig, 'x-openvibe-subscription-id': 'sub_1', 'x-openvibe-delivery-attempt': '2' }, secret, { requireV2: false });
assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, subscriptionId: 'sub_1', attempt: 2 });
-- assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
++ assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret, { requireV2: false }), null);
}],
['verifyDeliveryV2 checks X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") and the ±300 s window', async () => {
@@@ -230,8 +230,9 @@ run([
assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': '' }, secret, { now }), null, 'an empty v2 header is present and bad');
assert.equal(parseDelivery(raw, { ...v1only, 'x-openvibe-signature-v2': signed['X-OpenVibe-Signature-V2'] }, secret, { now: now + 200000, toleranceSec: 100 }), null, 'toleranceSec is passed through');
// No v2 header: v1 only while requireV2 is off.
-- assert.deepEqual(parseDelivery(raw, v1only, secret), want, 'v1-only still parses by default (backward compatible)');
++ assert.equal(parseDelivery(raw, v1only, secret), null, 'v2 is required by default (0.64.0): a v1-only delivery is replayable');
assert.equal(parseDelivery(raw, v1only, secret, { requireV2: true }), null, 'requireV2 refuses a v1-only (replayable) delivery');
++ assert.deepEqual(parseDelivery(raw, v1only, secret, { requireV2: false }), want, 'requireV2: false still accepts v1 (deprecated)');
assert.equal(parseDelivery(raw, { 'x-openvibe-signature': 'sha256=bad' }, secret), null);
// A v2 delivery with a bad v1 still parses: v1 is not consulted once v2 is there.
assert.deepEqual(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature': 'sha256=bad' }, secret, { now }), want);
ddiff --git a/test/front-page.test.js b/test/front-page.test.js
nnew file mode 100644
iindex 0000000..7e07870
---- /dev/null
++++ b/test/front-page.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++// openvibe-sdk/front-page: what may lead a front page. Slur cases are built at run time from OpenVibe.Chat's existing
++// categories (defensive moderation: no term is written out here).
++const assert = require('assert');
++const { frontPageOk, containsCoreSlur } = require('../src/front-page');
++const esm = import('../esm/front-page.mjs');
++
++const blockedAlpha = ['k', 'y', 'k', 'e'].join(''); // an existing built-in pattern
++const blockedBeta = ['f', 'a', 'g'].join(''); // another (the Community paste title of 2026-10-24)
++
++for (const t of [`Some ${blockedBeta} from r.s.`, `you ${blockedAlpha}`, blockedAlpha.split('').join(' '), blockedAlpha.split('').join('.'),
++ blockedAlpha.replace('e', '3'), blockedAlpha.toUpperCase()]) {
++ assert.ok(containsCoreSlur(t), `slur: ${t.length} chars`);
++ assert.ok(!frontPageOk(t), 'a slur never leads');
++}
++for (const t of ['this is fucking great', 'bullshit take', 'tô com o rollback do caralho', 'qué mierda', 'putain de lag', 'so ein Scheiße', 'an anti-semitic trope']) {
++ assert.ok(!frontPageOk(t), `rough: ${t}`);
++}
++for (const t of ['Faggin Pizza Party', 'a spicy taco', 'Fagaras mountains', 'shitake', 'Scunthorpe United', 'the classic Puttana sauce',
++ 'A creator explains the project', 'Lately on OpenVibe', 'gg', '😂']) {
++ assert.ok(frontPageOk(t), `ok: ${t}`);
++}
++assert.ok(!frontPageOk('') && !frontPageOk(' ') && !frontPageOk(null), 'nothing to show');
++
++esm.then((m) => {
++ assert.strictEqual(m.frontPageOk, frontPageOk);
++ assert.strictEqual(m.containsCoreSlur, containsCoreSlur);
++ console.log('front-page: slurs, rough words in five languages, near misses and the ESM entry pass');
++}).catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/jwks.test.js b/test/jwks.test.js
iindex a53fb58..a525ddf 100644
---- a/test/jwks.test.js
++++ b/test/jwks.test.js
@@@ -72,6 +72,19 @@ run([
assert.equal(c.status().ready, false);
}],
++ ['no keys at all: it retries on the backoff by itself, so readiness recovers without a token to check', async () => {
++ const w = world(), c = w.client(); w.fail = 'throw';
++ await assert.rejects(c.keys(), { code: 'token.no_key' });
++ assert.equal(c.status().ready, false);
++ w.fail = null; w.t += 1000; // Network is back; the first backoff (1 s) has passed
++ await new Promise((r) => setTimeout(r, 1100));
++ await tick();
++ assert.equal(w.fetches, 2, 'one retry, by itself');
++ assert.equal(c.status().ready, true, 'ready without a keys() call (a reboot that started it before Network)');
++ assert.ok(w.logs.some(([lvl, m]) => lvl === 'info' && /recovered after 1 failed fetch/.test(m)));
++ c.stop();
++ }],
++
['an unknown kid refetches once (a rotation), a flood of them only every 30 s', async () => {
const w = world(), c = w.client();
await c.keys();
ddiff --git a/test/no-looks.test.js b/test/no-looks.test.js
iindex 19437d8..43838a5 100644
---- a/test/no-looks.test.js
++++ b/test/no-looks.test.js
@@@ -2,11 +2,14 @@
// openvibe-sdk/no-looks: the shared corpus. Every case here was public AI text on OpenVibe.Live or Community (frame
// notes, overviews, clip lines, paste picture descriptions) or a near miss that must stay as written.
const assert = require('assert');
--const { noLooks, noLooksTags, hasLooks, RULES_VERSION } = require('../src/no-looks');
++const { noLooks, noLooksTags, hasLooks, isExplicit, RULES_VERSION } = require('../src/no-looks');
const esm = import('../esm/no-looks.mjs');
const cases = [
['A shirtless man sits in a room with framed pictures on the wall.', 'The streamer sits in a room with framed pictures on the wall.'],
++ // 2026-10-24: the AI moments job titled clip 483 "A Naked Man Lying On Their Back With Legs" on Live's home.
++ ['A naked man lies on his back on a bed.', 'The streamer lies on their back on a bed.'],
++ ['The streamer, half-naked, waves at chat.', 'The streamer waves at chat.'],
['A livestream frame showing a shirtless streamer in a small inset while playing a MOBA.', 'A livestream frame showing the streamer in a small inset while playing a MOBA.'],
['Chat watches a young boy at a desk build a PC.', 'Chat watches the streamer at a desk build a PC.'],
['Young, bearded man plays chess.', 'Streamer plays chess.'],
@@@ -44,6 +47,10 @@ const cases = [
// Clip overviews found public on 2026-10-22: a guessed gender, a slashed pattern before a shirt, a grooming sentence.
['The video features a man in a plaid/checkered shirt delivering a live-streamed explanation or discussion.', 'The video features a person delivering a live-streamed explanation or discussion.'],
['The video shows a man in a kitchen setting, appearing to stream live from a couch or kitchen area.', 'The video shows a person in a kitchen setting, appearing to stream live from a couch or kitchen area.'],
++ // Clothes below the waist (a Community screenshot summary of 2026-04); a shop or a pocket is not something worn.
++ ['The streamer in shorts kneels on grass beside a small black dog in an outdoor setting.', 'The streamer kneels on grass beside a small black dog in an outdoor setting.'],
++ ['The host in grey sweatpants and a cap laughs.', 'The host laughs.'],
++ ['Someone in the pants department shops.', 'Someone in the pants department shops.'],
['The video centers on a live stream featuring a male or female host wearing headphones, visible in a small webcam window.', 'The video centers on a live stream featuring a host, visible in a small webcam window.'],
['A home kitchen where a person adjusts their hair or clothing near the counter. A pot of soup simmers on the stove.', 'A pot of soup simmers on the stove.'],
['A female streamer and two guys race karts; the other men cheer.', 'A streamer and two people race karts; the other people cheer.'],
@@@ -98,13 +105,33 @@ assert.deepStrictEqual(noLooksTags([1, '', ' ', 'sofa']), ['sofa']);
// Titles: one that names looks is replaced, not repaired.
assert.strictEqual(hasLooks('Orange Shirt Kitchen Chat'), true);
assert.strictEqual(hasLooks('Minecraft Castle Marathon'), false);
++assert.strictEqual(hasLooks('A Naked Man Lying On Their Back With Legs'), true);
++assert.strictEqual(hasLooks('Noteworthy Half-Naked Moment'), true, 'no person word: the nudity word alone');
++assert.strictEqual(hasLooks('Nude Beach Debate'), true);
++assert.strictEqual(hasLooks('Iron Man Speedrun Record'), false, 'a name is not a person');
++assert.strictEqual(hasLooks('Pac-Man Marathon'), false);
++assert.strictEqual(hasLooks('Spider-Man 2 Swing Practice'), false);
++assert.strictEqual(hasLooks('Man Builds A Castle'), true);
assert.strictEqual(hasLooks(null), false);
// The rules' version: 16 hex digits, stable for these rules.
assert.match(RULES_VERSION, /^[0-9a-f]{16}$/);
++// isExplicit: what a scene shows (Live's clip 483 on 2026-10-23, word for word, and after the adjective repair).
++for (const t of [
++ 'A naked man lying on their back with legs spread, reaching toward their genitals',
++ noLooks('A naked man lying on their back with legs spread, reaching toward their genitals'),
++ 'The video shows the streamer engaging in explicit adult content, with close-up footage of sexual acts on a couch',
++ 'Pornographic footage plays on the second monitor', 'The scene contains nudity', 'masturbating on camera',
++]) assert.ok(isExplicit(t), `explicit: ${t}`);
++for (const t of ['A player in Fortnite is sprinting along a pink-purple sunset map', 'They read the news from Essex', 'A sextant and a map', '', null, undefined]) {
++ assert.ok(!isExplicit(t), `not explicit: ${t}`);
++}
++assert.ok(isExplicit('a fine title', null, 'the frame shows sexual activity'), 'any of several texts');
++
esm.then((m) => {
assert.strictEqual(m.noLooks, noLooks);
++ assert.strictEqual(m.isExplicit, isExplicit);
assert.strictEqual(m.RULES_VERSION, RULES_VERSION);
console.log(`no-looks: ${cases.length} cases, tags, titles and the ESM entry pass (rules ${RULES_VERSION})`);
}).catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 409f2da..710e15c 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 66);
++ assert.equal(subpaths.length, 67);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/surface.test.js b/test/surface.test.js
iindex 86b33b3..b4bc8ba 100644
---- a/test/surface.test.js
++++ b/test/surface.test.js
@@@ -6,7 +6,7 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { run } = require('./helpers');
--const { current, describe, diff, render, CORE, FILE } = require('../scripts/surface');
++const { current, describe, diff, render, CORE, TIERS, FILE } = require('../scripts/surface');
run([
['docs/api-surface.json lists every export (node scripts/surface.js updates it)', async () => {
@@@ -22,6 +22,27 @@ run([
assert.equal(recorded['openvibe-sdk/core'].createClient, 'function');
}],
++ ['every name in the 1.0 core entries has exactly one tier (docs/one-point-oh.json), and every tiered name exists', async () => {
++ const recorded = JSON.parse(fs.readFileSync(FILE, 'utf8'));
++ assert.deepEqual(Object.keys(TIERS).sort(), [...CORE].sort());
++ for (const spec of CORE) {
++ const t = TIERS[spec];
++ const all = [...t.frozen, ...t.platform, ...t.leaving];
++ assert.equal(new Set(all).size, all.length, `${spec}: a name in two tiers`);
++ assert.deepEqual(all.sort(), Object.keys(recorded[spec]).sort(), `${spec}: give each name a tier in docs/one-point-oh.json (and say why in docs/one-point-oh.md)`);
++ }
++ assert.ok(TIERS['openvibe-sdk/events'].leaving.includes('signDelivery'), 'v1 signatures leave before 1.0');
++ }],
++
++ ['a breaking change says the tier of a 1.0 core name', async () => {
++ const before = { 'openvibe-sdk/events': { parseDelivery: 'function', createPgOutbox: 'function', signDelivery: 'function' } };
++ assert.deepEqual(diff(before, { 'openvibe-sdk/events': {} }).breaking, [
++ 'openvibe-sdk/events: parseDelivery is gone [1.0 core]',
++ 'openvibe-sdk/events: createPgOutbox is gone [1.0 core entry, platform tier]',
++ 'openvibe-sdk/events: signDelivery is gone [1.0 core entry, leaves before 1.0]',
++ ]);
++ }],
++
['what each kind of export records', async () => {
class Thing {}
const mod = { f() {}, Thing, list: [1], codes: { B: 1, A: 2 }, name: 'x', n: 1, on: true, none: null, map: new Map() };
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex dd58688..2e203f4 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -96,13 +96,14 @@ export interface AppEventsClient {
export declare function createAppEvents(client: OpenVibeClient, opts: { projectId: string; appId: string; onBehalfOf?: string; baseUrl?: string }): AppEventsClient;
type RawBody = string | Uint8Array | ArrayBufferView;
++/** @deprecated v1 (no time in it: replayable); leaves in 1.0. Use signDeliveryHeaders / signDeliveryV2. */
export declare function signDelivery(rawBody: RawBody, secret: string): string;
++/** @deprecated v1 (no time in it: replayable); leaves in 1.0. Use verifyDeliveryV2 / parseDelivery. */
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2 (timestamp in unix seconds, default now). */
export declare function signDeliveryV2(rawBody: RawBody, secret: string, timestamp?: number): string;
--/** X-OpenVibe-Signature, X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2 for a delivery body, as Events sends them (now in ms). */
++/** X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2 for a delivery body, as Events sends them (now in ms). */
export declare function signDeliveryHeaders(rawBody: RawBody, secret: string, opts?: { now?: number }): {
-- 'X-OpenVibe-Signature': string;
'X-OpenVibe-Timestamp': string;
'X-OpenVibe-Signature-V2': string;
};
@@@ -115,7 +116,7 @@ export interface DeliveryV2Options {
/** Constant-time check of X-OpenVibe-Signature-V2 over the raw body, and of its timestamp (±toleranceSec). */
export declare function verifyDeliveryV2(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: DeliveryV2Options): boolean;
export interface ParseDeliveryOptions extends DeliveryV2Options {
-- /** Refuse deliveries without X-OpenVibe-Signature-V2 (v1-only). Default false. A present v2 header must always verify. */
++ /** Refuse deliveries without X-OpenVibe-Signature-V2 (v1-only, replayable). Default true since 0.64.0; false accepts v1 (deprecated). A present v2 header must always verify. */
requireV2?: boolean;
}
export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: ParseDeliveryOptions): { event: EventEnvelope; subscriptionId: string | null; attempt: number } | null;
ddiff --git a/types/front-page.d.ts b/types/front-page.d.ts
nnew file mode 100644
iindex 0000000..6bcde75
---- /dev/null
++++ b/types/front-page.d.ts
@@@ -0,0 +1,4 @@
++/** True when the text may lead a front page (a latest or most viewed list, a quote strip, a feed): not empty, no built-in slur category, no strong profanity in English, Portuguese, Spanish, French or German, no hate topic. */
++export function frontPageOk(text: unknown): boolean;
++/** True when the text has one of the built-in slur categories (OpenVibe.Chat's), spaced out, run together or leet-spelled included. */
++export function containsCoreSlur(text: unknown): boolean;
ddiff --git a/types/no-looks.d.ts b/types/no-looks.d.ts
iindex 744fb60..9ab0ce5 100644
---- a/types/no-looks.d.ts
++++ b/types/no-looks.d.ts
@@@ -4,5 +4,7 @@ export function noLooks<T>(text: T): T;
export function noLooksTags(tags: unknown): string[];
/** True when a short text (a title) names looks at all: replace it rather than repair it. */
export function hasLooks(text: unknown): boolean;
++/** True when any of the texts says a scene shows sexual content (body parts, sex acts, pornography, nudity): publish none of it. */
++export function isExplicit(...texts: unknown[]): boolean;
/** A 16-hex-digit hash of the rules: record it after repairing stored rows, and repair again when it changes. */
export const RULES_VERSION: string;