openvibers/openvibe.sdk Commits

openvibe-sdk/meme and openvibe-sdk/app-signin (with #107 and #101 brought onto main) (#112)

OP OpenVibers committed 11 days ago · 80fb739e27 · 20 files, +1105 −36

* 0.74.0: openvibe-sdk/app-signin, "Sign in with OpenVibe" for developer apps

A drop-in router for developer apps: createAppSignIn() gives /login, /callback, /logout, /me and /health on top of the SDK's own auth helpers (authorization code + PKCE, the state check). It has the same recovery as sso 0.73.0 (one automatic retry, then the same failurePage) and the same client health check. The app token is verified offline against the Network's JWKS and only trusted when it was minted for this app (sub = app:<clientId>) before on_behalf_of names the person. Types, tests against a stub Network and a changelog entry are included.

* 0.74.0: the version, the API surface and the browser bundle follow the new module

SDK_VERSION, package-lock, STATUS.json, README, the generated API surface (openvibe-sdk/app-signin) and the browser bundle.

* SDK 0.79.0: openvibe-sdk/meme, the encyclopedia, trends, analysis, research and generator client

Adds createMemeClient with search, entry, revisions, trends, analyze, research/researchGet/saveResearch and generate, typed in types/meme.d.ts. It is browser-safe and in the browser bundle, the top-level namespace and docs/api-surface.json. analyze refuses locally unless exactly one of url/media_id is given and generate refuses without a template or topic; no request is sent for either. Version 0.79.0; no tag.

* 0.79.0: vision.face.familiar — a camera owner's own face library

The SDK carries F1's familiar-face calls: list/create/get/update/remove the owner's library, enrol a crop, match only that library, list/rename/mute/delete a named person, and send or accept a consent request. Types say what the JSON says.

* Merge remote-tracking branch 'origin/main' into sdk-refresh-108

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	package-lock.json
#	package.json
#	src/core/index.js
#	test/pack.test.js

* Refresh onto main: the browser bundle rebuilt, the entry under Unreleased

* Merge remote-tracking branch 'origin/main' into sdk-refresh-107

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	package-lock.json
#	package.json
#	src/core/index.js
#	test/pack.test.js

* Refresh onto main: the bundle and surface rebuilt, the entry under Unreleased

* Merge remote-tracking branch 'origin/main' into sdk-refresh-101

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	package-lock.json
#	package.json
#	src/core/index.js
#	test/browser-entries.test.js

* Refresh onto main: the app-signin export kept, the pack test counts it, the entry under Unreleased

* Merge remote-tracking branch 'origin/feat/sdk-meme' into feat/sdk-faces-meme-signin

# Conflicts:
#	CHANGELOG.md

* Merge remote-tracking branch 'origin/feature/app-signin-drop-in' into feat/sdk-faces-meme-signin

# Conflicts:
#	CHANGELOG.md

* The meme and app-signin exports, counted in the pack test; the surface rebuilt

* Merge remote-tracking branch 'origin/main' into feat/sdk-faces-meme-signin

# Conflicts:
#	CHANGELOG.md

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ba7d353..db96ca3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -12,6 +12,28 @@
- `people`, `patchPerson` (rename or mute), `removePerson`, `requestConsent`, `checkConsent`, `acceptConsent` and
`denyConsent`.
- The types in `types/vision.d.ts` say what the JSON says; no template, crop or other library is ever in an answer.
++- **`openvibe-sdk/meme`:** the client for OpenVibe.Meme's encyclopedia, trends, analyser, deep dives and generator.
++ - `createMemeClient(client, { baseUrl? })` (both browser and server; audience `openvibe.meme`, service id `meme`).
++ - `search({ scope, q, status, label, kind, sort, window, channel, limit, cursor })`, `entry(slug)`, `revisions(slug)`,
++ `trends({ window: 'day'|'week', limit })`, `analyze({ url | media_id })`, `research(topic)` (202, poll
++ `researchGet(id)`), `saveResearch(id)` and `generate({ template | template_slug | topic, lines? })`.
++ - `analyze` refuses locally unless exactly one of `url`/`media_id` is given, and `generate` refuses with neither a
++ template nor a topic; no request is sent for either. Types are in `types/meme.d.ts`; the browser bundle includes
++ the client. The `meme` namespace is on `openvibe-sdk` and in the browser bundle, and `openvibe-sdk/meme`
++ (`createMemeClient`) is in docs/api-surface.json.
++- **`openvibe-sdk/app-signin`: "Sign in with OpenVibe" for developer apps, in one call.**
++ - `createAppSignIn({ clientId, clientSecret, redirectUri, networkUrl, audience, scope, session })` gives a router
++ with `/login`, `/callback`, `/logout`, `/me` and `/health`, built on the SDK's own auth helpers
++ (authorization code + PKCE, the state check): nothing here re-implements OAuth.
++ - The same recovery as `openvibe-sdk/sso` 0.73.0: a lost or expired flow and a code the Network no longer accepts
++ start the sign-in once more by themselves; the second failure shows the same plain page (`failurePage`, now
++ exported by `openvibe-sdk/sso`), with "Try again" and the way back. Neither ever sets a session.
++ - The same client check: `GET /health` and `checkClient()` answer whether the Network accepts the app's
++ credentials (a code that is never issued; cached 10 minutes; 503 only when they are refused).
++ - The app token is verified offline against the Network's JWKS and only trusted when it was minted for THIS app
++ (`sub = app:<clientId>`), before `on_behalf_of` names the person.
++ - The session: your own store (`session: { get, set, clear }`) or, by default, an in-memory store with an opaque
++ httpOnly cookie. The secret, the PKCE verifier and the token never reach the browser and never appear in a log.
- **`openvibe-sdk/live`: the rest of the Go live screen.** `live.slots.history(id)` (the slot's past streams),
`live.slots.quality(id, { resolution, fps, bitrate })` and `live.slots.titleIdeas(id, { weekday, hour, title })` (the
card's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does
ddiff --git a/browser.js b/browser.js
iindex bab0a8e..4973c72 100644
---- a/browser.js
++++ b/browser.js
@@@ -16,6 +16,7 @@ module.exports = {
bot: require('./src/bot'),
search: require('./src/search'),
bench: require('./src/bench'),
++ meme: require('./src/meme'),
vision: require('./src/vision'),
themes: require('./src/themes'),
redact: require('./src/redact'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex b910bf9..327290f 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.80.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, bench, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -23,15 +23,16 @@ module.exports = {
bot: __load(15),
search: __load(16),
bench: __load(17),
-- vision: __load(18),
-- themes: __load(19),
-- redact: __load(20),
-- chat: __load(21),
-- ai: __load(22),
-- jobs: __load(23),
-- tools: __load(25),
-- projects: __load(26),
-- geo: __load(27),
++ meme: __load(18),
++ vision: __load(19),
++ themes: __load(20),
++ redact: __load(21),
++ chat: __load(22),
++ ai: __load(23),
++ jobs: __load(24),
++ tools: __load(26),
++ projects: __load(27),
++ geo: __load(28),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -2194,7 +2195,75 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
},
--// ── 18: src/vision.js ───────────────────────────────────────────────
++// ── 18: src/meme.js ─────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/meme: OpenVibe.Meme's encyclopedia, trends, analyser, deep dives and generator. Browser-safe
++ * (JSON only). A signed-in person reads their own pending work with the client's usual token; public reads need no
++ * token.
++ *
++ * const meme = createMemeClient(client);
++ * const { entries } = await meme.search({ scope: 'internet', q: 'doge', label: '18+' });
++ * const entry = await meme.entry('doge');
++ * const rising = await meme.trends({ window: 'week' });
++ * const analysis = await meme.analyze({ url: 'https://www.reddit.com/r/memes/comments/abc123/…' });
++ * const job = await meme.research('surprised Pikachu'); // 202, poll researchGet(job.id)
++ * const made = await meme.generate({ template: 'tpl_classic' }); // { media_id, captions, template, … }
++ */
++const enc = encodeURIComponent;
++
++function queryOf(q = {}) {
++ const out = {};
++ for (const [key, value] of Object.entries(q)) {
++ if (value !== undefined && value !== null && value !== '') out[key] = value;
++ }
++ return Object.keys(out).length ? out : undefined;
++}
++
++function createMemeClient(client, defaults = {}) {
++ const call = (opts, o = {}) => client.json({ service: 'meme', baseUrl: defaults.baseUrl, audience: 'openvibe.meme', ...opts, signal: o.signal });
++ const post = (path, body, o) => call({ method: 'POST', path, json: body }, o);
++
++ return {
++ /** GET /api/v1/entries — public entries; filters scope/q/status/label/kind, sort newest|top, window day|week|month, channel stream id. */
++ search: (params = {}, o = {}) => call({ path: '/api/v1/entries', query: queryOf({
++ scope: params.scope, q: params.q, status: params.status, label: params.label, kind: params.kind,
++ sort: params.sort, window: params.window, channel: params.channel, limit: params.limit, cursor: params.cursor,
++ }) }, o),
++ /** GET /api/v1/entries/:slug — origin, citations, spread, variations, examples, related and revisions. */
++ entry: (slug, o = {}) => call({ path: `/api/v1/entries/${enc(slug)}` }, o),
++ /** GET /api/v1/entries/:slug/revisions — approved history (staff see pending/rejected too). */
++ revisions: (slug, o = {}) => call({ path: `/api/v1/entries/${enc(slug)}/revisions` }, o),
++ /** GET /api/v1/trends?window=day|week — rising posts with velocity, new templates and entries. */
++ trends: (opts = {}, o = {}) => call({ path: '/api/v1/trends', query: queryOf({ window: opts.window || 'day', limit: opts.limit }) }, o),
++ /** POST /api/v1/analyze { url | media_id } — the template, matches, origins, meaning, labels, Reddit spread/tone. */
++ analyze: (input = {}, o = {}) => {
++ const url = input.url == null ? null : String(input.url);
++ const mediaId = input.media_id == null ? (input.mediaId == null ? null : String(input.mediaId)) : String(input.media_id);
++ if ((url ? 1 : 0) + (mediaId ? 1 : 0) !== 1) throw new TypeError('analyze: pass exactly one of url or media_id');
++ return post('/api/v1/analyze', { ...(url ? { url } : { media_id: mediaId }) }, o);
++ },
++ /** POST /api/v1/research { topic } — 202 with the job; poll researchGet(id). */
++ research: (topic, o = {}) => post('/api/v1/research', { topic: String(topic == null ? '' : topic) }, o),
++ researchGet: (id, o = {}) => call({ path: `/api/v1/research/${enc(id)}` }, o),
++ saveResearch: (id, o = {}) => post(`/api/v1/research/${enc(id)}/entry`, {}, o),
++ /** POST /api/v1/generate { template_slug | topic, lines? } — a rendered PNG in Media and its captions. */
++ generate: (input = {}, o = {}) => {
++ const template = input.template_slug != null ? input.template_slug : input.template;
++ const body = {};
++ if (template != null && template !== '') body.template_slug = String(template);
++ if (input.topic != null && input.topic !== '') body.topic = String(input.topic);
++ if (input.lines !== undefined) body.lines = input.lines;
++ if (!body.template_slug && !body.topic) throw new TypeError('generate: pass template or topic');
++ return post('/api/v1/generate', body, o);
++ },
++ };
++}
++
++module.exports = { createMemeClient };
++},
++// ── 19: src/vision.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2352,7 +2421,7 @@ function pick(o, keys) {
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
},
--// ── 19: src/themes.js ───────────────────────────────────────────────
++// ── 20: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2502,7 +2571,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
--// ── 20: src/redact.js ───────────────────────────────────────────────
++// ── 21: src/redact.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2749,7 +2818,7 @@ function onlySecret(text) {
module.exports = { redact, onlySecret };
},
--// ── 21: src/chat.js ─────────────────────────────────────────────────
++// ── 22: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2991,7 +3060,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
--// ── 22: src/ai.js ───────────────────────────────────────────────────
++// ── 23: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3066,7 +3135,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
},
--// ── 23: src/jobs.js ─────────────────────────────────────────────────
++// ── 24: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3104,7 +3173,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(24);
++const { appendFiles } = __load(25);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -3268,7 +3337,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 24: src/core/form.js ────────────────────────────────────────────
++// ── 25: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3302,7 +3371,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 25: src/tools.js ────────────────────────────────────────────────
++// ── 26: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3336,8 +3405,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(24);
--const { createJobsClient } = __load(23);
++const { isUpload, appendFiles } = __load(25);
++const { createJobsClient } = __load(24);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -3587,7 +3656,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 26: src/projects.js ─────────────────────────────────────────────
++// ── 27: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3703,7 +3772,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 27: src/geo.js ──────────────────────────────────────────────────
++// ── 28: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3783,7 +3852,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, bot,
-- search, bench, vision, themes, redact, chat,
-- ai, jobs, tools, projects, geo,
++ search, bench, meme, vision, themes, redact,
++ chat, ai, jobs, tools, projects, geo,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 1feaeb9..f012296 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -165,6 +165,11 @@
"mediaUrls"
]
},
++ "meme": {
++ "object": [
++ "createMemeClient"
++ ]
++ },
"modules": {
"object": [
"createModulesClient"
@@@ -355,6 +360,10 @@
"TERMINAL": "object",
"createAiClient": "function"
},
++ "openvibe-sdk/app-signin": {
++ "createAppSignIn": "function",
++ "normalizeScope": "function"
++ },
"openvibe-sdk/auth": {
"TOKEN_VALID_AFTER": "string",
"base64url": "function",
@@@ -582,6 +591,9 @@
"createObjectsClient": "function",
"mediaUrls": "function"
},
++ "openvibe-sdk/meme": {
++ "createMemeClient": "function"
++ },
"openvibe-sdk/modules": {
"createModulesClient": "function"
},
ddiff --git a/esm/app-signin.mjs b/esm/app-signin.mjs
nnew file mode 100644
iindex 0000000..5be8d7d
---- /dev/null
++++ b/esm/app-signin.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/app-signin/index.js. Do not edit.
++import cjs from '../src/app-signin/index.js';
++
++export const {
++ createAppSignIn, normalizeScope,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 7343a53..51051b6 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -6,12 +6,12 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
-- community, bot, search, bench, vision, themes,
-- redact, chat, ai, jobs, tools, projects,
-- resources, vip, quest, live, inventory, tips,
-- blog, reviews, wiki, coupons, deals, trade,
-- space, host, news, food, actor, run,
-- video, work, zone, play, download, openre,
-- geo, usage, commerce, telemetry,
++ community, bot, search, bench, meme, vision,
++ themes, redact, chat, ai, jobs, tools,
++ projects, resources, vip, quest, live, inventory,
++ tips, blog, reviews, wiki, coupons, deals,
++ trade, space, host, news, food, actor,
++ run, video, work, zone, play, download,
++ openre, geo, usage, commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/meme.mjs b/esm/meme.mjs
nnew file mode 100644
iindex 0000000..0285e9e
---- /dev/null
++++ b/esm/meme.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/meme.js. Do not edit.
++import cjs from '../src/meme.js';
++
++export const {
++ createMemeClient,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 52ab784..6cb7b18 100644
---- a/index.js
++++ b/index.js
@@@ -19,6 +19,7 @@ module.exports = {
bot: require('./src/bot'),
search: require('./src/search'),
bench: require('./src/bench'),
++ meme: require('./src/meme'),
vision: require('./src/vision'),
themes: require('./src/themes'),
redact: require('./src/redact'),
ddiff --git a/package.json b/package.json
iindex 606b7c2..10e31be 100644
---- a/package.json
++++ b/package.json
@@@ -72,6 +72,12 @@
"import": "./esm/media.mjs",
"default": "./src/media.js"
},
++ "./meme": {
++ "types": "./types/meme.d.ts",
++ "browser": "./src/meme.js",
++ "import": "./esm/meme.mjs",
++ "default": "./src/meme.js"
++ },
"./community": {
"types": "./types/community.d.ts",
"import": "./esm/community.mjs",
@@@ -305,6 +311,12 @@
"import": "./esm/actor.mjs",
"default": "./src/actor.js"
},
++ "./app-signin": {
++ "types": "./types/app-signin.d.ts",
++ "browser": null,
++ "import": "./esm/app-signin.mjs",
++ "default": "./src/app-signin/index.js"
++ },
"./run": {
"types": "./types/run.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex c8e4d46..1d0d277 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -22,6 +22,7 @@ const ENTRIES = {
'modules.mjs': '../src/modules.js',
'events.mjs': '../src/events.js',
'sso.mjs': '../src/sso/index.js',
++ 'app-signin.mjs': '../src/app-signin/index.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
@@@ -29,6 +30,7 @@ const ENTRIES = {
'machines.mjs': '../src/machines.js',
'search.mjs': '../src/search.js',
'bench.mjs': '../src/bench.js',
++ 'meme.mjs': '../src/meme.js',
'themes.mjs': '../src/themes.js',
'limits.mjs': '../src/limits.js',
'account-data.mjs': '../src/account-data.js',
ddiff --git a/src/app-signin/index.js b/src/app-signin/index.js
nnew file mode 100644
iindex 0000000..f44ff21
---- /dev/null
++++ b/src/app-signin/index.js
@@@ -0,0 +1,325 @@
++'use strict';
++/**
++ * openvibe-sdk/app-signin: "Sign in with OpenVibe" for developer apps — a drop-in router, the same
++ * recovery and the same health check as openvibe-sdk/sso 0.73.0, built on the SDK's own auth
++ * helpers (authorization code + PKCE, the state check). Nothing here re-implements OAuth.
++ *
++ * const { createAppSignIn } = require('openvibe-sdk/app-signin');
++ * const signIn = createAppSignIn({
++ * clientId: 'app_...', clientSecret: '...', // the app's credentials (never in the browser)
++ * redirectUri: 'https://app.example/auth/callback',
++ * networkUrl: 'https://openvibe.network',
++ * audience: 'openvibe.media', // the one audience the token is for
++ * scope: ['media.object.read'], // the capability ids to ask for
++ * });
++ * app.use('/auth', signIn.router(express)); // /login /callback /logout /me /health
++ *
++ * Routes (mounted wherever you like; the redirect URI must match):
++ * GET /login → the Network's /oauth/authorize with the audience, the capability ids, a state
++ * and a PKCE challenge (S256). The state and the verifier stay on the server.
++ * GET /callback → the state must match (constant time), the code is exchanged once with the
++ * secret and the verifier; the app token is verified offline against the
++ * Network's JWKS and only trusted when it was minted for THIS app
++ * (sub = app:<clientId>); then on_behalf_of names the person.
++ * GET /logout → the session is dropped (the token is revoked, best effort).
++ * GET /me → { user: null } or { user: { subject, capabilities, expires_at } }.
++ * GET /health → { ok, client: 'ok' | 'invalid' | 'unknown' }: does the Network accept this
++ * app's credentials? (a code that is never issued; cached 10 minutes)
++ *
++ * When a sign-in cannot finish: a lost or expired flow (no pending state, or another state) and a
++ * code the Network no longer accepts (invalid_grant: used twice, or bound to another verifier)
++ * start the sign-in once more by themselves; a second failure shows the same plain page
++ * openvibe-sdk/sso shows, with "Try again" and the way back. Neither ever sets a session.
++ *
++ * The session: pass `session: { get, set, clear }` to keep the state in your own store (the
++ * recommended way in production), or leave it out and the SDK keeps it in memory and puts only an
++ * opaque id in an httpOnly cookie. Either way the secret, the PKCE verifier and the app token
++ * never reach the browser and never appear in a log.
++ */
++const crypto = require('node:crypto');
++const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('../auth');
++const { failurePage, parseCookies, sanitizeNext } = require('../sso');
++
++const PENDING_TTL_MS = 10 * 60 * 1000;
++const HEALTH_CODE = 'ov_app_health_check_never_issued';
++const DEFAULT_COOKIE = 'ov_app_sid';
++const SESSION_KEY = 'appSignIn';
++
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++
++/** The capability ids to ask for, as the Network takes them (a space-separated string). */
++function normalizeScope(scope) {
++ if (scope === undefined || scope === null) return undefined;
++ const list = Array.isArray(scope) ? scope : String(scope).split(/[\s,]+/);
++ const ids = list.map((x) => String(x || '').trim()).filter(Boolean);
++ return ids.length ? ids : undefined;
++}
++
++function createAppSignIn(opts = {}) {
++ const clientId = String(opts.clientId || '');
++ const clientSecret = opts.clientSecret ? String(opts.clientSecret) : '';
++ const networkUrl = trim(opts.networkUrl || 'https://openvibe.network');
++ const redirectUri = String(opts.redirectUri || '');
++ const audience = String(opts.audience || 'openvibe.network');
++ const scope = normalizeScope(opts.scope !== undefined ? opts.scope : opts.scopes);
++ const scopeText = scope ? scope.join(' ') : undefined;
++ const appName = String(opts.appName || 'the app');
++ const siteName = String(opts.siteName || appName);
++ const fetchImpl = opts.fetch || globalThis.fetch;
++ const log = opts.log || console;
++ const jwks = opts.jwks || (opts.publicKey ? null : `${networkUrl}/api/.well-known/jwks`);
++ const issuer = trim(opts.issuer || networkUrl);
++ const acceptSandbox = opts.acceptSandbox !== false;
++ const baseUrl = trim(opts.baseUrl || (() => { try { return new URL(redirectUri).origin; } catch { return ''; } })());
++ const sessionApi = opts.session && typeof opts.session.get === 'function' ? opts.session : null;
++ const cookieName = String((opts.cookie && opts.cookie.name) || opts.cookieName || DEFAULT_COOKIE);
++ const secure = opts.secureCookies !== undefined ? !!opts.secureCookies : redirectUri.startsWith('https://');
++ const cookieMaxAgeMs = (opts.cookie && opts.cookie.maxAge) || 60 * 60 * 1000;
++ const sameSite = (opts.cookie && opts.cookie.sameSite) || 'lax';
++ const store = new Map(); // the in-memory fallback: an opaque id -> the state (nothing readable in the browser)
++
++ if (!clientId) throw new TypeError('createAppSignIn: clientId is required');
++ if (!redirectUri) throw new TypeError('createAppSignIn: redirectUri is required');
++ if (!sessionApi && !opts.clientSecret) throw new TypeError('createAppSignIn: a confidential app needs its clientSecret (or a session adapter for a public one)');
++
++ const next = (value) => sanitizeNext(value, { baseUrl, networkUrl });
++ const cookieOpts = () => ({ httpOnly: true, sameSite, secure, path: '/', maxAge: Math.floor(cookieMaxAgeMs / 1000) });
++
++ // ── the session state: { pending: { state, verifier, at, next }, auth: { token, subject, ... }, retried } ──
++ async function load(req) {
++ if (sessionApi) {
++ const s = await sessionApi.get(req);
++ return (s && typeof s === 'object' && s[SESSION_KEY]) || null;
++ }
++ const sid = parseCookies(req)[cookieName];
++ return (sid && store.get(sid)) || null;
++ }
++ async function save(req, res, state, { rotate = false } = {}) {
++ if (sessionApi) {
++ const s = (await sessionApi.get(req)) || {};
++ s[SESSION_KEY] = state;
++ await sessionApi.set(req, res, s);
++ return;
++ }
++ const current = parseCookies(req)[cookieName];
++ let sid = current && store.has(current) ? current : null;
++ if (!sid || rotate) {
++ if (sid) store.delete(sid);
++ sid = crypto.randomBytes(32).toString('base64url');
++ res.cookie(cookieName, sid, cookieOpts());
++ }
++ store.set(sid, state);
++ }
++ async function drop(req, res) {
++ if (sessionApi) {
++ if (typeof sessionApi.clear === 'function') await sessionApi.clear(req, res);
++ else {
++ const s = await sessionApi.get(req);
++ if (s && typeof s === 'object') { delete s[SESSION_KEY]; await sessionApi.set(req, res, s); }
++ }
++ return;
++ }
++ const sid = parseCookies(req)[cookieName];
++ if (sid) store.delete(sid);
++ res.clearCookie(cookieName, { path: '/' });
++ }
++
++ const explain = (res, status, title, text, retryHref) => {
++ if (typeof res.type === 'function') res.type('html');
++ if (typeof res.set === 'function') res.set('Cache-Control', 'no-store');
++ return res.status(status).send(failurePage({ siteName, title, text, retryHref, backHref: '/' }));
++ };
++ const loginHref = (target) => `/login${target && target !== '/' ? `?next=${encodeURIComponent(target)}` : ''}`;
++ const SIGN_IN_AGAIN = { title: 'Sign-in didn\'t finish', text: 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.' };
++ /** A sign-in that can recover starts once more by itself; the second time it shows the page. Never sets a session. */
++ async function retryOrExplain(req, res, target, state) {
++ const st = state || {};
++ if (!st.retried) {
++ st.retried = true;
++ st.pending = null;
++ await save(req, res, st);
++ return res.redirect(loginHref(target));
++ }
++ st.retried = false;
++ st.pending = null;
++ await save(req, res, st);
++ return explain(res, 400, SIGN_IN_AGAIN.title, SIGN_IN_AGAIN.text, loginHref(target));
++ }
++
++ /** The verified claims of an app token: null when it is not one, or not ours. */
++ async function verify(token) {
++ if (!token) return null;
++ try {
++ const claims = await verifyAppToken(token, { jwks, publicKey: opts.publicKey, issuer, audience, acceptSandbox, fetch: fetchImpl });
++ // The token must be minted for THIS app before on_behalf_of means anything.
++ if (claims.sub !== `app:${clientId}`) return null;
++ return claims;
++ } catch (err) {
++ if (err && err.code === 'token.no_key') log.warn(`[app-signin] Network keys unavailable: ${err.message}`);
++ return null;
++ }
++ }
++
++ const handlers = {
++ /** Start a sign-in: the state and the PKCE verifier stay on the server. */
++ async login(req, res) {
++ const q = req.query || {};
++ const target = next(q.next);
++ const { url, state, codeVerifier } = await startAuthorization({
++ network: networkUrl, clientId, redirectUri, audience, scope,
++ });
++ const current = await load(req);
++ const state0 = current || {};
++ state0.pending = { state, verifier: codeVerifier, at: Date.now(), next: target };
++ state0.retried = false;
++ await save(req, res, state0);
++ return res.redirect(url);
++ },
++
++ /** Finish a sign-in: the state must match, the code is exchanged once, the token must be ours. */
++ async callback(req, res) {
++ const q = req.query || {};
++ const current = await load(req);
++ const pending = current && current.pending;
++ const target = next(pending && pending.next);
++ if (q.error) {
++ // The person declined, or the Network refused: their decision, not a lost flow.
++ const st = current || {};
++ st.pending = null;
++ await save(req, res, st);
++ return explain(res, 400, 'Sign-in was not completed', `The Network answered ${String(q.error).slice(0, 64)}. Nothing was signed in; you can start again.`, loginHref(target));
++ }
++ if (!pending || Date.now() - pending.at > PENDING_TTL_MS) return retryOrExplain(req, res, target, current);
++ let code;
++ try {
++ const where = req.originalUrl || req.url || `/?${new URLSearchParams(q).toString()}`;
++ ({ code } = readCallback(`http://app.invalid${where}`, { expectedState: pending.state }));
++ } catch (err) {
++ log.warn(`[app-signin] callback refused: ${err && err.code}`);
++ if (err && err.code === 'oauth.missing_code') return explain(res, 400, 'Sign-in was not completed', 'The Network sent no authorization code. Nothing was signed in; you can start again.', loginHref(target));
++ return retryOrExplain(req, res, target, current);
++ }
++ try {
++ const tokens = await exchangeCode({
++ network: networkUrl, fetch: fetchImpl, clientId, clientSecret,
++ code, codeVerifier: pending.verifier, redirectUri, audience, scope,
++ });
++ const claims = await verify(tokens.access_token);
++ if (!claims || !claims.on_behalf_of) {
++ const st = current || {};
++ st.pending = null;
++ await save(req, res, st);
++ return explain(res, 400, 'Sign-in failed', 'The token does not name a person for this app.', loginHref(target));
++ }
++ const auth = {
++ token: tokens.access_token,
++ subject: claims.on_behalf_of,
++ app: claims.sub,
++ projectId: claims.project_id || null,
++ env: claims.env || null,
++ capabilities: Array.isArray(claims.cap) ? claims.cap.slice() : (scope ? scope.slice() : []),
++ expiresAt: claims.exp ? claims.exp * 1000 : Date.now() + 5 * 60 * 1000,
++ };
++ await save(req, res, { pending: null, auth, retried: false }, { rotate: true });
++ log.log(`[app-signin] signed in ${auth.subject}`);
++ return res.redirect(target);
++ } catch (err) {
++ // A code the Network no longer accepts (used twice, too old, or bound to another
++ // verifier) starts the sign-in once more; a Network that does not answer does not.
++ const retryable = err && (err.code === 'oauth.invalid_grant' || err.status === 400 || err.status === 401);
++ log.warn(`[app-signin] exchange failed: ${err && err.message}`);
++ if (retryable) return retryOrExplain(req, res, target, current);
++ return explain(res, 502, 'OpenVibe.Network didn\'t answer', 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.', loginHref(target));
++ }
++ },
++
++ /** Drop the session; the token is revoked, best effort (app tokens expire in 5 minutes anyway). */
++ async logout(req, res) {
++ const current = await load(req);
++ const token = current && current.auth && current.auth.token;
++ await drop(req, res);
++ if (token) {
++ try {
++ await fetchImpl(`${networkUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, token }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ return res.redirect(next((req.query || {}).next));
++ },
++
++ /** Who signed in, and what this app may do for them. */
++ async me(req, res) {
++ const current = await load(req);
++ const auth = current && current.auth;
++ if (!auth || !auth.token || auth.expiresAt <= Date.now()) return res.json({ user: null });
++ const claims = await verify(auth.token);
++ if (!claims) return res.json({ user: null });
++ return res.json({
++ user: {
++ subject: claims.on_behalf_of || auth.subject,
++ app: claims.sub,
++ project_id: claims.project_id || null,
++ env: claims.env || null,
++ capabilities: Array.isArray(claims.cap) ? claims.cap : auth.capabilities,
++ expires_at: new Date(auth.expiresAt).toISOString(),
++ },
++ });
++ },
++ };
++
++ let health = null; // { client, at }: the last check
++ /**
++ * Does the Network accept this app's credentials? A token request with a code that is never
++ * issued: invalid_grant means the id and secret are right, invalid_client that they are not.
++ * Cached 10 minutes. → 'ok' | 'invalid' | 'unknown'
++ */
++ async function checkClient({ force = false } = {}) {
++ if (!force && health && Date.now() - health.at < 10 * 60 * 1000) return health.client;
++ try {
++ const res = await fetchImpl(`${networkUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body: new URLSearchParams({
++ grant_type: 'authorization_code', code: HEALTH_CODE, code_verifier: HEALTH_CODE,
++ redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret || '',
++ audience, ...(scopeText ? { scope: scopeText } : {}),
++ }).toString(),
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (res.ok) health = { client: 'ok', at: Date.now() };
++ else if (data.error === 'invalid_client' || data.error === 'unauthorized_client' || res.status === 401) {
++ health = { client: 'invalid', at: Date.now() };
++ log.error(`[app-signin] the Network refuses this app\'s credentials (${data.error || res.status}): check its client id and secret against the Network's registration`);
++ } else health = { client: 'ok', at: Date.now() }; // the grant was refused, but the client was not
++ } catch {
++ health = { client: 'unknown', at: Date.now() };
++ }
++ return health.client;
++ }
++ handlers.health = async (req, res) => {
++ const client = await checkClient();
++ if (typeof res.set === 'function') res.set('Cache-Control', 'no-store');
++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
++ };
++
++ /** The router. Pass your express module (the SDK does not depend on it). */
++ function router(express) {
++ if (!express || typeof express.Router !== 'function') throw new TypeError('appSignIn.router(express): pass the express module');
++ const r = express.Router();
++ const wrap = (fn) => (req, res, nextFn) => { Promise.resolve(fn(req, res)).catch(nextFn); };
++ r.get('/login', wrap(handlers.login));
++ r.get('/callback', wrap(handlers.callback));
++ r.get('/logout', wrap(handlers.logout));
++ r.get('/me', wrap(handlers.me));
++ r.get('/health', wrap(handlers.health));
++ return r;
++ }
++
++ return { router, handlers, verify, checkClient, redirectUri, audience, scope: scope || [], clientId };
++}
++
++module.exports = { createAppSignIn, normalizeScope };
ddiff --git a/src/meme.js b/src/meme.js
nnew file mode 100644
iindex 0000000..d0e07f2
---- /dev/null
++++ b/src/meme.js
@@@ -0,0 +1,65 @@
++'use strict';
++/**
++ * openvibe-sdk/meme: OpenVibe.Meme's encyclopedia, trends, analyser, deep dives and generator. Browser-safe
++ * (JSON only). A signed-in person reads their own pending work with the client's usual token; public reads need no
++ * token.
++ *
++ * const meme = createMemeClient(client);
++ * const { entries } = await meme.search({ scope: 'internet', q: 'doge', label: '18+' });
++ * const entry = await meme.entry('doge');
++ * const rising = await meme.trends({ window: 'week' });
++ * const analysis = await meme.analyze({ url: 'https://www.reddit.com/r/memes/comments/abc123/…' });
++ * const job = await meme.research('surprised Pikachu'); // 202, poll researchGet(job.id)
++ * const made = await meme.generate({ template: 'tpl_classic' }); // { media_id, captions, template, … }
++ */
++const enc = encodeURIComponent;
++
++function queryOf(q = {}) {
++ const out = {};
++ for (const [key, value] of Object.entries(q)) {
++ if (value !== undefined && value !== null && value !== '') out[key] = value;
++ }
++ return Object.keys(out).length ? out : undefined;
++}
++
++function createMemeClient(client, defaults = {}) {
++ const call = (opts, o = {}) => client.json({ service: 'meme', baseUrl: defaults.baseUrl, audience: 'openvibe.meme', ...opts, signal: o.signal });
++ const post = (path, body, o) => call({ method: 'POST', path, json: body }, o);
++
++ return {
++ /** GET /api/v1/entries — public entries; filters scope/q/status/label/kind, sort newest|top, window day|week|month, channel stream id. */
++ search: (params = {}, o = {}) => call({ path: '/api/v1/entries', query: queryOf({
++ scope: params.scope, q: params.q, status: params.status, label: params.label, kind: params.kind,
++ sort: params.sort, window: params.window, channel: params.channel, limit: params.limit, cursor: params.cursor,
++ }) }, o),
++ /** GET /api/v1/entries/:slug — origin, citations, spread, variations, examples, related and revisions. */
++ entry: (slug, o = {}) => call({ path: `/api/v1/entries/${enc(slug)}` }, o),
++ /** GET /api/v1/entries/:slug/revisions — approved history (staff see pending/rejected too). */
++ revisions: (slug, o = {}) => call({ path: `/api/v1/entries/${enc(slug)}/revisions` }, o),
++ /** GET /api/v1/trends?window=day|week — rising posts with velocity, new templates and entries. */
++ trends: (opts = {}, o = {}) => call({ path: '/api/v1/trends', query: queryOf({ window: opts.window || 'day', limit: opts.limit }) }, o),
++ /** POST /api/v1/analyze { url | media_id } — the template, matches, origins, meaning, labels, Reddit spread/tone. */
++ analyze: (input = {}, o = {}) => {
++ const url = input.url == null ? null : String(input.url);
++ const mediaId = input.media_id == null ? (input.mediaId == null ? null : String(input.mediaId)) : String(input.media_id);
++ if ((url ? 1 : 0) + (mediaId ? 1 : 0) !== 1) throw new TypeError('analyze: pass exactly one of url or media_id');
++ return post('/api/v1/analyze', { ...(url ? { url } : { media_id: mediaId }) }, o);
++ },
++ /** POST /api/v1/research { topic } — 202 with the job; poll researchGet(id). */
++ research: (topic, o = {}) => post('/api/v1/research', { topic: String(topic == null ? '' : topic) }, o),
++ researchGet: (id, o = {}) => call({ path: `/api/v1/research/${enc(id)}` }, o),
++ saveResearch: (id, o = {}) => post(`/api/v1/research/${enc(id)}/entry`, {}, o),
++ /** POST /api/v1/generate { template_slug | topic, lines? } — a rendered PNG in Media and its captions. */
++ generate: (input = {}, o = {}) => {
++ const template = input.template_slug != null ? input.template_slug : input.template;
++ const body = {};
++ if (template != null && template !== '') body.template_slug = String(template);
++ if (input.topic != null && input.topic !== '') body.topic = String(input.topic);
++ if (input.lines !== undefined) body.lines = input.lines;
++ if (!body.template_slug && !body.topic) throw new TypeError('generate: pass template or topic');
++ return post('/api/v1/generate', body, o);
++ },
++ };
++}
++
++module.exports = { createMemeClient };
ddiff --git a/test/app-signin.test.js b/test/app-signin.test.js
nnew file mode 100644
iindex 0000000..9c29a7e
---- /dev/null
++++ b/test/app-signin.test.js
@@@ -0,0 +1,197 @@
++'use strict';
++/** openvibe-sdk/app-signin: the developer-app sign-in against a stub Network (token grants, JWKS). */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const { run } = require('./helpers');
++const { createAppSignIn } = require('../src/app-signin');
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const ISS = 'https://openvibe.network';
++const now = () => Math.floor(Date.now() / 1000);
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++const sign = (claims) => {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT', kid: 'k1' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), keys.privateKey).toString('base64url')}`;
++};
++const ID = '01J8Z5N4K2M9Q7R3T6W8Y0X1CD'; // 26 Crockford base32 characters, as a ULID is
++const APP = `app_${ID}`;
++const OTHER_APP = `app_${ID.slice(0, 25)}E`;
++const USER = `usr_${ID}`;
++const PROJECT = `prj_${ID}`;
++
++const appToken = (over = {}) => sign({
++ sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.media'], cap: ['media.object.read'],
++ project_id: PROJECT, env: 'production', on_behalf_of: USER, jti: 'j1', iss: ISS, iat: now(), exp: now() + 300, ...over,
++});
++
++// A stub Network: /oauth/token (the code exchange and the health check) and the JWKS.
++const net = { grants: [], secret: 'secret', refuseCode: null, mint: null };
++const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const send = (status, body) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); };
++ if (req.url === '/api/.well-known/jwks') return send(200, { keys: [{ ...keys.publicKey.export({ format: 'jwk' }), kid: 'k1', use: 'sig', alg: 'RS256' }] });
++ if (req.url === '/oauth/token') {
++ const body = Object.fromEntries(new URLSearchParams(raw));
++ net.grants.push(body);
++ if (body.client_secret !== net.secret) return send(401, { error: 'invalid_client' });
++ if (body.code === 'ov_app_health_check_never_issued') return send(400, { error: 'invalid_grant' });
++ if (net.refuseCode && body.code === net.refuseCode) return send(400, { error: 'invalid_grant', error_description: 'code already used' });
++ return send(200, { access_token: net.mint ? net.mint() : appToken(), token_type: 'Bearer', expires_in: 300, scope: body.scope });
++ }
++ return send(404, {});
++ });
++});
++
++function fakeRes() {
++ const r = { cookies: {}, cleared: [], statusCode: 200 };
++ r.cookie = (k, v, o) => { r.cookies[k] = { v, o }; return r; };
++ r.clearCookie = (k) => { r.cleared.push(k); return r; };
++ r.redirect = (u) => { r.statusCode = 302; r.location = u; return r; };
++ r.status = (s) => { r.statusCode = s; return r; };
++ r.json = (b) => { r.body = b; return r; };
++ r.send = (b) => { r.body = b; return r; };
++ r.type = (t) => { r.contentType = t; return r; };
++ r.set = (k, v) => { (r.headers = r.headers || {})[k] = v; return r; };
++ return r;
++}
++const cookieHeader = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
++
++let base, signIn;
++const opts = (over = {}) => ({
++ clientId: APP, clientSecret: 'secret', redirectUri: 'https://app.example/auth/callback',
++ networkUrl: ISS, audience: 'openvibe.media', scope: ['media.object.read'],
++ log: { log() {}, warn() {}, error() {} }, ...over,
++});
++
++run([
++ ['login: the audience, the capabilities and a PKCE challenge go to the Network; the state and the verifier stay on the server', async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ base = `http://127.0.0.1:${server.address().port}`;
++ signIn = createAppSignIn(opts({ networkUrl: `${base}`, issuer: ISS }));
++ const res = fakeRes();
++ await signIn.handlers.login({ query: {}, headers: {} }, res);
++ assert.equal(res.statusCode, 302);
++ const u = new URL(res.location);
++ assert.equal(`${u.origin}${u.pathname}`, `${base}/oauth/authorize`);
++ assert.equal(u.searchParams.get('client_id'), APP);
++ assert.equal(u.searchParams.get('audience'), 'openvibe.media');
++ assert.equal(u.searchParams.get('scope'), 'media.object.read');
++ assert.equal(u.searchParams.get('code_challenge_method'), 'S256');
++ assert.ok(u.searchParams.get('state'));
++ // The browser holds an opaque id only: no state, no verifier, no token.
++ const sid = res.cookies.ov_app_sid.v;
++ assert.ok(sid && !sid.includes(u.searchParams.get('state')));
++ assert.ok(!JSON.stringify(res.cookies).includes('code_verifier'));
++ assert.ok(res.cookies.ov_app_sid.o.httpOnly);
++ }],
++
++ ['callback: the state must match, the code is exchanged once with the secret and the verifier, and the token must be ours', async () => {
++ net.grants.length = 0;
++ const start = fakeRes();
++ await signIn.handlers.login({ query: {}, headers: {} }, start);
++ const sid = start.cookies.ov_app_sid.v;
++ const state = new URL(start.location).searchParams.get('state');
++ // The verifier the Network saw is the one the SDK kept: the stub accepts it.
++ const ok = fakeRes();
++ await signIn.handlers.callback({ query: { code: 'the-code', state }, url: '/auth/callback?code=the-code&state=' + state, headers: { cookie: `ov_app_sid=${sid}` } }, ok);
++ assert.equal(ok.statusCode, 302);
++ assert.equal(ok.location, '/');
++ const g = net.grants.at(-1);
++ assert.equal(g.grant_type, 'authorization_code');
++ assert.equal(g.client_id, APP);
++ assert.equal(g.client_secret, 'secret');
++ assert.equal(g.redirect_uri, 'https://app.example/auth/callback');
++ assert.equal(g.audience, 'openvibe.media');
++ assert.equal(g.scope, 'media.object.read');
++ assert.match(g.code_verifier, /^[A-Za-z0-9_-]{43,128}$/, 'the PKCE verifier the SDK kept is the one sent');
++ assert.ok(g.code_verifier && !ok.location.includes(g.code_verifier));
++
++ // The new session id: no session fixation.
++ assert.notEqual(ok.cookies.ov_app_sid.v, sid);
++ const cookie = `ov_app_sid=${ok.cookies.ov_app_sid.v}`;
++ const me = fakeRes();
++ await signIn.handlers.me({ headers: { cookie } }, me);
++ assert.equal(me.body.user.subject, USER);
++ assert.deepEqual(me.body.user.capabilities, ['media.object.read']);
++ assert.equal(me.body.user.app, `app:${APP}`);
++ assert.ok(!JSON.stringify(me.body).includes('eyJ'), 'no token in the answer');
++ assert.ok(!JSON.stringify(ok.cookies).includes('eyJ'), 'no token in the cookie');
++
++ // Signed out: no user.
++ const out = fakeRes();
++ await signIn.handlers.logout({ query: {}, headers: { cookie } }, out);
++ assert.equal(out.statusCode, 302);
++ assert.ok(out.cleared.includes('ov_app_sid'));
++ const after = fakeRes();
++ await signIn.handlers.me({ headers: { cookie } }, after);
++ assert.equal(after.body.user, null);
++ }],
++
++ ['a token minted for another app is refused before on_behalf_of is trusted', async () => {
++ net.mint = () => appToken({ sub: `app:${OTHER_APP}`, on_behalf_of: 'usr_01J8Z5N4K2M9Q7R3T6W8Y0X1D' });
++ const start = fakeRes();
++ await signIn.handlers.login({ query: {}, headers: {} }, start);
++ const state = new URL(start.location).searchParams.get('state');
++ const res = fakeRes();
++ await signIn.handlers.callback({ query: { code: 'the-code', state }, url: '/auth/callback?code=the-code&state=' + state, headers: { cookie: `ov_app_sid=${start.cookies.ov_app_sid.v}` } }, res);
++ assert.equal(res.statusCode, 400);
++ assert.match(res.body, /does not name a person for this app/);
++ net.mint = null;
++ }],
++
++ ['recovery: a lost flow or a used code starts the sign-in once more, then the same page the site sign-in shows', async () => {
++ net.grants.length = 0;
++ // A callback this browser did not start (a forged state).
++ const forged = fakeRes();
++ await signIn.handlers.callback({ query: { code: 'the-code', state: 'not-the-state' }, url: '/auth/callback?code=the-code&state=not-the-state', headers: { cookie: 'ov_app_sid=someone-else' } }, forged);
++ assert.equal(forged.statusCode, 302, 'the first time: it starts again');
++ assert.equal(forged.location, '/login');
++ assert.ok(forged.cookies.ov_app_sid, 'a session to carry the retry mark');
++
++ // The second failure: the page, not another loop.
++ const again = fakeRes();
++ await signIn.handlers.callback({ query: { code: 'the-code', state: 'not-the-state' }, url: '/auth/callback?code=the-code&state=not-the-state', headers: { cookie: `ov_app_sid=${forged.cookies.ov_app_sid.v}` } }, again);
++ assert.equal(again.statusCode, 400);
++ assert.equal(again.contentType, 'html');
++ assert.match(again.body, /<h1>Sign-in didn&#39;t finish<\/h1>/);
++ assert.match(again.body, /<a class="go" href="\/login">Try again<\/a>/);
++
++ // A code the Network no longer accepts: the same, once.
++ net.refuseCode = 'used-code';
++ const start = fakeRes();
++ await signIn.handlers.login({ query: {}, headers: {} }, start);
++ const state = new URL(start.location).searchParams.get('state');
++ const used = fakeRes();
++ await signIn.handlers.callback({ query: { code: 'used-code', state }, url: '/auth/callback?code=used-code&state=' + state, headers: { cookie: `ov_app_sid=${start.cookies.ov_app_sid.v}` } }, used);
++ assert.equal(used.statusCode, 302);
++ assert.equal(used.location, '/login');
++ net.refuseCode = null;
++ }],
++
++ ['health: does the Network accept this app\'s credentials?', async () => {
++ assert.equal(await signIn.checkClient(), 'ok');
++ const res = fakeRes();
++ await signIn.handlers.health({ headers: {} }, res);
++ assert.equal(res.statusCode, 200);
++ assert.deepEqual(res.body, { ok: true, client: 'ok' });
++
++ const other = createAppSignIn(opts({ networkUrl: `${base}`, issuer: ISS, clientSecret: 'wrong' }));
++ assert.equal(await other.checkClient(), 'invalid');
++ const bad = fakeRes();
++ await other.handlers.health({ headers: {} }, bad);
++ assert.equal(bad.statusCode, 503);
++ assert.deepEqual(bad.body, { ok: false, client: 'invalid' });
++ }],
++
++ ['the router is the five routes', async () => {
++ const routes = [];
++ const express = { Router: () => ({ get: (p) => routes.push(`GET ${p}`), post: (p) => routes.push(`POST ${p}`) }) };
++ signIn.router(express);
++ assert.deepEqual(routes, ['GET /login', 'GET /callback', 'GET /logout', 'GET /me', 'GET /health']);
++ assert.throws(() => signIn.router(null), /pass the express module/);
++ server.close();
++ }],
++]);
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 0661f20..5c1ee83 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,12 +76,12 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
}
-- for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
++ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/app-signin/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
assert.ok(!rel.includes(never), `${never} is not reachable from a browser entry`);
}
console.log(` scanned ${scanned.size} files`);
ddiff --git a/test/meme.test.js b/test/meme.test.js
nnew file mode 100644
iindex 0000000..a59a55e
---- /dev/null
++++ b/test/meme.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++/** Meme client: routes, query/body shapes, envelopes, validation and problem errors. */
++const assert = require('node:assert/strict');
++const { stubServer, send, problem, run } = require('./helpers');
++const { createClient, OpenVibeError } = require('../src/core');
++const { createMemeClient } = require('../src/meme');
++
++async function memeServer() {
++ return stubServer((req, res, body) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/v1/entries') return send(res, 200, { count: 1, entries: [{ id: 'mem_1', slug: 'doge', scope: 'internet' }], next_cursor: null });
++ if (u.pathname === '/api/v1/entries/doge') return send(res, 200, { id: 'mem_1', slug: 'doge', origin: { url: 'https://example.com/doge' }, revisions: [] });
++ if (u.pathname === '/api/v1/entries/doge/revisions') return send(res, 200, { slug: 'doge', count: 0, revisions: [] });
++ if (u.pathname === '/api/v1/entries/missing') return problem(res, 404, 'meme.not_found', 'No such meme.');
++ if (u.pathname === '/api/v1/trends') return send(res, 200, { window: 'week', rising: [], templates: [], entries: [], subreddits: [] });
++ if (u.pathname === '/api/v1/analyze') return send(res, 200, { id: 'anl_1', template: { name: 'Doge' }, labels: ['language'] });
++ if (u.pathname === '/api/v1/research' && req.method === 'POST') return send(res, 202, { id: 'rsc_1', topic: 'Doge', status: 'queued' });
++ if (u.pathname === '/api/v1/research/rsc_1' && req.method === 'GET') return send(res, 200, { id: 'rsc_1', status: 'succeeded', sources: [] });
++ if (u.pathname === '/api/v1/research/rsc_1/entry' && req.method === 'POST') return send(res, 201, { entry: { id: 'mem_2', slug: 'doge-deep-dive' }, already: false });
++ if (u.pathname === '/api/v1/generate' && req.method === 'POST') return send(res, 201, { media_id: 'med_1', captions: ['one'], template: { id: 'tpl_classic' }, labels: [] });
++ return problem(res, 404, 'meme.not_found', 'Missing');
++ });
++}
++
++run([
++ ['all Meme routes preserve paths, query, bodies and envelopes', async () => {
++ const srv = await memeServer();
++ try {
++ const meme = createMemeClient(createClient({ baseUrls: { meme: srv.url }, retries: 0 }));
++ const search = await meme.search({ scope: 'internet', q: 'doge', label: '18+', sort: 'top', window: 'week', channel: 7001, limit: 5 });
++ assert.equal(search.count, 1);
++ assert.equal(search.entries[0].slug, 'doge');
++ const searchReq = new URL(srv.requests[0].url, 'http://x');
++ assert.equal(searchReq.pathname, '/api/v1/entries');
++ assert.equal(searchReq.searchParams.get('scope'), 'internet');
++ assert.equal(searchReq.searchParams.get('q'), 'doge');
++ assert.equal(searchReq.searchParams.get('label'), '18+');
++ assert.equal(searchReq.searchParams.get('sort'), 'top');
++ assert.equal(searchReq.searchParams.get('window'), 'week');
++ assert.equal(searchReq.searchParams.get('channel'), '7001');
++ assert.equal(searchReq.searchParams.get('limit'), '5');
++
++ assert.equal((await meme.entry('doge')).origin.url, 'https://example.com/doge');
++ assert.equal((await meme.revisions('doge')).count, 0);
++ assert.equal((await meme.trends({ window: 'week' })).window, 'week');
++ assert.equal((await meme.analyze({ url: 'https://example.com/doge.png' })).id, 'anl_1');
++ assert.equal((await meme.analyze({ mediaId: 'med_1' })).id, 'anl_1');
++ const analyzeBody = JSON.parse(srv.requests.at(-1).body.toString('utf8'));
++ assert.deepEqual(analyzeBody, { media_id: 'med_1' });
++
++ assert.equal((await meme.research('Doge')).status, 'queued');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString('utf8')), { topic: 'Doge' });
++ assert.equal((await meme.researchGet('rsc_1')).status, 'succeeded');
++ assert.equal((await meme.saveResearch('rsc_1')).entry.slug, 'doge-deep-dive');
++
++ assert.equal((await meme.generate({ template: 'tpl_classic', lines: ['one'] })).media_id, 'med_1');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString('utf8')), { template_slug: 'tpl_classic', lines: ['one'] });
++ assert.equal((await meme.generate({ topic: 'Doge' })).media_id, 'med_1');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString('utf8')), { topic: 'Doge' });
++ } finally { await srv.close(); }
++ }],
++ ['Meme validation is local and errors keep the problem code', async () => {
++ const srv = await memeServer();
++ try {
++ const meme = createMemeClient(createClient({ baseUrls: { meme: srv.url }, retries: 0 }));
++ assert.throws(() => meme.analyze({}), TypeError);
++ assert.throws(() => meme.analyze({ url: 'https://x.test/a.png', media_id: 'med_1' }), TypeError);
++ assert.throws(() => meme.generate({}), TypeError);
++ assert.equal(srv.requests.length, 0, 'no request left the client for a bad input');
++ await assert.rejects(() => meme.entry('missing'), (err) => err instanceof OpenVibeError && err.status === 404 && err.code === 'meme.not_found');
++ } finally { await srv.close(); }
++ }],
++ ['the browser bundle and ESM entry expose the client', async () => {
++ const esm = await import('../esm/meme.mjs');
++ assert.equal(typeof esm.createMemeClient, 'function');
++ const top = await import('../esm/index.mjs');
++ assert.equal(typeof top.meme.createMemeClient, 'function');
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex c877cb5..221f574 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -68,7 +68,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, 74); // 0.80.0 adds openvibe-sdk/machines
++ assert.equal(subpaths.length, 76); // 0.80.0 adds openvibe-sdk/machines; the next adds openvibe-sdk/app-signin and openvibe-sdk/meme
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/types/app-signin.d.ts b/types/app-signin.d.ts
nnew file mode 100644
iindex 0000000..28fd7ec
---- /dev/null
++++ b/types/app-signin.d.ts
@@@ -0,0 +1,81 @@
++import type { IncomingMessage, ServerResponse } from 'node:http';
++import type { FetchLike } from './core';
++
++type Req = IncomingMessage & Record<string, any>;
++type Res = ServerResponse & Record<string, any>;
++type Next = (err?: unknown) => void;
++type Handler = (req: Req, res: Res, next?: Next) => unknown;
++
++/** The app token's claims: who signed in, what this app may do for them, until when. */
++export interface AppSessionUser {
++ /** The person (usr_…) who authorized this app. */
++ subject: string;
++ /** The token's subject: app:<your app id>. */
++ app: string;
++ project_id: string | null;
++ env: 'sandbox' | 'production' | null;
++ /** The capability ids the person authorized. */
++ capabilities: string[];
++ expires_at: string;
++}
++
++export interface AppSession {
++ get(req: Req): unknown | Promise<unknown>;
++ set(req: Req, res: Res, session: unknown): unknown | Promise<unknown>;
++ clear?(req: Req, res: Res): unknown | Promise<unknown>;
++}
++
++export interface AppSignInOptions {
++ /** The app's id (app_…). */
++ clientId: string;
++ /** The app's secret. Confidential apps need it; it never leaves the server. */
++ clientSecret?: string;
++ /** The exact URI the Network redirects back to (it must be registered for this app). */
++ redirectUri: string;
++ /** The Network (default https://openvibe.network). */
++ networkUrl?: string;
++ /** The one audience the token is for (openvibe.<service>). */
++ audience?: string;
++ /** The capability ids to ask for. */
++ scope?: string[] | string;
++ /** The same thing under another name. */
++ scopes?: string[] | string;
++ /** This app's origin (post-sign-in targets). Default: the redirect URI's origin. */
++ baseUrl?: string;
++ /** The name the sign-in pages show. */
++ siteName?: string;
++ appName?: string;
++ /** Your own session store: get/set (and clear, for a sign-out). */
++ session?: AppSession;
++ /** The cookie that holds the opaque session id (the in-memory fallback, when no session is given). */
++ cookie?: { name?: string; sameSite?: string; maxAge?: number };
++ cookieName?: string;
++ secureCookies?: boolean;
++ /** A pinned PEM instead of the JWKS. */
++ publicKey?: string;
++ jwks?: string;
++ issuer?: string;
++ /** Accept sandbox tokens (default true: a sandbox app signs people in in development). */
++ acceptSandbox?: boolean;
++ fetch?: FetchLike;
++ log?: Pick<Console, 'warn' | 'log'> & Partial<Pick<Console, 'error'>>;
++}
++
++export interface AppSignIn {
++ /** GET /login /callback /logout /me /health. */
++ router(express: any): any;
++ handlers: { login: Handler; callback: Handler; logout: Handler; me: Handler; health: Handler };
++ /** The verified claims of an app token minted for THIS app, or null. */
++ verify(token: string | null | undefined): Promise<Record<string, any> | null>;
++ /** Does the Network accept this app's credentials? Cached 10 minutes; GET /health answers with it (503 when 'invalid'). */
++ checkClient(opts?: { force?: boolean }): Promise<'ok' | 'invalid' | 'unknown'>;
++ redirectUri: string;
++ audience: string;
++ scope: string[];
++ clientId: string;
++}
++
++/** "Sign in with OpenVibe" for developer apps: the routes, the session and the recovery, in one call. */
++export declare function createAppSignIn(opts: AppSignInOptions): AppSignIn;
++/** The capability ids to ask for, as the Network takes them. */
++export declare function normalizeScope(scope: string[] | string | undefined | null): string[] | undefined;
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex bbf8cf5..2cdde15 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -8,6 +8,7 @@ import * as community from './community';
import * as bot from './bot';
import * as search from './search';
import * as bench from './bench';
++import * as meme from './meme';
import * as vision from './vision';
import * as themes from './themes';
import * as redact from './redact';
@@@ -29,6 +30,7 @@ export * as community from './community';
export * as bot from './bot';
export * as search from './search';
export * as bench from './bench';
++export * as meme from './meme';
export * as vision from './vision';
export * as themes from './themes';
export * as redact from './redact';
@@@ -41,6 +43,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; bench: typeof bench; vision: typeof vision; themes: typeof themes; redact: typeof redact; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
++ media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; redact: typeof redact; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
};
export default sdk;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 47774dc..eac4d71 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -26,6 +26,7 @@ export * as inventory from './inventory';
export * as tips from './tips';
export * as blog from './blog';
export * as reviews from './reviews';
++export * as meme from './meme';
export * as wiki from './wiki';
export * as coupons from './coupons';
export * as deals from './deals';
ddiff --git a/types/meme.d.ts b/types/meme.d.ts
nnew file mode 100644
iindex 0000000..ba401df
---- /dev/null
++++ b/types/meme.d.ts
@@@ -0,0 +1,186 @@
++import type { OpenVibeClient } from './core';
++
++export interface MemeOptions { signal?: AbortSignal; }
++export interface MemeDefaults extends MemeOptions { baseUrl?: string; }
++
++export type MemeScope = 'openvibe' | 'internet';
++export type MemeStatus = 'rising' | 'peaked' | 'classic' | 'dead';
++export type MemeLabel = 'nudity' | 'language' | 'graphic' | '18+' | 'flashing' | 'spoilers';
++
++export interface MemeEntry {
++ id: string;
++ slug: string;
++ title: string;
++ kind: string;
++ scope: MemeScope;
++ summary: string | null;
++ meaning?: string | null;
++ usage?: string | null;
++ labels: MemeLabel[];
++ status: MemeStatus;
++ status_since: string;
++ first_seen_at: string;
++ last_seen_at: string;
++ visibility: 'public' | 'unlisted' | 'hidden';
++ revision: number;
++ submitted_by?: string | null;
++ uses?: number;
++ created_at: string;
++ updated_at: string;
++ [field: string]: unknown;
++}
++
++export interface MemeCitation {
++ id: number;
++ entry_id: string;
++ role: 'origin' | 'use' | 'example';
++ source_service: string;
++ source_type: string;
++ source_id: string;
++ quote_text: string;
++ url: string;
++ observed_at: string;
++ source_status: 'live' | 'hidden' | 'gone';
++ [field: string]: unknown;
++}
++
++export interface MemeOrigin {
++ source: 'web' | 'reddit' | 'media' | 'openvibe';
++ url: string;
++ title: string;
++ publisher: string | null;
++ author: string | null;
++ published_at: string | null;
++ retrieved_at: string;
++ quote: string | null;
++}
++
++export interface MemeSpreadPoint { id: number; observed_at: string; place: string; url: string | null; count: number | null; note: string | null; }
++export interface MemeVariation { id: number; name: string; description: string | null; media_id: string | null; source_url: string | null; }
++export interface MemeExample { id?: number; media_id: string; caption?: string | null; source_url?: string | null; }
++export interface MemeRelated { id: string; slug: string; title: string; status: MemeStatus; summary: string | null; }
++
++export interface MemeRevision {
++ revision: number;
++ author: string;
++ base_revision: number | null;
++ change: Record<string, unknown>;
++ diff: Array<{ field: string; from: unknown; to: unknown }>;
++ moderation_state: 'pending' | 'approved' | 'rejected';
++ note: string | null;
++ created_at: string;
++ reviewed_at: string | null;
++ reviewed_by: string | null;
++ entry_slug?: string;
++ entry_title?: string;
++}
++
++export interface MemeEntryDetail extends MemeEntry {
++ origin: MemeOrigin | MemeCitation | null;
++ web_origin?: MemeOrigin | null;
++ citations: MemeCitation[];
++ timeline: MemeCitation[];
++ examples: MemeExample[];
++ spread: MemeSpreadPoint[];
++ variations: MemeVariation[];
++ related: MemeRelated[];
++ revisions: MemeRevision[];
++}
++
++export interface MemeSearchQuery extends MemeOptions {
++ scope?: MemeScope;
++ q?: string;
++ status?: MemeStatus;
++ label?: MemeLabel;
++ kind?: string;
++ sort?: 'newest' | 'top';
++ window?: 'day' | 'week' | 'month' | 'all';
++ channel?: number | string;
++ limit?: number;
++ cursor?: string;
++}
++export interface MemeSearchResult { count: number; entries: MemeEntry[]; next_cursor: string | null; }
++
++export interface MemeTrendPost {
++ id: string; subreddit: string; title: string; author: string | null; permalink: string;
++ url: string | null; image_url: string | null; media_id: string | null; phash: string | null;
++ score: number; comments: number; over_18: boolean; created_at: string; last_seen_at: string;
++ velocity: number; velocity_basis: 'samples' | 'score_age'; velocity_samples: number; score_delta: number | null;
++}
++export interface MemeTrendTemplate { id: string; label: string | null; phash: string; image_url: string | null; media_id: string | null; post_count: number; first_seen_at: string; last_seen_at: string; }
++export interface MemeTrends {
++ window: 'day' | 'week'; from: string; to: string;
++ rising: MemeTrendPost[]; templates: MemeTrendTemplate[];
++ entries: Array<Pick<MemeEntry, 'id' | 'slug' | 'title' | 'kind' | 'status' | 'first_seen_at' | 'last_seen_at'>>;
++ subreddits: Array<{ subreddit: string; posts: number; score: number; top: { id: string; title: string; score: number } | null }>;
++}
++
++export interface MemeAnalysisInput { url?: string; media_id?: string; mediaId?: string; }
++export interface MemeAnalysisOrigin { title: string; url: string | null; year: number | null; confidence: number; why: string | null; entry_slug: string | null; }
++export interface MemeAnalysis {
++ id: string;
++ input: { url: string | null; media_id: string | null };
++ template: { name: string | null; known: boolean; template_id: string | null; template: MemeTemplate | null; closest_entry: MemeRelated | null };
++ matched_entries: Array<MemeRelated & { kind: string; match: number }>;
++ origins: MemeAnalysisOrigin[];
++ meaning: string | null;
++ usage: string | null;
++ labels: MemeLabel[];
++ vision: { alt: string; caption: string; tags: string[] } | null;
++ reddit: {
++ spread: { subreddit: string | null; score: number; upvote_ratio: number; comments: number; created_at: string | null; permalink: string | null; crossposts: number; over_18: boolean };
++ post: { id: string; title: string; permalink: string | null };
++ comments_tone: { tone: 'positive' | 'negative' | 'mixed' | 'neutral' | 'ironic'; summary: string } | null;
++ comments_sampled: number;
++ } | null;
++ similar: string[];
++ cost_usd: number;
++ created_at: string;
++}
++
++export interface MemeResearchInput { topic: string; }
++export interface MemeResearchSource { n: number; title: string; url: string; provider: string; license: string | null; retrieved_at: string | null; snippet: string; }
++export interface MemeResearch {
++ id: string;
++ topic: string;
++ status: 'queued' | 'running' | 'succeeded' | 'failed';
++ title: string | null;
++ summary: string | null;
++ body_markdown?: string | null;
++ sources: MemeResearchSource[];
++ facts: Array<{ label: string; value: string }>;
++ citations: MemeResearchSource[];
++ cost_usd: number;
++ error: { code: string; detail: string | null } | null;
++ entry_slug: string | null;
++ created_at: string;
++ started_at: string | null;
++ finished_at: string | null;
++ subject?: string;
++}
++
++export interface MemeTemplate { id: string; name: string; image_url: string; box_count: number; source: string; license: string; layout?: Record<string, unknown> | null; created_at?: string; }
++export interface MemeGenerateInput { template?: string; template_slug?: string; topic?: string; lines?: string[]; }
++export interface MemeGenerateResult {
++ media_id: string;
++ media_url: string;
++ captions: string[];
++ template: MemeTemplate;
++ labels: MemeLabel[];
++ caption: { id: string; subject: string; template_id: string; lines: string[]; created_at: string; visibility: string; labels?: MemeLabel[]; media_id?: string | null };
++ cost_usd: number;
++}
++
++export interface MemeClient {
++ search(query?: MemeSearchQuery): Promise<MemeSearchResult>;
++ entry(slug: string, opts?: MemeOptions): Promise<MemeEntryDetail>;
++ revisions(slug: string, opts?: MemeOptions): Promise<{ slug: string; count: number; revisions: MemeRevision[] }>;
++ trends(opts?: { window?: 'day' | 'week'; limit?: number } & MemeOptions): Promise<MemeTrends>;
++ analyze(input: MemeAnalysisInput, opts?: MemeOptions): Promise<MemeAnalysis>;
++ research(topic: string, opts?: MemeOptions): Promise<MemeResearch>;
++ researchGet(id: string, opts?: MemeOptions): Promise<MemeResearch>;
++ saveResearch(id: string, opts?: MemeOptions): Promise<{ entry: MemeEntry; already: boolean }>;
++ generate(input: MemeGenerateInput, opts?: MemeOptions): Promise<MemeGenerateResult>;
++}
++
++export function createMemeClient(client: OpenVibeClient, defaults?: MemeDefaults): MemeClient;