openvibers/openvibe.sdk Commits
openvibe-sdk/meme and openvibe-sdk/app-signin (with #107 and #101 brought onto main) (#112)
Changes
@@@ -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 })` (thecard's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does@@@ -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'),@@@ -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 = 'openmodule.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 = 'opemodule.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;@@@ -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"},nnew file mode 100644@@@ -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;@@@ -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;nnew file mode 100644@@@ -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;@@@ -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'),@@@ -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,@@@ -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',nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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'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();++ }],++]);@@@ -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 Windowsfor (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`);nnew file mode 100644@@@ -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');++ }],++]);@@@ -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/memeassert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -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;@@@ -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;@@@ -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';nnew file mode 100644@@@ -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;
@@ -12,6 +12,28 @@
@@ -12,6 +12,28 @@
- `people`, `patchPerson` (rename or mute), `removePerson`, `requestConsent`, `checkConsent`, `acceptConsent` and
- `people`, `patchPerson` (rename or mute), `removePerson`, `requestConsent`, `checkConsent`, `acceptConsent` and
`denyConsent`.
`denyConsent`.
- The types in `types/vision.d.ts` say what the JSON says; no template, crop or other library is ever in an answer.
- 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),
- **`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
`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
card's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does
@@ -16,6 +16,7 @@ module.exports = {
@@ -16,6 +16,7 @@ module.exports = {
bot: require('./src/bot'),
bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
bench: require('./src/bench'),
bench: require('./src/bench'),
+ meme: require('./src/meme'),
vision: require('./src/vision'),
vision: require('./src/vision'),
themes: require('./src/themes'),
themes: require('./src/themes'),
redact: require('./src/redact'),
redact: require('./src/redact'),
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.80.0, MIT). Do not edit.
// 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
// 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 */
/* eslint-disable */
const __defs = [
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
// ── 0: browser.js ──────────────────────────────────────────────────
@@ -23,15 +23,16 @@ module.exports = {
@@ -23,15 +23,16 @@ module.exports = {
bot: __load(15),
bot: __load(15),
search: __load(16),
search: __load(16),
bench: __load(17),
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 ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -2194,7 +2195,75 @@ function createBenchClient(client, defaults = {}) {
@@ -2194,7 +2195,75 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
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) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2352,7 +2421,7 @@ function pick(o, keys) {
@@ -2352,7 +2421,7 @@ function pick(o, keys) {
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
},
},
-// ── 19: src/themes.js ───────────────────────────────────────────────
+// ── 20: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2502,7 +2571,7 @@ function followTheme(fn, opts = {}) {
@@ -2502,7 +2571,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
},
-// ── 20: src/redact.js ───────────────────────────────────────────────
+// ── 21: src/redact.js ───────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2749,7 +2818,7 @@ function onlySecret(text) {
@@ -2749,7 +2818,7 @@ function onlySecret(text) {
module.exports = { redact, onlySecret };
module.exports = { redact, onlySecret };
},
},
-// ── 21: src/chat.js ─────────────────────────────────────────────────
+// ── 22: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2991,7 +3060,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
@@ -2991,7 +3060,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
},
-// ── 22: src/ai.js ───────────────────────────────────────────────────
+// ── 23: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3066,7 +3135,7 @@ function createAiClient(client, defaults = {}) {
@@ -3066,7 +3135,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
module.exports = { createAiClient, TERMINAL, OPS };
},
},
-// ── 23: src/jobs.js ─────────────────────────────────────────────────
+// ── 24: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3104,7 +3173,7 @@ function (module, exports) {
@@ -3104,7 +3173,7 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
const { newIdempotencyKey } = __load(5);
-const { appendFiles } = __load(24);
+const { appendFiles } = __load(25);
const { parseSSE } = __load(11);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@ -3268,7 +3337,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
@@ -3268,7 +3337,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
},
-// ── 24: src/core/form.js ────────────────────────────────────────────
+// ── 25: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3302,7 +3371,7 @@ function appendFiles(form, list, where) {
@@ -3302,7 +3371,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
module.exports = { isBlob, isUpload, appendFiles };
},
},
-// ── 25: src/tools.js ────────────────────────────────────────────────
+// ── 26: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3336,8 +3405,8 @@ function (module, exports) {
@@ -3336,8 +3405,8 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
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 enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@ -3587,7 +3656,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
@@ -3587,7 +3656,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
},
-// ── 26: src/projects.js ─────────────────────────────────────────────
+// ── 27: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3703,7 +3772,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
@@ -3703,7 +3772,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
module.exports = { createProjectsClient };
},
},
-// ── 27: src/geo.js ──────────────────────────────────────────────────
+// ── 28: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3783,7 +3852,7 @@ export const {
@@ -3783,7 +3852,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, bot,
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;
} = sdk;
export default sdk;
export default sdk;
@@ -165,6 +165,11 @@
@@ -165,6 +165,11 @@
"mediaUrls"
"mediaUrls"
]
]
},
},
+ "meme": {
+ "object": [
+ "createMemeClient"
+ ]
+ },
"modules": {
"modules": {
"object": [
"object": [
"createModulesClient"
"createModulesClient"
@@ -355,6 +360,10 @@
@@ -355,6 +360,10 @@
"TERMINAL": "object",
"TERMINAL": "object",
"createAiClient": "function"
"createAiClient": "function"
},
},
+ "openvibe-sdk/app-signin": {
+ "createAppSignIn": "function",
+ "normalizeScope": "function"
+ },
"openvibe-sdk/auth": {
"openvibe-sdk/auth": {
"TOKEN_VALID_AFTER": "string",
"TOKEN_VALID_AFTER": "string",
"base64url": "function",
"base64url": "function",
@@ -582,6 +591,9 @@
@@ -582,6 +591,9 @@
"createObjectsClient": "function",
"createObjectsClient": "function",
"mediaUrls": "function"
"mediaUrls": "function"
},
},
+ "openvibe-sdk/meme": {
+ "createMemeClient": "function"
+ },
"openvibe-sdk/modules": {
"openvibe-sdk/modules": {
"createModulesClient": "function"
"createModulesClient": "function"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
@@ -6,12 +6,12 @@ export const {
@@ -6,12 +6,12 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
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;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
@@ -19,6 +19,7 @@ module.exports = {
@@ -19,6 +19,7 @@ module.exports = {
bot: require('./src/bot'),
bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
bench: require('./src/bench'),
bench: require('./src/bench'),
+ meme: require('./src/meme'),
vision: require('./src/vision'),
vision: require('./src/vision'),
themes: require('./src/themes'),
themes: require('./src/themes'),
redact: require('./src/redact'),
redact: require('./src/redact'),
@@ -72,6 +72,12 @@
@@ -72,6 +72,12 @@
"import": "./esm/media.mjs",
"import": "./esm/media.mjs",
"default": "./src/media.js"
"default": "./src/media.js"
},
},
+ "./meme": {
+ "types": "./types/meme.d.ts",
+ "browser": "./src/meme.js",
+ "import": "./esm/meme.mjs",
+ "default": "./src/meme.js"
+ },
"./community": {
"./community": {
"types": "./types/community.d.ts",
"types": "./types/community.d.ts",
"import": "./esm/community.mjs",
"import": "./esm/community.mjs",
@@ -305,6 +311,12 @@
@@ -305,6 +311,12 @@
"import": "./esm/actor.mjs",
"import": "./esm/actor.mjs",
"default": "./src/actor.js"
"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": {
"./run": {
"types": "./types/run.d.ts",
"types": "./types/run.d.ts",
"browser": null,
"browser": null,
@@ -22,6 +22,7 @@ const ENTRIES = {
@@ -22,6 +22,7 @@ const ENTRIES = {
'modules.mjs': '../src/modules.js',
'modules.mjs': '../src/modules.js',
'events.mjs': '../src/events.js',
'events.mjs': '../src/events.js',
'sso.mjs': '../src/sso/index.js',
'sso.mjs': '../src/sso/index.js',
+ 'app-signin.mjs': '../src/app-signin/index.js',
'realtime.mjs': '../src/realtime.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'community.mjs': '../src/community.js',
@@ -29,6 +30,7 @@ const ENTRIES = {
@@ -29,6 +30,7 @@ const ENTRIES = {
'machines.mjs': '../src/machines.js',
'machines.mjs': '../src/machines.js',
'search.mjs': '../src/search.js',
'search.mjs': '../src/search.js',
'bench.mjs': '../src/bench.js',
'bench.mjs': '../src/bench.js',
+ 'meme.mjs': '../src/meme.js',
'themes.mjs': '../src/themes.js',
'themes.mjs': '../src/themes.js',
'limits.mjs': '../src/limits.js',
'limits.mjs': '../src/limits.js',
'account-data.mjs': '../src/account-data.js',
'account-data.mjs': '../src/account-data.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,325 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -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'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();
+ }],
+]);
@@ -76,12 +76,12 @@ run([
@@ -76,12 +76,12 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
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
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']) {
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`);
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`);
assert.ok(!rel.includes(never), `${never} is not reachable from a browser entry`);
}
}
console.log(` scanned ${scanned.size} files`);
console.log(` scanned ${scanned.size} files`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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');
+ }],
+]);
@@ -68,7 +68,7 @@ run([
@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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;
@@ -8,6 +8,7 @@ import * as community from './community';
@@ -8,6 +8,7 @@ import * as community from './community';
import * as bot from './bot';
import * as bot from './bot';
import * as search from './search';
import * as search from './search';
import * as bench from './bench';
import * as bench from './bench';
+import * as meme from './meme';
import * as vision from './vision';
import * as vision from './vision';
import * as themes from './themes';
import * as themes from './themes';
import * as redact from './redact';
import * as redact from './redact';
@@ -29,6 +30,7 @@ export * as community from './community';
@@ -29,6 +30,7 @@ export * as community from './community';
export * as bot from './bot';
export * as bot from './bot';
export * as search from './search';
export * as search from './search';
export * as bench from './bench';
export * as bench from './bench';
+export * as meme from './meme';
export * as vision from './vision';
export * as vision from './vision';
export * as themes from './themes';
export * as themes from './themes';
export * as redact from './redact';
export * as redact from './redact';
@@ -41,6 +43,6 @@ export * as geo from './geo';
@@ -41,6 +43,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
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;
export default sdk;
@@ -26,6 +26,7 @@ export * as inventory from './inventory';
@@ -26,6 +26,7 @@ export * as inventory from './inventory';
export * as tips from './tips';
export * as tips from './tips';
export * as blog from './blog';
export * as blog from './blog';
export * as reviews from './reviews';
export * as reviews from './reviews';
+export * as meme from './meme';
export * as wiki from './wiki';
export * as wiki from './wiki';
export * as coupons from './coupons';
export * as coupons from './coupons';
export * as deals from './deals';
export * as deals from './deals';
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -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;