openvibers/openvibe.blog Commits

Members-only posts through OpenVibe.VIP: the blog owner's members read, everyone else gets a teaser and a join link, never the body

OP OpenVibers committed 23 Sept 2026 · 95e99acdcf · 18 files, +683 −61

- BLOG_ENTITLEMENTS_PROVIDER=vip (the default when OV_OAUTH_CLIENT_SECRET is set; 'none' otherwise): server/clients/vip.js asks VIP's POST /api/v1/policies/evaluate with resource { blog, post, id }, owner = the blog's owner and Blog's default gate fallback { requirement: member, binding: blog:gated_post } (an active member of the owner, or with the owner's gated_post perk when one exists; the owner's own VIP rule wins), through createVipCache. Client-credentials token for audience openvibe.vip, scope vip.resource.policy.evaluate. Every doubt (VIP down, refused token, no grant, no owner) refuses. The official blog has no VIP owner: its members-only posts admit only its members and staff.
- The client is openvibe-vip/client vendored verbatim in server/vip/vip-client.js (OpenVibe.VIP 2accbeb) until VIP publishes a tag to pin.
- The entitlement seam takes { subject, blog, post } and answers { allow, reason }; canReadPost carries VIP's reason. entitlement_key defaults to vip:<handle> and is informational (it names the gate in document ACLs); VIP decides.
- Readers without access get the teaser (title, the author's summary, never text from the body) and a join link (OV_VIP_URL/<owner username>): the page (403, private, noindex), the .json twin (403 { teaser }) and GET /api/v1/posts/:id (403 problem with teaser). Feeds, sitemaps and listings stay public-only; Search keeps getting tombstones; the product events' document for a members post now carries the teaser, not the body.
- Convergence bound: a cached "yes" lives at most BLOG_VIP_CACHE_TTL_MS (30 s, never past the entitlement's expiry), a "no" 10 s, a failure 2 s; Blog stops admitting a reader within 30 s after VIP stops granting, and at once when vip.membership.changed is handed to ctx.vip.cache.handleEvent (Blog does not subscribe to Events yet).
- test/members-vip.test.js (stub VIP): the owner, resource, fallback and token audience/capability sent; members read; strangers, signed-out readers and a VIP outage get the teaser and join link with a body sentinel absent from pages, .json, API, feeds, sitemaps, llms.txt, Search and every outbox event; owner and staff read without VIP; the official blog refuses without asking VIP; convergence inside/past the TTL, on the event, and a new member within the deny TTL. privacy.test.js follows the teaser and the new seam.
- Editor help text, README (members-only posts, the grant, the bound), .env.example, STATUS.json and the service manifest note.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3248db8..978a2d5 100644
---- a/.env.example
++++ b/.env.example
@@@ -54,9 +54,17 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
#BLOG_SCHEDULE_INTERVAL_MS=15000
#BLOG_WORKER_ID=
--# Members-only (VIP) posts: 'none' = no entitlement service yet → fails closed
--# (only the blog's members and staff can read them)
--#BLOG_ENTITLEMENTS_PROVIDER=none
++# Members-only posts: 'vip' (default when OV_OAUTH_CLIENT_SECRET is set) asks OpenVibe.VIP
++# (grant vip.resource.policy.evaluate on openvibe.vip); 'none' admits only the blog's members and
++# staff. Every doubt (VIP down, no grant, no owner) is a refusal.
++#BLOG_ENTITLEMENTS_PROVIDER=vip
++#OV_VIP_INTERNAL_URL=http://127.0.0.1:4620
++#OV_VIP_URL=https://openvibe.vip
++#BLOG_VIP_TIMEOUT_MS=2000
++# A cached "yes" lives at most this long (the convergence bound after VIP stops granting)
++#BLOG_VIP_CACHE_TTL_MS=30000
++#BLOG_VIP_CACHE_DENY_TTL_MS=10000
++#BLOG_VIP_CACHE_UNAVAILABLE_TTL_MS=2000
# Browser origins that may call /api/v1 with a Bearer Network JWT
#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media
ddiff --git a/README.md b/README.md
iindex 4364f9b..e92518f 100644
---- a/README.md
++++ b/README.md
@@@ -20,7 +20,7 @@ It also uses:
- Community for comments
- OpenVibe.AI's `blog.draft_post` output, as drafts that need review
- openvibe-shared theme presets
--- an optional VIP gate: members-only posts with an entitlement check that fails closed
++- a VIP gate: members-only posts decided by OpenVibe.VIP (the blog owner's members), teaser and join link for everyone else, failing closed
## Owns
@@@ -94,11 +94,40 @@ Other tables in the same database:
noindex and can't be published or scheduled until a person records an approving review. A person
editing it makes a hybrid (AI-assisted) revision. The disclosure appears on the page, in the JSON
and in the Search document.
--- **Members-only (VIP) posts:**
-- - Visibility `members` with an `entitlement_key` in the ACL.
-- - The entitlement seam (`server/domain/access.js`) admits nobody while
-- `BLOG_ENTITLEMENTS_PROVIDER=none`, the default: OpenVibe.VIP runs loopback-only on the host but
-- Blog has no VIP client yet. Only the blog's members and staff can read these posts.
++- **Members-only posts (OpenVibe.VIP, the `blog gated_post` binding):**
++ - Visibility `members`. The blog's own members (owner, editors, authors of the post) and staff
++ read as always. Anyone else is admitted only when OpenVibe.VIP says so:
++ `POST /api/v1/policies/evaluate` with `resource { service: 'blog', type: 'post', id }`,
++ `owner` = the blog's owner (`blogs.owner_subject`) and Blog's default gate
++ `fallback { requirement: 'member', binding: 'blog:gated_post' }` — an active member of the owner
++ or, if the owner defines a perk bound to `blog gated_post`, a member whose plan version includes
++ it. A rule the owner sets for the post on openvibe.vip takes precedence.
++ ([server/clients/vip.js](server/clients/vip.js), the seam in `server/domain/access.js`.)
++ - `BLOG_ENTITLEMENTS_PROVIDER`: `vip` is the default when `OV_OAUTH_CLIENT_SECRET` is set, else
++ `none` (only the blog's members and staff). Every doubt — VIP down, a refused token, no grant,
++ no owner — is a refusal.
++ - The official blog has no owner in VIP (its owners are memberships), so its members-only posts
++ admit nobody beyond its members and staff.
++ - Everyone else gets a **teaser**: the title, the author's summary (never text taken from the
++ body; write a summary to have one) and a join link to the owner's plans
++ (`OV_VIP_URL/<owner username>`), on the page (403), the `.json` twin (403 `{ teaser }`) and
++ `GET /api/v1/posts/:id` (403 problem with `teaser`). Feeds, sitemaps and listings for outsiders
++ carry public posts only; Search gets a tombstone; the product events' document carries the
++ teaser, never the body.
++ - `entitlement_key` (default `vip:<handle>`) is informational: it names the gate in the members
++ ACL of product-event documents. VIP decides.
++ - The client is `openvibe-vip/client`, vendored verbatim in
++ [server/vip/vip-client.js](server/vip/vip-client.js) (from OpenVibe.VIP 2accbeb) until VIP
++ publishes a tag to pin.
++ - **Convergence bound.** Answers are cached (`createVipCache`): a "yes" at most
++ `BLOG_VIP_CACHE_TTL_MS` (30 s, and never past the entitlement's expiry), a "no"
++ `BLOG_VIP_CACHE_DENY_TTL_MS` (10 s: a new member waits at most that long), a failure
++ `BLOG_VIP_CACHE_UNAVAILABLE_TTL_MS` (2 s, and it never extends a "yes"). So once VIP stops
++ granting (it applied `billing.entitlement.changed` and emitted `vip.membership.changed`), Blog
++ stops admitting that reader within 30 s. Blog does not subscribe to Events yet; a delivery
++ handed to `ctx.vip.cache.handleEvent` refuses at once. End to end (VIP's own bound when events
++ are lost) see OpenVibe.VIP's README, "The product cache and the convergence bound".
++ `test/members-vip.test.js` proves both.
### Routes (server-rendered, useful without JavaScript)
@@@ -183,8 +212,8 @@ repo pins v0.19.0.
- **OpenVibe.Events:** `events.event.publish`.
- **OpenVibe.Search:** consumes `blog.index_document.*` through its `*.index_document.*`
subscription. `blog` is already in Search's default `SEARCH_EVENT_OWNERS`.
--- **Optional:** an entitlement service (OpenVibe.VIP or Billing's `billing.entitlement.check`) for
-- members-only posts. Until one exists, the gate fails closed.
++- **OpenVibe.VIP:** `vip.resource.policy.evaluate` for members-only posts
++ (`OV_VIP_INTERNAL_URL`, default `http://127.0.0.1:4620`). Without it the gate fails closed.
### Grants the Network must hold for client `blog`
@@@ -196,6 +225,8 @@ Each grant is `[client, capability, audience]`:
- `[blog, community.comment.moderate, openvibe.community]` (optional: hides the threads of deleted
or no-longer-public posts)
- `[blog, media.object.read, openvibe.media]`, namespace `blog`. Media also needs a `blog` tenant.
++- `[blog, vip.resource.policy.evaluate, openvibe.vip]` for members-only posts (until granted, only the
++ blog's members and staff read them).
- For OpenVibe.AI to deliver drafts: `[ai, blog.post.create, openvibe.blog]`. Add
`[ai, blog.post.read, openvibe.blog]` if it reads the drafts back.
@@@ -208,6 +239,7 @@ Each grant is `[client, capability, audience]`:
| Feeds, canonical HTML, sitemap and Search converge on publication state: publish, revise, unpublish and delete. | `test/lifecycle.test.js` |
| Feeds and canonical URLs stay stable across slug changes: the feed id is unchanged, old paths 301 and chains collapse. | `test/slug-media.test.js` |
| VIP-only and private posts never leak through cache headers, Search, feeds or sitemaps. Visibility changes tombstone with a higher revision, and the entitlement seam fails closed. | `test/privacy.test.js` |
++| Members-only posts through OpenVibe.VIP: the owner, resource and default gate are sent; members read; strangers, signed-out readers and a VIP outage get the teaser and join link, and the body is absent from pages, `.json`, API, feeds, sitemaps, Search and product events; a cached yes ends within the TTL and at once on `vip.membership.changed`. | `test/members-vip.test.js` |
| A deleted Media object yields an explicit broken-asset state in the page, JSON, feed and editor, and an outage is not a deletion. | `test/slug-media.test.js` |
| Useful without JavaScript: write, edit, publish, comment and configure with forms only. Form tokens, the 412 message and Community threads referenced, never copied. | `test/nojs-editor.test.js` |
| Capability-guarded service tokens, X-OV-Subject membership, AI drafts that need a person's review, and problem+json with request ids. | `test/api-ai.test.js` |
@@@ -272,12 +304,12 @@ its commit or tag, date and GitHub URL, and is attached as a citation.
- Links on member blogs get `rel="nofollow ugc noopener"`.
- CSP from helmet.
- **Private content:** read decisions live in `server/domain/access.js`. 404 hides drafts and
-- private posts, and 403 applies to members-only posts. A redirect never reveals the new slug of a
++ private posts, and 403 (with the teaser, never the body) applies to members-only posts. A redirect never reveals the new slug of a
post the reader may not see.
- **Leaks:** see the caching and events sections above. The product events carry no body. Search
gets bodies only for public, listable posts.
- **SSRF:** Blog makes no outbound calls to user-chosen URLs. It calls only its configured Network,
-- Community and Media hosts.
++ Community, Media and VIP hosts.
- **AI:** output is never attributed to a person. It needs a person's (`usr_`) review before
publication or indexing, and only a signed-in person can record a review.
- **Abuse:** rate limits on `/auth`, `/write` and `/api/v1`, both in Express and in the nginx
ddiff --git a/STATUS.json b/STATUS.json
iindex 2fc0600..c3d6cf2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -29,7 +29,7 @@
],
"capabilitiesRegistered": true,
"serviceManifestRegistered": true,
-- "entitlements": "none wired (members-only posts fail closed; OpenVibe.VIP runs loopback-only on the host but Blog has no VIP client yet)",
++ "entitlements": "OpenVibe.VIP in code (policies/evaluate with the blog owner and the blog:gated_post default gate, cached 30 s; teaser and join link otherwise). Not deployed yet, and the Network has not granted blog vip.resource.policy.evaluate: until both, members-only posts admit only the blog's members and staff",
"seedPost": "draft in production (verbatim quotes; not reviewed by a person); 0 posts published",
"deployed": true,
"deployment": "Public at https://openvibe.blog since 2026-09-23 (~03:00 UTC): /opt/openvibe.blog at e7b4986, unit openvibe-blog on 127.0.0.1:4810 behind the openvibe.blog nginx vhost, principal blog. Production content: 1 blog (official), 1 post, which is a draft, so the public site shows nothing published yet. Restore drill passed 2026-09-23.",
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex c7e7b3b..e897100 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -41,5 +41,5 @@
"namespacesOwned": [
"blog.*"
],
-- "notes": "Port 4810. Owns blogs, blog_memberships, blog_posts, blog_post_revisions, blog_series, blog_taxonomy, blog_post_terms, blog_schedules, blog_redirects, blog_feed_settings in its own SQLite (openvibe-publishing packages, prefix blog). Consumes OpenVibe.Network (SSO, identity.subject.resolve), OpenVibe.Community (community.comment.write, community.comment.moderate), OpenVibe.Media (media.object.read, namespace blog) and publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads blog.index_document.* from Events."
++ "notes": "Port 4810. Owns blogs, blog_memberships, blog_posts, blog_post_revisions, blog_series, blog_taxonomy, blog_post_terms, blog_schedules, blog_redirects, blog_feed_settings in its own SQLite (openvibe-publishing packages, prefix blog). Consumes OpenVibe.Network (SSO, identity.subject.resolve), OpenVibe.Community (community.comment.write, community.comment.moderate), OpenVibe.Media (media.object.read, namespace blog), OpenVibe.VIP (vip.resource.policy.evaluate, members-only posts) and publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads blog.index_document.* from Events."
}
ddiff --git a/server/app.js b/server/app.js
iindex 86b4433..9c3c876 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,7 @@ const { createEffects } = require('./domain/effects');
const { createPeople } = require('./clients/network');
const { createCommunity } = require('./clients/community');
const { createMedia } = require('./clients/media');
++const { createVip } = require('./clients/vip');
const { createBlogOutbox } = require('./events/outbox');
const { createPublicRoutes } = require('./http/public');
const { createEditorRoutes } = require('./http/editor');
@@@ -42,7 +43,7 @@ const VERSION = require('../package.json').version;
/**
* opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),
-- * entitlementCheck ({ subject, key }) → bool, log
++ * entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log
*/
function createApp(opts = {}) {
const config = opts.config || configLib.load();
@@@ -57,15 +58,16 @@ function createApp(opts = {}) {
const people = createPeople({ store, config, fetchImpl });
const community = createCommunity({ store, config, fetchImpl });
const media = createMedia({ config, fetchImpl });
-- const reading = createReading({ store, blogs, posts, publication, people, media });
++ const vip = createVip({ config, fetchImpl, now: store.now, log });
++ const reading = createReading({ store, blogs, posts, publication, people, media, vip });
const effects = createEffects({ outbox, community });
-- const entitlements = access.createEntitlementChecker({ provider: config.entitlements.provider, check: opts.entitlementCheck });
++ const entitlements = access.createEntitlementChecker({ provider: config.entitlements.provider, check: opts.entitlementCheck, vip });
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, store, posts, media, outbox, log });
blogs.ensureOfficial();
-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, auth, viewers, access, worker };
++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/clients/vip.js b/server/clients/vip.js
nnew file mode 100644
iindex 0000000..a7eeb0c
---- /dev/null
++++ b/server/clients/vip.js
@@@ -0,0 +1,52 @@
++'use strict';
++
++/**
++ * OpenVibe.VIP — who may read a members-only post. Blog asks VIP's gated-resource policy
++ * (POST /api/v1/policies/evaluate, capability vip.resource.policy.evaluate, a client-credentials
++ * token for audience openvibe.vip) with:
++ *
++ * resource { service: 'blog', type: 'post', id: <post id> }
++ * owner the blog's owner (blogs.owner_subject) — the creator whose members may read
++ * fallback { requirement: 'member', binding: 'blog:gated_post' } — Blog's default gate: any active
++ * member of the owner, or, when the owner defines a perk bound to `blog gated_post`, a
++ * member whose plan version includes it. A rule the owner sets in VIP for the post wins.
++ *
++ * Answers go through createVipCache (server/vip/vip-client.js, vendored from OpenVibe.VIP): a "yes"
++ * lives at most config.vip.ttlMs (never past the entitlement's expiry), a "no" denyTtlMs, a failure
++ * unavailableTtlMs. Every failure is a refusal.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++const { createVipClient, createVipCache } = require('../vip/vip-client');
++
++const FALLBACK = Object.freeze({ requirement: 'member', binding: 'blog:gated_post' });
++
++function createVip({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const v = config.vip;
++ const enabled = Boolean(config.oauth.clientSecret);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate', fetchImpl,
++ }) : null;
++ const client = enabled ? createVipClient({ baseUrl: v.internalUrl, tokenClient: tokens, fetch: fetchImpl, timeoutMs: v.timeoutMs, log }) : null;
++ const cache = client ? createVipCache({ vip: client, ttlMs: v.ttlMs, denyTtlMs: v.denyTtlMs, unavailableTtlMs: v.unavailableTtlMs, now }) : null;
++
++ /** May `subject` read members-only `post` of `blog`? → { allow, reason } (never throws). */
++ async function decide({ subject, blog, post }) {
++ if (!cache) return { allow: false, reason: 'vip_not_configured' };
++ if (!blog || !blog.owner_subject) return { allow: false, reason: 'no_owner' };
++ if (!subject) return { allow: false, reason: 'not_signed_in' };
++ try {
++ const d = await cache.evaluate({ subject, resource: { service: 'blog', type: 'post', id: String(post.id) }, owner: blog.owner_subject, fallback: FALLBACK });
++ return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
++ } catch (err) {
++ return { allow: false, reason: 'vip_unavailable', error: err.message };
++ }
++ }
++
++ /** The page where a reader joins the owner's plans (their VIP handle, else their subject). */
++ const joinUrl = (owner) => (owner && (owner.username || owner.subject) ? `${v.publicUrl}/${encodeURIComponent(owner.username || owner.subject)}` : v.publicUrl);
++
++ return { enabled, client, cache, decide, joinUrl, FALLBACK };
++}
++
++module.exports = { createVip, FALLBACK };
ddiff --git a/server/config.js b/server/config.js
iindex 6864ddb..73254ba 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -82,9 +82,23 @@ function load(env = process.env) {
id: env.BLOG_WORKER_ID || `blog-${process.pid}`,
},
-- // Entitlement checks for members-only (VIP) posts. 'none' (default): no entitlement service
-- // exists yet, so the check fails closed — only the post's blog members and staff can read.
-- entitlements: { provider: env.BLOG_ENTITLEMENTS_PROVIDER || 'none' },
++ // Entitlement checks for members-only posts. 'vip' (the default when Blog has a client secret):
++ // OpenVibe.VIP decides (policies/evaluate with the blog owner as owner and the product's default
++ // gate { member, blog:gated_post }), through a cache. 'none' (the default without a secret):
++ // nobody outside the blog's members and staff reads a members-only post. Both fail closed.
++ entitlements: { provider: env.BLOG_ENTITLEMENTS_PROVIDER || (env.OV_OAUTH_CLIENT_SECRET ? 'vip' : 'none') },
++
++ // OpenVibe.VIP: the service Blog asks (loopback) and the public site readers join on. A cached
++ // "yes" lives BLOG_VIP_CACHE_TTL_MS at most — the bound on how long Blog keeps admitting a
++ // reader after VIP stops (README, "Members-only posts").
++ vip: {
++ internalUrl: trim(env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620'),
++ publicUrl: trim(env.OV_VIP_URL || 'https://openvibe.vip'),
++ timeoutMs: int(env.BLOG_VIP_TIMEOUT_MS, 2000),
++ ttlMs: int(env.BLOG_VIP_CACHE_TTL_MS, 30_000),
++ denyTtlMs: int(env.BLOG_VIP_CACHE_DENY_TTL_MS, 10_000),
++ unavailableTtlMs: int(env.BLOG_VIP_CACHE_UNAVAILABLE_TTL_MS, 2_000),
++ },
// Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media'),
ddiff --git a/server/domain/access.js b/server/domain/access.js
iindex d73faea..efb6cde 100644
---- a/server/domain/access.js
++++ b/server/domain/access.js
@@@ -20,8 +20,9 @@
* Reading (visibility of a post):
* public everyone once published
* unlisted everyone with the link once published; never listed, fed, mapped or indexed
-- * members the blog's members, staff, and viewers the entitlement check admits (VIP). No
-- * entitlement service exists yet: the default checker admits nobody (fails closed)
++ * members the blog's members, staff, and viewers OpenVibe.VIP admits as members of the blog's
++ * owner (the entitlement checker below). Everyone else gets a teaser, never the body.
++ * The official blog has no owner in VIP: its members-only posts admit nobody else
* private the blog's members and staff
* Anything not published (draft, scheduled, unpublished) is readable by the blog's members
* (authors: their own posts) and staff only. Deleted posts are readable by nobody.
@@@ -56,30 +57,48 @@ function memberCanSee(store, viewer, blog, post) {
/**
* The entitlement seam for members-only (VIP) posts.
-- * check({ subject, key }) → true | false (never throws: an error counts as "no")
-- * 'none' — no entitlement service exists yet, so nobody outside the blog's members and staff is
-- * admitted. A cached badge or a client claim is never authorization.
++ * decide({ subject, blog, post }) → { allow, reason } never throws: an error is a "no"
++ * has({ subject, blog, post }) → true | false
++ * Providers:
++ * 'vip' OpenVibe.VIP decides (server/clients/vip.js: policies/evaluate, owner = the blog's owner,
++ * Blog's default gate member + blog:gated_post), through the VIP cache
++ * 'none' nobody outside the blog's members and staff is admitted
++ * check (tests) a function ({ subject, key, blog, post }) → true
++ * A cached badge or a client claim is never authorization. post.entitlement_key is informational
++ * (it names the gate in Search ACLs); VIP decides.
*/
--function createEntitlementChecker({ provider = 'none', check = null } = {}) {
++function createEntitlementChecker({ provider = 'none', check = null, vip = null } = {}) {
++ const wrap = (name, decide) => ({
++ provider: name,
++ async decide(args) {
++ if (!args || !args.subject) return { allow: false, reason: 'not_signed_in' };
++ try {
++ const d = await decide(args);
++ return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
++ } catch { return { allow: false, reason: 'error' }; }
++ },
++ async has(args) { return (await this.decide(args)).allow; },
++ });
if (typeof check === 'function') {
-- return {
-- provider: 'custom',
-- async has(subject, key) {
-- if (!subject || !key) return false;
-- try { return (await check({ subject, key })) === true; } catch { return false; }
-- },
-- };
++ return wrap('custom', async ({ subject, blog, post }) => ((await check({ subject, key: post && post.entitlement_key, blog, post })) === true
++ ? { allow: true, reason: 'member' } : { allow: false, reason: 'not_a_member' }));
++ }
++ if (provider === 'vip') {
++ if (vip && vip.enabled) return wrap('vip', (args) => vip.decide(args));
++ console.warn('[Blog] BLOG_ENTITLEMENTS_PROVIDER=vip needs OV_OAUTH_CLIENT_SECRET; members-only posts stay closed');
++ return wrap('none', async () => ({ allow: false, reason: 'vip_not_configured' }));
}
if (provider !== 'none') {
// A provider name we do not implement must not silently admit anyone.
console.warn(`[Blog] BLOG_ENTITLEMENTS_PROVIDER=${provider} is not implemented; members-only posts stay closed`);
}
-- return { provider: 'none', async has() { return false; } };
++ return wrap('none', async () => ({ allow: false, reason: 'no_entitlement_service' }));
}
/**
-- * Read decision for one post. → { allowed, status, reason }
-- * status 404 hides existence (drafts, private, deleted); 403 says "members only".
++ * Read decision for one post. → { allowed, status, reason, vip? }
++ * status 404 hides existence (drafts, private, deleted); 403 says "members only" (the caller shows
++ * a teaser and a join link, never the body); `vip` is the entitlement answer's reason.
*/
async function canReadPost(store, viewer, blog, post, entitlements) {
if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
@@@ -90,10 +109,9 @@ async function canReadPost(store, viewer, blog, post, entitlements) {
if (member) return { allowed: true, status: 200, reason: 'member' };
if (post.visibility === 'private') return { allowed: false, status: 404, reason: 'private' };
// members (VIP): the entitlement check, failing closed.
-- if (viewer && viewer.subject && post.entitlement_key && await entitlements.has(viewer.subject, post.entitlement_key)) {
-- return { allowed: true, status: 200, reason: 'entitled' };
-- }
-- return { allowed: false, status: 403, reason: 'members_only' };
++ const d = await entitlements.decide({ subject: viewer && viewer.subject, blog, post });
++ if (d.allow) return { allowed: true, status: 200, reason: 'entitled' };
++ return { allowed: false, status: 403, reason: 'members_only', vip: d.reason };
}
/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex 9cc72f0..1707368 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -76,7 +76,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
const vis = v == null || v === '' ? 'public' : String(v);
if (!VISIBILITIES.includes(vis)) throw new ApiError(422, 'post.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
if (vis !== 'members') return { visibility: vis, entitlement_key: null };
-- const k = String(key || `blog:${blog.handle}:members`).trim().toLowerCase();
++ const k = String(key || `vip:${blog.handle}`).trim().toLowerCase(); // informational: VIP decides (domain/access.js)
if (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');
return { visibility: vis, entitlement_key: k };
}
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex c333897..fa8045e 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -9,7 +9,8 @@
*
* Search receives ONLY published, public, listable posts. Everything else (drafts, scheduled,
* unlisted, members/VIP, private, unpublished, deleted) is a tombstone, so a visibility change or
-- * an unpublish removes the old copy; a post that was never indexed gets no tombstone.
++ * an unpublish removes the old copy; a post that was never indexed gets no tombstone. The product
++ * events' document for a members-only post carries the teaser (title, summary), never the body.
*/
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
@@@ -91,6 +92,9 @@ function createPublication({ store, config, outbox }) {
const series = post.series_id ? db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;
const rec = authorshipOf(rev);
const acl = aclFor(blog, post);
++ // Members-only: whatever carries this document (the blog.post.* events) gets the teaser — the
++ // title and the author's summary — never the body. (Search itself only ever gets a tombstone.)
++ const gated = post.visibility === 'members';
const doc = hooks.buildIndexDocument({
...identity,
state: 'published',
@@@ -99,8 +103,8 @@ function createPublication({ store, config, outbox }) {
includeUnlisted: !forSearch,
canonicalUrl: postUrl(blog, post),
title: rev.fields.title,
-- summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),
-- body: ssr.markdownToText(rev.content),
++ summary: gated ? (rev.fields.summary || null) : (rev.fields.summary || ssr.markdownToText(rev.content, 300)),
++ body: gated ? '' : ssr.markdownToText(rev.content),
facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },
authorship: rec,
citations: store.citations.forRevision(post.id, rev.number),
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex cdf943c..6f75249 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -10,7 +10,7 @@ const ssr = require('openvibe-publishing/ssr');
const authorship = require('openvibe-publishing/authorship');
const { renderBody } = require('../render/pages');
--function createReading({ store, blogs, posts: postsApi, publication, people, media }) {
++function createReading({ store, blogs, posts: postsApi, publication, people, media, vip = null }) {
const { db } = store;
const blogPath = (blog) => publication.blogPath(blog);
@@@ -141,7 +141,30 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
});
}
-- return { urls, feedsOf, termsView, listItems, categoriesTree, feedItems, postJson, postJsonLd, db };
++ /**
++ * What a reader without access to a members-only post sees: the title, the author's summary (the
++ * teaser — never text taken from the body), where it lives and where to join the owner's plans on
++ * OpenVibe.VIP. The official blog has no VIP owner: no join link.
++ */
++ async function teaser(blog, post, { reason = null } = {}) {
++ const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;
++ const owner = blog.owner_subject ? await people.one(blog.owner_subject) : null;
++ return {
++ id: post.id,
++ url: publication.postUrl(blog, post),
++ blog: { id: blog.id, handle: blog.handle, title: blog.title, url: publication.abs(blogPath(blog)) },
++ title: rev ? rev.fields.title : null,
++ summary: rev ? rev.fields.summary || null : null,
++ visibility: post.visibility,
++ members_only: true,
++ published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
++ owner: owner ? { subject: owner.subject, name: owner.known ? owner.name : null, username: owner.username } : null,
++ join_url: owner && vip ? vip.joinUrl(owner) : null,
++ reason,
++ };
++ }
++
++ return { urls, feedsOf, termsView, listItems, categoriesTree, feedItems, postJson, postJsonLd, teaser, db };
}
module.exports = { createReading };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex bf8252c..7bbb54e 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -204,7 +204,9 @@ function createApi(ctx) {
const { post, blog } = mustPost(req);
if (access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: postDto(post, { full: true }) };
const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);
-- if (!d.allowed) throw new ApiError(d.status, d.status === 403 ? 'post.members_only' : 'post.not_found', d.status === 403 ? 'Members only' : 'No such post');
++ // Members only: the teaser (title, the author's summary) and the join link, never the body.
++ if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
++ if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
return { post: postDto(post) };
}));
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 79a5549..14d7ff0 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -297,6 +297,25 @@ function createPublicRoutes(ctx) {
}, { cacheable });
}
++ /**
++ * 403 for a members-only post the reader may not read: the teaser (title, the author's summary)
++ * and a join link to the owner's plans on OpenVibe.VIP — never the text. Private, no-store, noindex.
++ */
++ async function membersOnly(req, res, blog, post, decision) {
++ const teaser = await reading.teaser(blog, post, { reason: decision.vip || null });
++ if (req.path.endsWith('.json')) {
++ cacheHeaders(res, { cacheable: false, robots: 'noindex, nofollow' });
++ return res.status(403).json({ code: 'post.members_only', detail: 'Members only', teaser });
++ }
++ const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);
++ send(req, res, 403, {
++ title: teaser.title ? `${teaser.title} (members only)` : 'Members only',
++ description: teaser.summary || undefined,
++ decision: pageDecision(req.path, { indexable: false }),
++ body: pages.membersTeaser({ teaser, signedIn, loginUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}`, unavailable: decision.vip === 'vip_unavailable' || decision.vip === 'entitlement_unknown' }),
++ });
++ }
++
/** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */
async function resolvePost(req, res, blog, slug) {
const post = posts.bySlug(blog, slug);
@@@ -323,9 +342,8 @@ function createPublicRoutes(ctx) {
}
const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements);
if (!decision.allowed) {
-- if (decision.status === 403) {
-- messagePage(req, res, 403, 'Members only', 'This post is for members of this blog. Sign in with an account that has access.', { href: `/auth/login?next=${encodeURIComponent(req.originalUrl)}`, label: 'Sign in' });
-- } else notFound(req, res);
++ if (decision.status === 403) await membersOnly(req, res, blog, post, decision);
++ else notFound(req, res);
return null;
}
return post;
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex fa7144a..d01fd78 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -63,9 +63,9 @@ ${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value=
<label for="series">Series <span class="meta">(title; empty for none)</span></label><input id="series" name="series" value="${series ? series.title : ''}">
<label for="seriesPosition">Part number in the series</label><input id="seriesPosition" name="seriesPosition" type="number" min="1" value="${post && post.series_position ? post.series_position : ''}">
<label for="visibility">Who can read it</label>
--${select('visibility', [['public', 'Public'], ['unlisted', 'Unlisted (link only; never listed, fed or indexed)'], ['members', 'Members only (VIP entitlement)'], ['private', 'Private (this blog’s members)']], vis)}
--<label for="entitlementKey">Entitlement key for members-only posts</label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `blog:${blog.handle}:members`}">
--<p class="meta">Members-only posts are readable by this blog’s members and staff. Other readers need the entitlement; no entitlement service is connected yet, so nobody else is admitted.</p>
++${select('visibility', [['public', 'Public'], ['unlisted', 'Unlisted (link only; never listed, fed or indexed)'], ['members', 'Members only (the blog owner’s OpenVibe.VIP members)'], ['private', 'Private (this blog’s members)']], vis)}
++<label for="entitlementKey">Gate label for members-only posts <span class="meta">(informational; OpenVibe.VIP decides who reads)</span></label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `vip:${blog.handle}`}">
++<p class="meta">Members-only posts are readable by this blog’s members and staff, and by active OpenVibe.VIP members of the blog’s owner. If the owner defines a perk bound to <code>blog gated_post</code>, members need a plan that includes it; a rule the owner sets for the post on openvibe.vip takes precedence. Everyone else sees the title, the summary above as a teaser, and a link to join — never the text. ${blog.kind === 'official' ? 'The official blog has no VIP owner, so here nobody else is admitted.' : ''}</p>
<label><input type="checkbox" name="allowComments" value="1"${raw(!post || post.allow_comments ? ' checked' : '')}> Comments (public posts only, hosted by OpenVibe.Community)</label>
<label><input type="checkbox" name="noindex" value="1"${raw(post && post.noindex ? ' checked' : '')}> Ask search engines not to index it</label>
<input type="hidden" name="allowComments" value="0">
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex f5eebd5..6e3b6fe 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -141,8 +141,22 @@ ${categories.length ? h`<p class="categories">Filed under ${categories.map((c) =
${raw(comments ? commentsSection(comments, o) : '')}`);
}
++/** A members-only post for a reader without access: title, the author's summary, a join link — never the text. */
++function membersTeaser({ teaser, signedIn, loginUrl, unavailable }) {
++ const owner = teaser.owner && (teaser.owner.name || teaser.owner.username);
++ return String(h`<article class="post teaser" data-post-id="${teaser.id}" data-members-only="1">
++<header><h1>${teaser.title || 'Members-only post'}</h1><p class="byline"><span class="badge">Members only</span> · <a href="${teaser.blog.url}">${teaser.blog.title}</a></p></header>
++${teaser.summary ? h`<p class="summary">${teaser.summary}</p>` : ''}
++<section class="message">
++<p>${owner ? h`This post is for members of ${owner} on OpenVibe.VIP.` : 'This post is for members of this blog.'}${unavailable ? ' Memberships could not be checked just now; try again in a moment.' : ''}</p>
++${teaser.join_url ? h`<p><a class="button" href="${teaser.join_url}" rel="noopener">Join on OpenVibe.VIP</a></p>` : ''}
++${signedIn ? '' : h`<p>Already a member? <a href="${loginUrl}">Sign in</a>.</p>`}
++</section>
++</article>`);
++}
++
function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
--module.exports = { renderBody, blogIndex, collection, postPage, message, postList, categoryTree, time, dateLabel };
++module.exports = { renderBody, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };
ddiff --git a/server/vip/vip-client.js b/server/vip/vip-client.js
nnew file mode 100644
iindex 0000000..713a6c4
---- /dev/null
++++ b/server/vip/vip-client.js
@@@ -0,0 +1,235 @@
++// Vendored verbatim from OpenVibe.VIP client/vip-client.js at 2accbeb (openvibe-vip/client). Replace with the
++// pinned openvibe-vip package once VIP publishes a tag; do not edit here, change it in OpenVibe.VIP.
++'use strict';
++
++/**
++ * openvibe-vip/client — the consumer seam for products that honour VIP memberships (Blog, Wiki,
++ * Chat, Live, Community, …). No dependencies; Node 18+ (global fetch) or any fetch you pass.
++ *
++ * const { createVipClient } = require('openvibe-vip/client');
++ * const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl, clientId, clientSecret,
++ * audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
++ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
++ *
++ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject,
++ * fallback: { requirement: 'member', binding: 'blog:gated_post' } }); // the product's default gate
++ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
++ *
++ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…', product: 'chat' }); // e.active, e.product_perks
++ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt
++ *
++ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial
++ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an
++ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks
++ * OpenVibe.Billing directly instead of its projection.
++ *
++ * Put createVipCache (below) in front of it: it holds the convergence bound every product shares.
++ */
++
++function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {
++ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');
++ const base = String(baseUrl).replace(/\/+$/, '');
++
++ async function headers() {
++ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();
++ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };
++ throw new Error('createVipClient: tokenClient or getToken is required');
++ }
++
++ async function post(path, body, retried = false) {
++ const res = await fetchImpl(`${base}${path}`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);
++ return data;
++ }
++ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
++
++ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
++ async function evaluate({ subject = null, resource, owner = null, ruleId, mode, fallback } = {}) {
++ try {
++ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode, fallback });
++ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
++ } catch (err) {
++ note('evaluate', err);
++ return { allow: false, reason: 'vip_unavailable', error: err.message };
++ }
++ }
++
++ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */
++ async function checkEntitlement({ subject, creator, mode, product } = {}) {
++ try {
++ const e = await post('/api/v1/entitlements/check', { subject, creator, mode, product });
++ return { ...e, active: e.active === true && e.status === 'active' };
++ } catch (err) {
++ note('checkEntitlement', err);
++ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };
++ }
++ }
++
++ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;
++
++ return { evaluate, checkEntitlement, isMember };
++}
++
++/**
++ * createVipCache({ vip, ttlMs, denyTtlMs, unavailableTtlMs, maxEntries, now }) — the product-side
++ * cache every consumer puts in front of the client, with ONE convergence rule:
++ *
++ * A cached "yes" lives at most ttlMs (and never past the entitlement's expires_at or VIP's
++ * valid_until). So once VIP stops granting — it has applied Billing's billing.entitlement.changed
++ * and emitted vip.membership.changed — the product stops granting within ttlMs. handleEvent(envelope)
++ * with either event (or billing.subscription.canceled / billing.transaction.reversed) drops that
++ * member's entries for that creator at once, so a product that receives the events converges
++ * immediately. A "no" lives denyTtlMs (a new member waits at most that long); a failure
++ * (vip_unavailable / unknown) lives unavailableTtlMs, and a cached "yes" is never extended by a
++ * failure: when VIP is down, entries expire into denials (fail closed).
++ *
++ * await cache.entitlement({ subject, creator, product }) → the checkEntitlement answer (cached)
++ * cache.peekEntitlement({ subject, creator, product }) → the cached answer or undefined; never waits
++ * (a miss starts a fetch in the background)
++ * await cache.evaluate({ subject, resource, owner, fallback, mode }) → the evaluate answer (cached;
++ * mode 'authoritative' is never cached)
++ * cache.invalidate({ member, creator }), cache.handleEvent(envelope), cache.clear()
++ */
++function createVipCache({ vip, ttlMs = 30_000, denyTtlMs = 10_000, unavailableTtlMs = 2_000, maxEntries = 5000, now = () => Date.now() } = {}) {
++ if (!vip || typeof vip.evaluate !== 'function' || typeof vip.checkEntitlement !== 'function') throw new TypeError('createVipCache: a VIP client is required');
++ const entries = new Map(); // key → { value, expires, member, creator }
++ const inflight = new Map(); // key → Promise
++ let generation = 0; // bumped by clear(); an answer fetched before a clear is not stored
++ const pairGen = new Map(); // `${member}|${creator}` → generation of its last invalidation
++
++ const idOf = (v) => (v && typeof v === 'object' ? (v.id != null ? String(v.id) : null) : (v != null && v !== '' ? String(v) : null));
++ const pairKey = (member, creator) => `${member || ''}|${creator || ''}`;
++
++ function lifetime(value, allowed) {
++ const t = now();
++ if (value && (value.reason === 'vip_unavailable' || value.status === 'unknown' || value.reason === 'entitlement_unknown')) return unavailableTtlMs;
++ if (!allowed) return denyTtlMs;
++ let life = ttlMs;
++ const ent = value.entitlement !== undefined ? value.entitlement : value;
++ for (const at of [ent && ent.expires_at, ent && ent.valid_until]) {
++ const ms = at ? Date.parse(at) : NaN;
++ if (Number.isFinite(ms)) life = Math.min(life, ms - t);
++ }
++ if (ent && ent.stale) life = Math.min(life, unavailableTtlMs);
++ return Math.max(0, life);
++ }
++
++ function store(key, value, allowed, member, creator, startedGen, startedPair) {
++ if (startedGen !== generation || (pairGen.get(pairKey(member, creator)) || 0) !== startedPair) return; // invalidated meanwhile
++ const life = lifetime(value, allowed);
++ if (life <= 0) { entries.delete(key); return; }
++ if (entries.size >= maxEntries) {
++ const t = now();
++ for (const [k, e] of entries) if (e.expires <= t) entries.delete(k);
++ while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);
++ }
++ entries.set(key, { value, expires: now() + life, member, creator });
++ }
++
++ function fresh(key) {
++ const e = entries.get(key);
++ if (!e) return undefined;
++ if (e.expires <= now()) { entries.delete(key); return undefined; }
++ return e.value;
++ }
++
++ function load(key, member, creator, fetcher, allowedOf) {
++ const hit = fresh(key);
++ if (hit !== undefined) return Promise.resolve(hit);
++ if (inflight.has(key)) return inflight.get(key);
++ const g = generation;
++ const pg = pairGen.get(pairKey(member, creator)) || 0;
++ const p = (async () => {
++ try {
++ const value = await fetcher();
++ store(key, value, allowedOf(value), member, creator, g, pg);
++ return value;
++ } finally { inflight.delete(key); }
++ })();
++ inflight.set(key, p);
++ return p;
++ }
++
++ function entitlement({ subject, creator, product = null, mode } = {}) {
++ const member = idOf(subject);
++ const owner = idOf(creator);
++ if (!member || !owner) return Promise.resolve({ status: 'inactive', active: false, reason: 'not_signed_in' });
++ if (mode === 'authoritative') return vip.checkEntitlement({ subject: member, creator: owner, mode, product: product || undefined });
++ const key = `e|${member}|${owner}|${product || ''}`;
++ return load(key, member, owner, () => vip.checkEntitlement({ subject: member, creator: owner, product: product || undefined }), (v) => v.active === true);
++ }
++
++ function peekEntitlement(args = {}) {
++ const member = idOf(args.subject);
++ const owner = idOf(args.creator);
++ if (!member || !owner) return undefined;
++ const key = `e|${member}|${owner}|${args.product || ''}`;
++ const hit = fresh(key);
++ if (hit === undefined) entitlement(args).catch(() => {});
++ return hit;
++ }
++
++ function evaluate({ subject = null, resource, owner, fallback, mode } = {}) {
++ const member = idOf(subject);
++ const creator = idOf(owner);
++ if (mode === 'authoritative' || !resource || typeof resource !== 'object') return vip.evaluate({ subject: member, resource, owner: creator, fallback, mode });
++ if (!member) return Promise.resolve({ allow: false, reason: 'not_signed_in' });
++ const fb = fallback == null ? '' : JSON.stringify(fallback);
++ const key = `p|${member}|${creator || ''}|${resource.service}|${resource.type}|${resource.id}|${fb}`;
++ return load(key, member, creator, () => vip.evaluate({ subject: member, resource, owner: creator, fallback }), (v) => v.allow === true);
++ }
++
++ /** Drop what the cache knows about (member, creator); either may be omitted to drop more. */
++ function invalidate({ member = null, creator = null } = {}) {
++ const m = idOf(member);
++ const c = idOf(creator);
++ if (m && c) pairGen.set(pairKey(m, c), (pairGen.get(pairKey(m, c)) || 0) + 1);
++ else generation++;
++ let dropped = 0;
++ for (const [k, e] of entries) {
++ if ((!m || e.member === m) && (!c || e.creator === c)) { entries.delete(k); dropped++; }
++ }
++ return dropped;
++ }
++
++ /**
++ * An OpenVibe.Events envelope about a membership → invalidate that pair. Returns true when the
++ * event was understood. vip.membership.changed { member, creator }; billing.entitlement.changed
++ * { subject, streamer }; billing.subscription.canceled { subscription: { subscriber, streamer } };
++ * billing.transaction.reversed { to_subject (member), from_subject (creator) }.
++ */
++ function handleEvent(envelope) {
++ const e = envelope && envelope.event && envelope.event.event_type ? envelope.event : envelope;
++ const p = (e && e.payload) || {};
++ let member = null;
++ let creator = null;
++ switch (e && e.event_type) {
++ case 'vip.membership.changed': member = idOf(p.member); creator = idOf(p.creator); break;
++ case 'billing.entitlement.changed': member = idOf(p.subject); creator = idOf(p.streamer); break;
++ case 'billing.subscription.canceled': member = idOf(p.subscription && p.subscription.subscriber); creator = idOf(p.subscription && p.subscription.streamer); break;
++ case 'billing.transaction.reversed': member = idOf(p.to_subject); creator = idOf(p.from_subject); break;
++ default: return false;
++ }
++ if (!member || !creator) return false;
++ invalidate({ member, creator });
++ return true;
++ }
++
++ function clear() { generation++; entries.clear(); }
++
++ return {
++ entitlement, peekEntitlement, evaluate, invalidate, handleEvent, clear,
++ get size() { return entries.size; },
++ /** The convergence bound, for docs and /ready: how long a cached "yes" can outlive VIP's "no". */
++ bounds: Object.freeze({ grantMs: ttlMs, denyMs: denyTtlMs, unavailableMs: unavailableTtlMs }),
++ };
++}
++
++module.exports = { createVipClient, createVipCache };
ddiff --git a/test/members-vip.test.js b/test/members-vip.test.js
nnew file mode 100644
iindex 0000000..4b03bab
---- /dev/null
++++ b/test/members-vip.test.js
@@@ -0,0 +1,193 @@
++'use strict';
++/**
++ * Members-only posts through OpenVibe.VIP (roadmap Wave 10 / §12.6): Blog asks VIP's
++ * policies/evaluate with the blog owner as `owner` and its default gate { member, blog:gated_post };
++ * a reader VIP admits reads the post, everyone else (signed out, not a member, VIP down) gets a
++ * teaser and a join link and the body never leaves Blog — not through the page, the .json twin, the
++ * API, feeds, sitemaps, Search documents or the product events. Convergence: a cached "yes" ends
++ * within BLOG_VIP_CACHE_TTL_MS after VIP stops granting, and at once on vip.membership.changed.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const SENTINEL = 'ZEBRA-SENTINEL-7731';
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++const TTL = 30_000;
++
++/** A stand-in for VIP's POST /api/v1/policies/evaluate: answers from `allow` (subject → bool). */
++function startVip() {
++ const state = { allow: new Map(), down: false, calls: [] };
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ if (state.down) { res.writeHead(503, { 'Content-Type': 'application/json' }); return res.end('{"code":"vip.unavailable"}'); }
++ const token = String(req.headers.authorization || '').replace(/^Bearer /, '');
++ let claims = {};
++ try { claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch { /* */ }
++ const body = raw ? JSON.parse(raw) : {};
++ state.calls.push({ path: req.url, body, claims });
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url !== '/api/v1/policies/evaluate' || req.method !== 'POST') return json(404, { code: 'not_found' });
++ if (!(claims.cap || []).includes('vip.resource.policy.evaluate') || !(claims.aud || []).includes('openvibe.vip')) return json(403, { code: 'capability.denied' });
++ const ok = state.allow.get(body.subject) === true;
++ return json(200, { allow: ok, reason: ok ? 'member' : 'not_a_member', rule: null, fallback: true, entitlement: ok ? { status: 'active', active: true, expires_at: new Date(Date.parse('2027-01-01')).toISOString() } : null });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ ...state, state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
++}
++
++(async () => {
++ const vip = await startVip();
++ const t = await boot({ env: { OV_VIP_INTERNAL_URL: vip.url, OV_VIP_URL: 'https://openvibe.vip', BLOG_VIP_CACHE_TTL_MS: String(TTL) } });
++ const carol = t.network.addUser('carol');
++ const fan = t.network.addUser('fan');
++ const stranger = t.network.addUser('stranger');
++ const late = t.network.addUser('late');
++ const admin = t.network.addUser('boss', { role: 'admin' });
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++ vip.state.allow.set(fan.subject, true);
++
++ const make = async (as, handle, title, visibility, extra = {}) => {
++ const p = (await t.get(`/api/v1/blogs/${handle}/posts`, { as, json: { title, body: `Opening line. ${LONG} ${SENTINEL} ${title}`, visibility, ...extra } })).json().post;
++ const r = await t.get(`/api/v1/posts/${p.id}/publish`, { as, json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ return p;
++ };
++ const mem = await make(carol, 'carol', 'Backstage notes', 'members', { summary: 'What happened backstage this week.' });
++ const official = await make(t.official, 'openvibe', 'Staff memo', 'members');
++ const noBody = (text, where) => assert.ok(!String(text).includes(SENTINEL), `${where}: the body leaked`);
++ const lastCall = () => vip.state.calls[vip.state.calls.length - 1];
++
++ await check('the provider defaults to vip when Blog has a client secret', async () => {
++ assert.strictEqual(t.ctx.entitlements.provider, 'vip');
++ assert.strictEqual(t.ctx.vip.enabled, true);
++ });
++
++ await check('a VIP member reads the full post; Blog asked VIP with the owner, the resource and its default gate', async () => {
++ const r = await t.get('/@carol/backstage-notes', { as: fan });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(SENTINEL));
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ const c = lastCall();
++ assert.strictEqual(c.path, '/api/v1/policies/evaluate');
++ assert.deepStrictEqual(c.body.resource, { service: 'blog', type: 'post', id: mem.id });
++ assert.strictEqual(c.body.owner, carol.subject);
++ assert.strictEqual(c.body.subject, fan.subject);
++ assert.deepStrictEqual(c.body.fallback, { requirement: 'member', binding: 'blog:gated_post' });
++ assert.strictEqual(c.claims.sub, 'svc:blog');
++ const j = await t.get(`/api/v1/posts/${mem.id}`, { as: fan });
++ assert.strictEqual(j.status, 200);
++ const tw = await t.get('/@carol/backstage-notes.json', { as: fan });
++ assert.strictEqual(tw.status, 200);
++ assert.ok(tw.json().body_markdown.includes(SENTINEL));
++ });
++
++ await check('a stranger and a signed-out reader get the teaser and a join link, never the body', async () => {
++ for (const as of [stranger, undefined]) {
++ const who = as ? 'stranger' : 'anonymous';
++ const page = await t.get('/@carol/backstage-notes', { as });
++ assert.strictEqual(page.status, 403, who);
++ noBody(page.text, `${who} page`);
++ assert.match(page.text, /Backstage notes/);
++ assert.match(page.text, /What happened backstage this week\./);
++ assert.match(page.text, /href="https:\/\/openvibe\.vip\/carol"/);
++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
++ assert.match(page.headers.get('x-robots-tag') || '', /noindex/);
++ const twin = await t.get('/@carol/backstage-notes.json', { as });
++ assert.strictEqual(twin.status, 403);
++ noBody(twin.text, `${who} .json`);
++ assert.strictEqual(twin.json().teaser.join_url, 'https://openvibe.vip/carol');
++ const api = await t.get(`/api/v1/posts/${mem.id}`, { as });
++ assert.strictEqual(api.status, 403);
++ noBody(api.text, `${who} API`);
++ const b = api.json();
++ assert.strictEqual(b.code, 'post.members_only');
++ assert.strictEqual(b.teaser.title, 'Backstage notes');
++ assert.strictEqual(b.teaser.summary, 'What happened backstage this week.');
++ assert.strictEqual(b.teaser.join_url, 'https://openvibe.vip/carol');
++ assert.strictEqual(b.teaser.members_only, true);
++ for (const k of ['body', 'body_markdown', 'content', 'html']) assert.ok(!(k in b.teaser) && !(k in b), `no ${k}`);
++ }
++ assert.strictEqual(lastCall().body.subject, stranger.subject, 'a signed-out reader never reaches VIP');
++ });
++
++ await check('the blog\'s own members (the owner) and staff read without VIP', async () => {
++ const before = vip.state.calls.length;
++ assert.strictEqual((await t.get('/@carol/backstage-notes', { as: carol })).status, 200);
++ assert.strictEqual((await t.get('/@carol/backstage-notes', { as: admin })).status, 200);
++ assert.strictEqual(vip.state.calls.length, before);
++ });
++
++ await check('VIP down: refused with the teaser (fail closed)', async () => {
++ vip.state.down = true;
++ try {
++ const r = await t.get('/@carol/backstage-notes', { as: late });
++ assert.strictEqual(r.status, 403);
++ noBody(r.text, 'VIP down');
++ assert.match(r.text, /could not be checked/);
++ assert.strictEqual((await t.get(`/api/v1/posts/${mem.id}`, { as: late })).json().teaser.reason, 'vip_unavailable');
++ } finally { vip.state.down = false; }
++ });
++
++ await check('the official blog has no VIP owner: outsiders are refused without asking VIP, and no join link', async () => {
++ const before = vip.state.calls.length;
++ const r = await t.get(`/api/v1/posts/${official.id}`, { as: fan });
++ assert.strictEqual(r.status, 403);
++ noBody(r.text, 'official');
++ assert.strictEqual(r.json().teaser.join_url, null);
++ assert.strictEqual(r.json().teaser.reason, 'no_owner');
++ assert.strictEqual(vip.state.calls.length, before);
++ assert.strictEqual((await t.get(`/api/v1/posts/${official.id}`, { as: t.official })).status, 200);
++ });
++
++ await check('feeds, sitemaps, listings, Search documents and product events never carry the body', async () => {
++ for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/feed.xml', '/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed', '/llms.txt']) {
++ for (const as of [undefined, fan]) noBody((await t.get(p, { as })).text, p);
++ }
++ const all = t.events();
++ assert.ok(all.length > 0);
++ for (const e of all) noBody(JSON.stringify(e), e.event_type);
++ assert.strictEqual(t.events('blog.index_document.upserted').filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');
++ const doc = t.ctx.publication.documentFor(t.ctx.blogs.byHandle('carol'), t.ctx.posts.get(mem.id), { forSearch: false }).doc;
++ assert.strictEqual(doc.body, '');
++ assert.strictEqual(doc.summary, 'What happened backstage this week.');
++ assert.deepStrictEqual(doc.acl.entitlements, ['vip:carol'], 'the default gate label names the blog');
++ });
++
++ await check('convergence: a cached yes ends within the TTL after VIP stops granting', async () => {
++ const reader = t.network.addUser('reader1');
++ vip.state.allow.set(reader.subject, true);
++ assert.strictEqual((await t.get('/@carol/backstage-notes', { as: reader })).status, 200);
++ vip.state.allow.set(reader.subject, false); // VIP applied the cancellation
++ t.clock.advance(TTL - 1);
++ assert.strictEqual((await t.get('/@carol/backstage-notes', { as: reader })).status, 200, 'inside the bound Blog may still admit');
++ t.clock.advance(2);
++ const r = await t.get('/@carol/backstage-notes', { as: reader });
++ assert.strictEqual(r.status, 403, 'past the TTL Blog has asked VIP again');
++ noBody(r.text, 'after convergence');
++ });
++
++ await check('convergence: vip.membership.changed handed to the cache refuses at once', async () => {
++ const reader = t.network.addUser('reader2');
++ vip.state.allow.set(reader.subject, true);
++ assert.strictEqual((await t.get(`/api/v1/posts/${mem.id}`, { as: reader })).status, 200);
++ vip.state.allow.set(reader.subject, false);
++ assert.strictEqual((await t.get(`/api/v1/posts/${mem.id}`, { as: reader })).status, 200, 'still the cached yes');
++ assert.strictEqual(t.ctx.vip.cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: reader.subject }, creator: { type: 'user', id: carol.subject } } }), true);
++ assert.strictEqual((await t.get(`/api/v1/posts/${mem.id}`, { as: reader })).status, 403);
++ });
++
++ await check('a new member waits at most the deny TTL', async () => {
++ const reader = t.network.addUser('reader3');
++ assert.strictEqual((await t.get(`/api/v1/posts/${mem.id}`, { as: reader })).status, 403);
++ vip.state.allow.set(reader.subject, true);
++ t.clock.advance(10_001);
++ assert.strictEqual((await t.get(`/api/v1/posts/${mem.id}`, { as: reader })).status, 200);
++ });
++
++ await t.close();
++ await vip.close();
++ done();
++})();
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
iindex 2fda2c8..b72be0b 100644
---- a/test/privacy.test.js
++++ b/test/privacy.test.js
@@@ -45,11 +45,12 @@ function assertPrivate(r, what) {
assertPrivate(await t.get('/@carol/open-post', { as: reader }), 'signed-in view');
});
-- await check('members-only: anonymous → 403 without the text; non-entitled member of the network → 403; no-store', async () => {
++ await check('members-only: anonymous → 403 teaser without the text; non-entitled member of the network → 403; no-store', async () => {
const a = await t.get('/@carol/members-post');
assert.strictEqual(a.status, 403);
assert.doesNotMatch(a.text, /SECRET-members/);
-- assert.doesNotMatch(a.text, /Members post/, 'not even the title');
++ assert.match(a.text, /Members post/, 'the teaser names the post');
++ assert.match(a.text, /data-members-only="1"/);
assertPrivate(a, 'members 403');
assert.match(a.headers.get('x-robots-tag'), /noindex/);
const r = await t.get('/@carol/members-post', { as: reader });
@@@ -128,12 +129,18 @@ function assertPrivate(r, what) {
await check('without an entitlement service the check fails closed', async () => {
const { createEntitlementChecker } = require('../server/domain/access');
++ const blog = t.ctx.blogs.byHandle('carol');
++ const post = t.ctx.posts.get(mem.id);
++ const args = { subject: vip.subject, blog, post };
const none = createEntitlementChecker({ provider: 'none' });
-- assert.strictEqual(await none.has(vip.subject, 'vip:carol'), false);
++ assert.strictEqual(await none.has(args), false);
const unknown = createEntitlementChecker({ provider: 'vip-service-that-does-not-exist' });
-- assert.strictEqual(await unknown.has(vip.subject, 'vip:carol'), false);
++ assert.strictEqual(await unknown.has(args), false);
++ const unwired = createEntitlementChecker({ provider: 'vip' });
++ assert.strictEqual(await unwired.has(args), false, 'vip without a client secret');
const throwing = createEntitlementChecker({ check: async () => { throw new Error('down'); } });
-- assert.strictEqual(await throwing.has(vip.subject, 'vip:carol'), false);
++ assert.strictEqual(await throwing.has(args), false);
++ assert.deepStrictEqual(await throwing.decide(args), { allow: false, reason: 'error' });
});
await check('API reads of non-public posts are private and refused to outsiders', async () => {