Skip to content

[pull] main from TryGhost:main#1098

Merged
pull[bot] merged 10 commits intocode:mainfrom
TryGhost:main
Apr 27, 2026
Merged

[pull] main from TryGhost:main#1098
pull[bot] merged 10 commits intocode:mainfrom
TryGhost:main

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented Apr 27, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

sagzy and others added 10 commits April 27, 2026 10:22
closes https://linear.app/ghost/issue/BER-3477

Marks a member's redeemed gift as `consumed` (with `consumed_at = now`) when they upgrade to a paid subscription, instead of leaving the gift in `redeemed` status until its natural expiry.

## What changed

- **Event subscription**: `GiftServiceWrapper` subscribes to
`SubscriptionActivatedEvent` and consumes the redeemer's active gift on
activation. Failures are caught and logged so an event-handler error
never bubbles back into the subscription-activation flow.
- **Shared `consume()` method**: extracted from `processConsumed` so
both the scheduled batch job and the new event-driven path go through
the same logic — re-fetch under `forUpdate`, no-op if not `redeemed`
(idempotent), optional `transacting` for composition with an outer
transaction.
ref #27530                                                                                                         
                                                                                                                                                           
Emits structured metrics from the Redis cache adapter for every                                                                                          
operation: hit, miss, timeout, error, reset, and background refresh
(triggered/succeeded/failed). Each metric carries an optional                                                                                            
'feature' dimension (via the new featureName config option) for                                                                                          
dashboard filtering. Timed operations include a duration value.

The timeout path uses a single-settlement guard to prevent                                                                                               
double-counting when a timed-out lookup later rejects. set() accepts                                                                                     
a throwOnError option so background refresh only emits 'succeeded'                                                                                       
after a write that actually landed.
closes https://linear.app/ghost/issue/ONC-1640/member-with-email-disabled-repeatedly-being-sent-newsletters-for

Always set member.email_disabled to true on 607 errors from Mailgun.

When a suppressed member was deleted and someone later signed up with
the same email, the new member was created with `email_disabled=false`
even though the suppression record still existed. The send filter then
included them in every newsletter.

Mailgun rejected each send with a 607, triggering the suppression
handler. But the handler silently swallowed the `ER_DUP_ENTRY` error
from trying to re-insert the existing suppression record, never reaching
the dispatch that would have set `email_disabled=true`. The cycle
repeated indefinitely.
Consolidated the per-action branches in the admin-auth iframe bridge
into a single actions table with one shared dispatcher. Each action
declares its URL template, and the listener funnels every call through
one path: parse payload, look up action, build URL, fetch, post
response back. Adding a new action becomes one table entry rather than
a new branch in a switch.

Pulled identifier validation (24-char hex ObjectID shape) and
querystring construction into small helpers at the top of the module.
Identifier shape is checked once at dispatch, so URLs built from
message-supplied IDs reject malformed input before reaching fetch.

Unknown actions are ignored, malformed JSON no longer breaks dispatch,
and the parse-failure diagnostic log now includes the bridge name and
payload. No behaviour change for the comments UI, which already sends
valid ObjectIDs.

A small ESLint override under core/frontend/src/admin-auth/** lints the
bridge in a browser environment without Node-only rules
(no-console, ghost-custom/no-native-error).

Credit: Jorian Woltjer
fetchOembedData returns provider-supplied embed html for rich and
video oEmbed response types. The fallback path — used when the source
isn't on the known-provider allowlist — now drops that html and
treats the response as a bookmark candidate, so the caller falls back
to a bookmark card.

Allowlisted providers (YouTube, Vimeo, Twitter, Spotify, SoundCloud,
etc.) continue to use @extractus/oembed-extractor's allowlist via
knownProvider, unchanged. photo-type responses are unchanged.

Credit: Jorian Woltjer
Password change and password reset destroyed every other active
session for the user, but the originating session was preserved
unchanged — including its session_id. The originating browser stayed
logged in (intended), but the cookie value didn't refresh.

Both endpoints now call req.session.regenerate() and re-assign the
verified user to the new session, so the originating browser keeps its
login with a fresh session_id. Extracted as
rotateAndAssignVerifiedUserToSession to share the logic across the two
endpoints.

Session.destroy is now idempotent: a missing row is treated as
success. Needed because req.session.regenerate() calls store.destroy()
on a row the model-layer destroy loop has already removed.
no ref

This is a types-only change.
…#27561)

closes https://linear.app/ghost/issue/BER-3566

We validate that a tier is active at the time a gift is purchased. After
purchase, gifts should remain redeemable even if the tier is later
archived, since they have already been paid for already. This change
allows gifts to be redeemed on an archived tier.

Additionally, gift members can currently only continue their
subscription on the same tier/cadence so remaining gift time can be
carried over as trial days. If that tier has since been archived, this
results in a checkout error, as we don't allow paid subscriptions on
archived tiers. With this change, the UI renders the standard upgrade
flow when the gifted tier is no longer available, letting the member
pick from current active tiers. The trade-off is that the remaining gift
days can't be preserved in that case; archived tiers are expected to be
an edge case in practice.

## What changed

Portal:
- New `isArchivedTier({member, site})` helper that follows the existing
`isActiveOffer` pattern (a tier id missing from `site.products` means
the tier has been archived).
- The gift "Continue subscription" banner and the gift-specific Continue
button on the account page are gated on the tier still being available;
otherwise the regular Change button takes over and routes the member
through the standard upgrade screen.

Backend:
- `MemberRepository.create` / `update` previously rejected any product
that wasn't `active` with "Cannot use archived Tiers", which also
blocked the gift-redemption path (creating a member on the gifted tier).
The check is now skipped when `status === 'gift'`. Non-gift paths
(comped, paid) keep the archived-tier block.
- In `create()`, the products validation block was moved below the
status defaulting block so the gift carve-out can read the resolved
status.
@pull pull Bot locked and limited conversation to collaborators Apr 27, 2026
@pull pull Bot added the ⤵️ pull label Apr 27, 2026
@pull pull Bot merged commit b119490 into code:main Apr 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants