Skip to content

feat: paid articles via membership subscription - #2769

Merged
Innei merged 37 commits into
masterfrom
feat/paid-articles-membership
Jul 19, 2026
Merged

feat: paid articles via membership subscription#2769
Innei merged 37 commits into
masterfrom
feat/paid-articles-membership

Conversation

@Innei

@Innei Innei commented Jul 18, 2026

Copy link
Copy Markdown
Member

Paid articles via membership subscription

Adds a single-tier paid-membership paywall for posts, paid through a pluggable payment provider (Dodo Payments first), with admin management and a full public-surface leak audit.

Design spec: docs/superpowers/specs/2026-07-18-paid-articles-membership-design.md

What's included

Core

  • DB: posts.is_premium, memberships, billing_webhook_events (expand-only migration 0024).
  • Membership module: provider-adapter layer (Dodo implemented; Creem/Lemon/Stripe are one file each later), webhook-idempotent + retryable event application, EntitlementService as the sole cross-module export.
  • HTTP API: checkout / status / webhook / admin member list + manual grant/revoke.
  • Paywall gate on post serving: detail + list routes, strict-subset teaser via getPublicText / getPublicContent, entitlement-keyed HTTP cache.
  • Premium is lexical-only (PREMIUM_REQUIRES_LEXICAL).

Admin

  • Post editor: premium toggle + preview-block count.
  • Readers page: membership badge, status filter, detail block with grant/revoke.
  • Settings: Membership section (provider, product IDs, keys, environment) — keys use encrypted password fields.

api-client: typed membership endpoints + meta.paywall.

Public-surface leak audit

A sweep of every unauthenticated endpoint that can emit a post body found and fixed five full/near-full content leaks that bypassed the gate:

  • GET /ai/translations/article/:id (+/generate) — full translated premium body → 403 for premium.
  • GET /ai/insights/article/:id (+/generate) — same missing check → 403 for premium.
  • GET /aggregate/latest — full text/content → teaser for premium.
  • GET /activity/recent and /activity/last-year/publication — full bodies incl. unpublished drafts → teaser + publishedOnly.

Each was reproduced against a running server before/after. The detail route, list route, RSS/feed, search index and WebSocket broadcast were already gated.

Left as deliberate policy (not changed):

  • Outbound webhook payload forwards the full body to the owner-configured URL.
  • Newsletter email includes a bounded ~150-char premium preview.

Local verification

End-to-end against a live Dodo test-mode account: product creation → checkout session → real card payment → webhook (subscription.active + renewed) verified via standardwebhooks → idempotent persist → membership active → entitlement true. Full core test suite green (1884 passing).

Note

0508c9fbf fix(admin): improve webhook editor feedback is a pre-existing unrelated admin commit (by @Innei) that happened to sit on local master ahead of origin; it rides along in this branch. Drop it from the branch if you'd rather land it separately.

Innei added 24 commits July 18, 2026 18:42
…migration, repositories

Adds the DB foundation for the paid-articles-via-membership feature:
posts.is_premium, memberships, and billing_webhook_events tables plus
MembershipRepository and BillingWebhookEventRepository.

The generated migration diff also caught pre-existing drift between the
0023 snapshot and the actually-applied schema (ai_agent_conversations
session_id rewrite, comments owner-reply/country columns, companion
tables) - those statements were stripped from the emitted SQL since
they were already applied; only the new membership/billing DDL is
included. The 0024 snapshot itself is accurate and now reflects true
current state, fixing the diff baseline forward for future migrations.
Add truncateLexicalContent and renderTeaserText, mirroring the
createHeadlessEditor + allHeadlessNodes + $toMarkdown pattern used by
LexicalService via the shared mxLexicalToMarkdown projection.
…fig, error codes

Adds the core membership module: PaymentProviderAdapter interface and a
DodoProvider implementation (dodopayments + standardwebhooks, imported only
inside providers/), MembershipService.applyEvent with idempotent webhook
processing and manual grant/revoke, and EntitlementService.isActiveMember as
the single entitlement entry point for other modules. Wires a `membership`
configs section (enabled/provider/product ids) and DODO_API_KEY/DODO_WEBHOOK_KEY
env secrets, plus MembershipRequired/WebhookVerifyFailed/
MembershipProviderNotConfigured/PremiumRequiresLexical error codes. No HTTP
surface yet (controllers land in a follow-up task).
…orts

Make BillingWebhookEventRepository.create the sole idempotency gate via
onConflictDoNothing().returning() on (provider, event_id), returning null
for a losing concurrent insert instead of throwing; applyEvent treats
null as { applied: false } and drops the separate check-then-insert
pre-check. Narrow MembershipModule exports to EntitlementService only,
per the decoupling rule that it's the sole consumable export.
…ok and admin member management

Adds POST /membership/checkout, GET /membership/status, POST
/membership/webhook/:provider, and the owner-only members list/grant/revoke
endpoints. Webhook signature verification needs the exact request bytes, so
the Fastify adapter gains a narrow preParsing hook that buffers the raw body
only for the membership webhook route.
Post detail handlers (getById/getByCateAndSlug) now truncate premium
Lexical content and teaser text for requesters who are neither the
owner nor an active/on_hold member, using the existing pure Lexical
truncation helpers and EntitlementService.isActiveMember as the sole
membership entry point. Reader identity is resolved via the global
RolesGuard's optional request.readerId (@CurrentReaderId), mirroring
how HasAdminAccess already works on public routes.

Write-side: isPremium is added to the post write schema and rejected
with PREMIUM_REQUIRES_LEXICAL whenever the effective post state (after
merging with the existing document on update) is premium but not
Lexical.

meta.types.ts gains a paywall shape and PostMetaBuilder.paywall().
Post detail responses are not Redis-cached today, so no cache-key
changes were needed.
- GET /posts now forces a strict-subset teaser (content+text) on every
  isPremium row regardless of the client-controlled truncate query param
  or entitlement, closing a full-content leak on the list endpoint.
- previewBlocks is now clamped to [0, blockCount-1] via a new
  resolveEffectivePreviewBlocks helper, so a short or misconfigured
  premium post can no longer serve full content while reporting
  locked:true.
…ent for premium posts

Close the remaining premium-content leak surfaces: RSS/feed JSON export
(AggregateService.buildRssStructure), the search index (post/translation
documents), and gateway/websocket post create/update broadcasts now emit
a teaser instead of the full body for premium posts. Also fail-closed the
getPaginate list-path hardening gap: a premium row whose content isn't a
string now serves an empty teaser instead of leaking full text.
GET /render/markdown/:id and GET /markdown/render/structure/:id were
unauthenticated and rendered document.text unconditionally, leaking the
full body of premium posts. MarkdownService.renderArticle now defaults
to getPublicText(document) (teaser for premium posts) and only returns
the full body when the caller explicitly passes { asOwner: true } —
RenderEjsController wires this from RequestContext.hasAdminAccess(),
resolved before rendering since RolesGuard runs globally.

Also add a regression test pinning that FeedController.rss never emits
full body text for a premium/lexical post, since that branch's safety
today rests on the premium-implies-lexical invariant rather than routing
through the accessor.
Left-join memberships (1:1 on reader_id) into the reader list query so each
row carries membership: { status, plan, provider, currentPeriodEnd } | null,
and add a membershipStatus SQL filter (active/on_hold/cancelled/expired/none)
with semantics matching EntitlementService's entitlement rule.
Add a typed membership client (checkout/status) mirroring the new
/membership endpoints, plus isPremium on the post model and a
paywall field on PostResponseMeta so consumers can type-check the
locked-post response shape. PostController.getPost now returns a
RequestProxyResultWithMeta typed with PostResponseMeta.
Add a premium toggle (Lexical-only, per server contract) and a free-preview
number input to the post write panel, persisting isPremium and
meta.paywall.previewBlocks alongside the existing copyright/pin fields.
WriteRouteViewsContent's save and draft paths wrote meta.paywall.previewBlocks
unconditionally, leaving a stale paywall key on posts after isPremium was
toggled off (or on posts that were never premium). resolvePaywallMeta() now
merges previewBlocks only when isPremium is true and otherwise strips the
previewBlocks key (and the whole paywall object if it becomes empty) while
preserving all other meta keys.

Investigated the models/draft.ts formatting finding but left it unchanged:
the current two-line union form is what this repo's actual Prettier config
produces for TypeSpecificData, confirmed by running prettier over the
older multi-line piped form and seeing it collapse back down.
Adds the reader-list membership badge (effective status derived from
raw status + currentPeriodEnd, with lapsed active/on_hold shown as
"expired"), a membershipStatus filter in the readers toolbar, and a
detail-page membership block with owner grant/extend and revoke
actions.

- api/readers.ts: membership types, membershipStatus list param, and
  PUT/DELETE /membership/members/:readerId calls
- ReaderDetailRoute merges membership from the list-cache snapshot
  since GET /readers/:id omits it; mutations patch the detail cache
  directly after invalidation so the merge survives a refetch
- MembershipGrantModal mirrors BanReaderModal's present/useModal shape
GET /readers/:id never joined memberships, so the response always carried
membership:null; any background refetch of the admin reader detail page
(staleTime 30s, refetchOnWindowFocus) wiped the badge and wrongly re-showed
the Grant action for provider-managed members. findByIdDetailed now mirrors
list()'s membership join/mapping, making the server authoritative and
removing the dead client-side fallback merge in ReaderDetailRoute. Also
drops the JSDoc on ReaderModel.membership and makes the field non-optional.
…unded raw body, readerId validation

- cache.interceptor: bypass shared cache read/write and force private
  cache-control for any reader-identified request, not only admins; also
  skip the cache write whenever the response carries paywall.locked:false
  as a defense-in-depth guard against identity-detection regressions.
- membership.service: on a webhook insert conflict, look up the existing
  event row and re-apply membership state when processed_at is still null,
  so a delivery that fails mid-apply is retried instead of silently
  swallowed.
- fastify.adapter: cap raw-body buffering on the membership webhook route
  at 1 MiB (413 once exceeded) and match on the parsed path instead of the
  raw URL so a query string can't spoof or dodge the route match.
- membership.controller: validate the readerId path param via the
  existing EntityIdDto-style Zod pattern instead of a bare string, so a
  malformed id is rejected before reaching the service layer.
Replace DODO_* env vars / app.config.ts with an admin-editable membership.dodoApiKey/dodoWebhookKey/dodoEnvironment section, encrypted like other secret fields. DodoProvider now reads configs per call and rebuilds its client when the key or environment changes, so admin edits apply without a restart.
…atest

getLatest() returned posts verbatim, so the public timeline leaked a
premium post's full text/lexical content in both the combined and
{posts, notes} response shapes. Map posts through getPublicText/
getPublicContent so premium posts surface only the teaser.
getRecentPublish() and getLastYearPublication() fetched posts without
publishedOnly, exposing unpublished drafts, and returned bodies
verbatim, exposing full premium content on public endpoints. Restrict
both to published posts and teaser premium ones via
getPublicText/getPublicContent.
…osts

GET /ai/translations/article/:id and its /generate SSE counterpart are
unauthenticated and streamed a premium post's full translated body,
bypassing the paywall entirely. Add a blockPremium option to
loadVisibleArticleOrThrow, scoped to the two public read paths
(getTranslationForArticle, streamTranslationForArticle) only; the
owner-authenticated getTranslationAndAvailableLanguages path (used by
post/note/page detail routes via helper.translation.service, where
paywall gating is applied separately) is untouched.
GET /ai/insights/article/:id and its /generate SSE counterpart are
unauthenticated and served a premium post's AI insights, bypassing the
paywall. Add a blockPremium option to resolveArticleForInsights, scoped
to the two public read paths (getOrGenerateInsightsForArticle,
streamInsightsForArticle) only; @Auth-gated routes and background
task/event-driven generation are untouched.
@safedep

safedep Bot commented Jul 18, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon dodopayments @ 2.42.2
pnpm-lock.yaml apps/core/package.json
ok icon
ok icon
ok icon
🔗
icon standardwebhooks @ 1.0.0
pnpm-lock.yaml apps/core/package.json
ok icon
ok icon
ok icon
🔗

View complete scan results →

This report is generated by SafeDep Github App

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4bd082cb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

docs.push(
buildSearchDocument(
refType,
this.toPublicArticleFields(article as any),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply paywall to returned search rows

For public /search and /search/post, this only applies toPublicArticleFields() while building the search index; loadSearchResultData() later re-fetches matching posts with postService.findManyByIds() and returns the full post row. When a non-member searches a premium post by title or teaser text, the search response will still include the original text/content from the database, bypassing the paywall. Apply the same public-field projection to the loaded result rows unless the requester has admin access.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadSearchResultData() already projects non-admin post rows through toPublicArticleFields() (search.service.ts, the hasAdminAccess ? post : this.toPublicArticleFields(post) branch), so the loaded result rows carry the teaser text/content, not the full body. No change made.

Comment on lines +486 to +490
await this.applyPaywall(
postData as Record<string, any>,
metaBuilder,
!!isAuthenticated,
readerId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide AI summaries before returning locked posts

In this slug-detail path, summaryDoc is added to metaBuilder before the new paywall call, but applyPaywall() only truncates postData.text and postData.content. For a locked premium post that has an AI summary, unauthenticated/non-member GET /posts/:category/:slug still receives meta.summary.text, which is generated from the full article rather than the teaser. Omit the summary unless the reader is entitled, or derive it from the public teaser.

Useful? React with 👍 / 👎.

}

return {
status: membership.status,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return effective status for expired memberships

This returns the stored membership status even after currentPeriodEnd has passed. In that state EntitlementService denies premium access and the admin readers view treats the membership as expired, but /membership/status still reports active to the reader, so the UI/API can show an active subscription while premium posts remain locked. Return an effective expired status when the period end is in the past, or share the entitlement/effective-status logic here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/membership/status now returns effectiveMembershipStatus(membership), which reports expired once currentPeriodEnd is in the past. Addressed in an earlier commit.

Innei added 2 commits July 18, 2026 23:46
loadSearchResultData re-fetched full premium post rows (text/content) for public search callers, bypassing the paywall teaser used elsewhere. Project post rows through the existing toPublicArticleFields() helper unless the caller has admin access.
…d lapses

GET /membership/status returned the raw stored status, so an active/on_hold row past its currentPeriodEnd still showed 'active' even though EntitlementService already denies access. Add effectiveMembershipStatus() and use it in the status endpoint, matching the semantics already used by apps/admin/src/features/readers/utils/membership-status.ts.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e22a9eeac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


const isLiveProviderSubscription = (row: MembershipRow): boolean => {
if (row.provider === 'manual') return false
if (row.status === 'active') return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check period end before blocking manual grants

When a provider-managed row remains active after currentPeriodEnd has passed, this still treats it as a live subscription, so grantManual() rejects the admin override even though EntitlementService and the effective status logic treat the same row as expired. That leaves the reader locked out of premium content while the admin cannot rescue them with a manual grant; apply the same period-end check used for on_hold/effective status before returning true.

Useful? React with 👍 / 👎.

…icle

Accept a validated relative returnPath on POST /membership/checkout, resolve
it against the configured webUrl with a membership=success marker, and forward
it to the Dodo checkout session as return_url so readers land back on the
article after paying. Off-origin, protocol-relative, and absolute paths are
rejected to prevent open redirects.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 322acd5a70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


const session = await client.checkoutSessions.create({
product_cart: [{ product_id: productId, quantity: 1 }],
metadata: { readerId: input.reader.id },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the selected checkout plan

When a reader buys the yearly product and Dodo's subscription webhook omits payment_frequency_interval (this event type is modeled as optional), verifyAndParseWebhook() emits plan: undefined and MembershipService creates the membership as event.plan ?? 'monthly'. That makes yearly subscribers show up as monthly in /membership/status and the admin readers view. Include the selected plan in checkout metadata (or derive it from the product id) instead of relying on an optional webhook field.

Useful? React with 👍 / 👎.

Comment on lines +91 to +92
const adapter = this.resolveProvider(membershipConfig.provider)
return adapter.createCheckout({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refuse checkout for live memberships

When an already-entitled reader calls /membership/checkout again, this path still creates a new provider checkout session, and nothing else in this flow checks the existing memberships row before the provider can create another subscription for the same reader. Because the database uniqueness only applies after webhooks arrive, repeated clicks/API calls can result in duplicate paid subscriptions; check the reader's effective membership status before calling the adapter.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d832e87b91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +116 to +120
if (!doc?.isPremium) return doc
return {
...doc,
text: getPublicText(doc),
content: getPublicContent(doc),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate translated visitor events for premium posts

When a translation is generated or updated for a premium post, AiTranslationService.emitTranslationEvent() emits the full translation.text with visitor scope, and the translation websocket handlers rebroadcast that payload directly. This helper only truncates objects that already carry isPremium, so those translation payloads (refId/refType/text, without isPremium) pass through unchanged to non-member sockets in the article room. Fetch the source post or include premium metadata before broadcasting translation events.

Useful? React with 👍 / 👎.

const plans: MembershipPlan[] = []
if (config.monthlyProductId) plans.push('monthly')
if (config.yearlyProductId) plans.push('yearly')
const enabled = !!config.enabled && !!config.provider && plans.length > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only enable membership for supported providers

When the admin selects a non-Dodo provider that the new config schema allows, this reports membership as enabled as long as a product ID exists, but PaymentProviderRegistry only registers dodo. In that configuration checkout fails with provider-not-configured while applyPaywall() sees the plan as purchasable and locks premium posts with no working purchase path; either restrict the provider to registered adapters or include adapter availability in this check.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f9bf74cdd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return buildSearchDocument(refType, merged, translation.lang)
return buildSearchDocument(
refType,
this.toPublicArticleFields(merged),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rebuild translated search docs when paywall projection changes

When an already-translated post is later marked premium (or its meta.paywall.previewBlocks is reduced), the translated search document's indexed body now depends on article.isPremium/article.meta, but its sourceHash still comes only from translation.hash and post-update events only upsert the source-language document. Incremental rebuilds therefore skip the stale translated row, so public searches with that lang can still match hidden translated text and return snippets from the full article.

Useful? React with 👍 / 👎.


if (skills.length > 0) metaBuilder.skills(skills)

await this.applyPaywall(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prune hidden enrichments from locked premium details

For locked premium posts that contain a link-card or bare URL outside the preview, attachEnrichments() has already extracted metadata from the full postEntity, and this paywall call only truncates postData.text/content. The response still includes meta.enrichments for URLs in hidden blocks, exposing their URLs/titles/descriptions to non-member detail callers; build enrichments from the truncated document or prune them when the paywall is locked.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3d5b6ebd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +353 to +357
if (
effectiveIsPremium &&
effectiveContentFormat !== ContentFormat.Lexical
) {
throw createAppException(AppErrorCode.PREMIUM_REQUIRES_LEXICAL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate premium updates have usable Lexical content

When a caller uses PATCH /posts/:id to set contentFormat: 'lexical' and isPremium: true but omits content, this check passes even for a legacy markdown row whose stored content is null; PartialPostDto allows that combination because both content and text are absent. The row is then treated as premium, and the non-member detail paywall path parses doc.content as Lexical, causing a 500 instead of returning a locked teaser. Check the effective stored content before allowing premium.

Useful? React with 👍 / 👎.

Comment on lines +48 to +49
const enabled = !!config.enabled && !!config.provider && plans.length > 0
return { enabled, plans: enabled ? plans : [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require provider credentials before enabling paywall

When Dodo is selected with product ids but dodoApiKey is still empty (which the config schema/defaults allow and DodoProvider.createCheckout() rejects later), this reports membership as enabled. applyPaywall() uses that enabled state to lock premium posts, but /membership/checkout then fails with MEMBERSHIP_PROVIDER_NOT_CONFIGURED, leaving readers with locked content and no working purchase path; include provider readiness/required credentials in the availability decision.

Useful? React with 👍 / 👎.

- treat active provider subscriptions past currentPeriodEnd as dead so
  admin manual grants are not blocked
- refuse /membership/checkout when the reader already has a live
  membership (new MEMBERSHIP_ALREADY_ACTIVE error)
- persist the selected plan in dodo checkout metadata and prefer it over
  the optional webhook interval field
- disable membership availability for providers without a registered
  adapter
- omit AI summary meta and build enrichments from the truncated teaser
  for locked premium posts
- strip text/summary from visitor translation broadcasts of premium posts
- fold premium projection into translated search doc source hash and
  refresh translation docs on article update events

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c46c0d6edf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

.enrichments(enrichments as Record<string, EnrichmentEntry>)

if (summaryDoc) {
if (summaryDoc && !paywall?.locked) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate public summary routes for premium posts

Fresh evidence: this line now hides meta.summary from locked post-detail responses, but the public /ai/summaries/article/:id and /ai/summaries/article/:id/generate routes still call AiSummaryService directly, where resolveArticleForSummary() only checks visibility and then returns/generates summaries from the full article text. An unauthenticated caller who knows a premium post id can still fetch or generate the full-article AI summary outside the detail endpoint, so the same premium entitlement check needs to be applied in the summary service/controller.

Useful? React with 👍 / 👎.

membership: ReaderMembership | null | undefined,
): boolean {
if (!membership || membership.provider === 'manual') return false
if (membership.status === 'active') return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor period end before hiding manual grants

When a provider-managed row remains stored as active after currentPeriodEnd has passed, effectiveMembershipStatus() displays it as expired and the server-side grant path now allows a manual override, but this helper still returns true solely because the stored status is active. In that expired-provider scenario the reader detail panel hides the Grant/Extend button and only shows the provider-managed hint, so admins cannot rescue the reader from the UI even though the API would accept it.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3922afcfee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +80 to +81
if (!membershipConfig.enabled || !membershipConfig.provider) {
throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate checkout against provider readiness

When membership is enabled but the Dodo webhook key is missing, /membership/plans and applyPaywall() treat membership as unavailable via resolveMembershipAvailability(), but this checkout path only checks enabled/provider. DodoProvider.createCheckout() can still create a paid session with just an API key/product ID, and the resulting webhook then fails verification, so a reader can be charged without receiving entitlement. Gate checkout with the same availability/credential check before calling the adapter.

Useful? React with 👍 / 👎.

bannedAt: row.bannedAt ?? null,
banReason: row.banReason ?? null,
lastLoginAt: row.lastLoginAt ?? null,
membership: row.membership ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Project membership before returning public readers

Adding membership to the generic reader shape means every existing public consumer of findReaderInIds() now receives status, plan, provider, and currentPeriodEnd; for example public comment responses attach these readers after adding only an isMember badge, and activity presence returns/broadcasts the same reader objects to room visitors. Keep the full membership summary for admin reader endpoints, or project public consumers down to safe fields plus isMember.

Useful? React with 👍 / 👎.

@Innei
Innei merged commit 04c082f into master Jul 19, 2026
13 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1691a64ce4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

categoryId: category.id,
copyright: post.copyright,
isPublished: post.isPublished,
isPremium: post.isPremium,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Send only premium teasers to email subscribers

When a published post is created with isPremium true and newsletter subscriptions are enabled, the existing SubscribeService POST_CREATE handler still emails noteOrPost.text and aggregate.post.text from the full document to every SubscribePostCreateBit subscriber (apps/core/src/modules/subscribe/subscribe.service.ts:132-151). That bypasses the new membership entitlement checks outside the HTTP surfaces, so premium articles are leaked by email on publish; project the document through the same teaser/paywall helper or skip newsletter bodies for premium posts.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant