feat: paid articles via membership subscription - #2769
Conversation
…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 Report SummaryPackage Details
This report is generated by SafeDep Github App |
There was a problem hiding this comment.
💡 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), |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| await this.applyPaywall( | ||
| postData as Record<string, any>, | ||
| metaBuilder, | ||
| !!isAuthenticated, | ||
| readerId, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
/membership/status now returns effectiveMembershipStatus(membership), which reports expired once currentPeriodEnd is in the past. Addressed in an earlier commit.
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.
There was a problem hiding this comment.
💡 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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 }, |
There was a problem hiding this comment.
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 👍 / 👎.
| const adapter = this.resolveProvider(membershipConfig.provider) | ||
| return adapter.createCheckout({ |
There was a problem hiding this comment.
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 👍 / 👎.
…ren't dodo-coupled
There was a problem hiding this comment.
💡 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".
| if (!doc?.isPremium) return doc | ||
| return { | ||
| ...doc, | ||
| text: getPublicText(doc), | ||
| content: getPublicContent(doc), |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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), |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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 👍 / 👎.
…ons instead of hardcoded path match
There was a problem hiding this comment.
💡 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".
| if ( | ||
| effectiveIsPremium && | ||
| effectiveContentFormat !== ContentFormat.Lexical | ||
| ) { | ||
| throw createAppException(AppErrorCode.PREMIUM_REQUIRES_LEXICAL) |
There was a problem hiding this comment.
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 👍 / 👎.
| const enabled = !!config.enabled && !!config.provider && plans.length > 0 | ||
| return { enabled, plans: enabled ? plans : [] } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
💡 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) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| if (!membershipConfig.enabled || !membershipConfig.provider) { | ||
| throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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, |
There was a problem hiding this comment.
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 👍 / 👎.




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.mdWhat's included
Core
posts.is_premium,memberships,billing_webhook_events(expand-only migration0024).EntitlementServiceas the sole cross-module export.getPublicText/getPublicContent, entitlement-keyed HTTP cache.PREMIUM_REQUIRES_LEXICAL).Admin
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/recentand/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):
Local verification
End-to-end against a live Dodo test-mode account: product creation → checkout session → real card payment → webhook (
subscription.active+renewed) verified viastandardwebhooks→ idempotent persist → membershipactive→ entitlementtrue. Full core test suite green (1884 passing).Note
0508c9fbf fix(admin): improve webhook editor feedbackis 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.