Skip to content

[pull] main from TryGhost:main - #1411

Merged
pull[bot] merged 25 commits into
code:mainfrom
TryGhost:main
Aug 13, 2026
Merged

pull[bot] merged 25 commits into
code:mainfrom
TryGhost:main

Conversation

@pull

@pull pull Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

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 : )

ErisDS and others added 25 commits August 13, 2026 11:25
Make the internal package golden path enforceable rather than relying on
documentation alone.

Introduce `compliant`, `migration`, and `exempt` lifecycle states, allowing
history-preserving imports to remain intentionally transitional until their
separate modernization work.

Add a checker for package metadata, TypeScript/ESM structure, exports, shared
configuration and workspace dependencies. It uses pnpm as the workspace source
of truth and validates the package template without letting template drift
redefine the contract.

Run the check through local linting and lightweight, path-filtered CI.
We're moving our codebase docs into the repository. The PR was done
in two steps - copy and update. The resulting will have one change but 
have access to that history.

The resulting docs are split into Translating Ghost aimed at i18n 
contributors AND an internal codebase guide for creating translatable copy.
ref TryGhost/docker-library-ghost#429
- add support for _FILE-suffixed environment variables pointing to local files for secrets loading, in keeping with existing Docker conventions
Some of the public app READMEs had stale information and were 
functionally incorrect. This is part of work to bring codebase docs
into the codebase, and make sure the information is consistent,
correct and coherent.
ref https://linear.app/ghost/issue/BER-3846/

- the mapping table was capped at a hardcoded 400px, which takes no account of
  how much room the dialog actually has: on a short window the footer is pushed
  off the screen and an import that has already been started cannot be finished
- the dialog is now bounded to the viewport and laid out as a column, so the
  table takes whatever height is left over and the footer is always reachable
- it also grows on a tall screen instead of stopping at 400px, which is worth
  having on a screen whose whole job is checking mappings against sample data
- not gated: this is broken for everyone importing members today, and has
  nothing to do with the custom fields work the rest of this branch carries
ref https://linear.app/ghost/issue/BER-3846/

- the list opened at a fixed height, so wherever the field sat low in a tall
  dialog it ran past the bottom of the screen: the last labels were unreachable
  and nothing indicated the list scrolls
- it now measures the room below when it opens and takes what is there, between
  the height it always had and a floor worth showing, scrolling inside it as
  before. Where there is room the result is identical to before
- measured rather than anchored deliberately: this lives inside a Dialog, and a
  Radix Popover would portal out of it where the Dialog's scroll lock blocks it,
  which is why the dropdown is hand-positioned in the first place
- CSS cannot express "distance from this element to the bottom of the viewport"
  today. Anchor positioning can (position-area: block-end), and would replace all
  of this with three declarations, but it only reached Baseline in Jan 2026 and
  its failure mode on older browsers is exactly the bug being fixed here
- surfaced by the members import modal growing to the viewport, but the fixed
  ceiling was every consumer's, so all six get it

Isolated to this file on purpose: droppable or replaceable on its own.
ref https://linear.app/ghost/issue/BER-3846/

- leaving a column out of the mapping is how the importer is told to carry it
  through under its own header, which is what lets an exported custom_fields.*
  column re-import untouched — so omitting a column was the opposite of
  excluding it, and a caller had no way to say "not this one"
- an empty target now drops the column, which is the only spelling that could
  not already mean something else
- the admin framework stopped discarding empty mapping values before they
  reached the wire, so that spelling survives the request; null and undefined
  still drop, since they are a column with nothing said about it
- backwards compatible: no caller sent an empty target before, because the
  framework dropped it
…misses

ref https://linear.app/ghost/issue/BER-3846/

The mapping table read its columns from the first parsed row, and papaparse omits keys for a row carrying fewer cells than the header rather than padding it out. A hand-edited or partly exported CSV whose first row is short therefore lost every column that row did not reach: they never appeared in the table, so the publisher was never asked what they held. The importer carries a column the mapping does not name, so those columns were imported anyway, under whatever the header happened to say. Columns are now taken from the whole file, with papaparse's own overflow key left out of the count because it is not a column and no mapping can name it. Not gated: this is wrong for everyone importing members today.
ref https://linear.app/ghost/issue/BER-3846/

The API serializer rewrites an error's message to a generic summary and leaves the sentence that explains the failure, usually with what to do about it, in context. The helper that reads that only understood validation errors, so every other kind came back as the generic summary while the reason sat unread in the payload a line away. Callers noticed and worked around it one at a time, each reaching into the error body themselves to check context before message, which is knowledge none of them should need. It now reads any error carrying an API body the same way, so a caller asks what went wrong without first working out which class it was handed. Toasts pick this up too: a refusal that used to read as a generic summary now says what the server actually said.
ref https://linear.app/ghost/issue/BER-3846/

- a CSV column with no matching field could only be left out: the publisher had
  to abandon the import, create the field in Settings, and upload again
- creating it from the mapping step removes that detour and puts custom fields
  where the need for them shows up, which is migration
- one searchable picker per row lists membership and custom fields under their
  own headings, so what a column can be imported as is answered by reading
  rather than by switching a control to find out what is behind each setting
- a checkbox decides whether a column is imported at all, which the field
  picker no longer has to carry; deselecting keeps the field chosen, since
  excluding a column and choosing what it holds are separate answers
- a composite has no single column, so the form creates the field and reopens
  the picker filtered to it rather than asking which part on a third control
- the table composes one mapping and both the email check and the request read
  it, so nothing can disagree about what is being imported
- columns come from the whole file rather than the previewed row: papaparse
  omits keys for a row with fewer cells than the header, so a ragged CSV could
  otherwise hide a column from the table entirely — and a column the mapping
  never names is carried through by the importer rather than left out
- served through a gate rather than threaded through the import as it shipped:
  the mapping step diverges in almost every part, and while both lived in one
  file every change was opt-out — five reached the flag-off path before anyone
  noticed. The files this feature would have edited are untouched, so it cannot
  regress them by being forgotten about
- the cost is duplication: a fix to the import has to be applied to both, or
  knowingly to one, until the flag goes and the shipped version is deleted
Our old explore integration had an auth flow. This has been
deprecated for a long time. The new explore setup is built
into Ghost so that it doesn't need an auth flow and so much
code to make it work.

This cleans up all the legacy code and cleans up the related
database entries. Note that the rollback is partial, it's not 
possible to recreate the api key, but a rollback is to restore 
Ghost to working not the integration as that is defunct, so
this makes sense as the best option.
closes https://linear.app/ghost/issue/GVA-921/
ref https://linear.app/ghost/project/self-serve-archives-bd3a8920c136

Static UX/UI mockup of the one-click data export flow, behind a new
private labs flag (`selfServeArchives`). Will be wired up in follow-up
PRs
Ported our configuration guide and updated it to match reality.
This is part of work to bring codebase docs into the codebase, 
and make sure the information is consistent, correct and coherent.
no ref
- fix pack script to include repo-root license file
Updated some links to the old "Codex" copy of the
Translating Ghost docs to the new in-repo copy.
Ported our error handling guide and updated it to match reality.
This is part of work to bring codebase docs into the codebase, 
and make sure the information is consistent, correct and coherent.
## Summary

Adds pay-per-request Stripe [Machine
Payments](https://docs.stripe.com/payments/machine) for paid-members
markdown (`.md`) URLs, so AI agents can unlock premium content without
creating a member session.

- **MPP only for v1** (Tempo USDC + Shared Payment Tokens). The
orchestrator stays multi-adapter-ready; additional rails (e.g. x402) are
intentionally not shipping here
- **TypeScript service** under
`ghost/core/core/server/services/machine-payments/` (gifts/donations
pattern: `.ts` domain + thin CJS `index.js` wrapper), wired via
`machinePaymentsService.init()` in `boot.js`
- **One-shot unlock** of markdown bytes for that request only — no tier
grants, Portal changes, or `content-gating` changes
- **Explicit `.md` URLs only** — Accept-header markdown, HTML theme
views, and the Content API stay membership-gated
- Enablement: labs `machinePayments` + `llms_enabled` +
`machine_payments_enabled` + Stripe connected
- Discovery: eligible paid posts appear in `/llms.txt` and get markdown
alternate links when enabled
- Durable Tempo deposit addresses (settings-backed) +
`machine_payment_events` ledger after successful fulfill
- Admin controls in Membership → Tiers (price + toggle), gated behind
labs / Stripe / llms.txt **and** settings-key presence so a newer Admin
against an older API cannot PUT rejected settings

Product fences:
`ghost/core/core/server/services/machine-payments/README.md`.

Supersedes the earlier exploration in #28291.

## Test plan

- [x] Run migrations; confirm `machine_payments_*` settings and
`machine_payment_events` table exist
- [x] Enable Labs → Machine payments; connect Stripe; keep llms.txt on;
enable agent payments under Membership → Tiers
- [x] Publish a paid post; `curl -i http://localhost:2368/<slug>.md`
returns **402** with a Payment challenge (not 403)
- [x] Confirm paid slug appears in `/llms.txt` when enabled, and
free-members-only posts do not
- [x] Confirm public `.md` still returns 200; Accept-header markdown on
paid HTML URLs is unchanged
- [x] Optional roundtrip: `npx mppx@latest validate
http://localhost:2368/<slug>.md` (Stripe sandbox / testnet config)
- [x] `cd ghost/core && pnpm run test:single
test/unit/server/services/machine-payments/`
- [x] Admin tiers acceptance: controls show with lab + settings keys;
hide when `machine_payments_enabled` is absent from browse settings
- [x] Settings / schema snapshot tests updated and green

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Consolidates Ghost-owned licensing around the repository-root LICENSE,
removing redundant copies from individual workspace packages. pnpm
automatically includes the root license when packing each workspace, so
published artifacts remain licensed without duplicated files.

Updates package READMEs to use an absolute repository license URL, which
works both in the monorepo and when npm renders the packed README.

Retains package-local licenses where they contain distinct upstream
attribution, along with licenses used by test fixtures.
Ported our authentication guide and updated it to match reality.
This is part of work to bring codebase docs into the codebase, 
and make sure the information is consistent, correct and coherent.
no issue

The gifts entry point had no TypeScript interface, forcing TypeScript
consumers to suppress missing-declaration errors or add a separate
declaration shim. Moving the existing boot-time wiring into `index.ts`
follows the pattern used by newer services such as gift links and keeps
the interface alongside its implementation.

- replaced the CommonJS `GiftServiceWrapper` singleton with a TypeScript gifts module
- exported live `init`, `service`, and `controller` bindings while preserving existing CommonJS callers
- updated the gift preview tests to mock the service at the require seam instead of mutating the module export
ref https://linear.app/ghost/issue/BER-3848

The React member details screen has until now only been reachable per-site behind a Labs toggle, and self-hosted sites never read the remote flag manifest that switched it on for Ghost(Pro), so promoting the flag to generally available is what actually puts every site on the React screen, while Ghost(Pro) keeps its kill switch because remote overrides still sit above the GA list. Several browser tests had never enabled the flag and so had always exercised the Ember screen, encoding its behaviour: its straight apostrophe, its heading structure, its clickable label, a one-click enable control React does not offer, and its habit of letting an invalid email reach the server and of settling on a "Saved" state after creating a member. Those tests are brought into line with the screen that now actually serves them.
…creen

ref https://linear.app/ghost/issue/BER-3848

With the React member details screen generally available the old Ember screen is unreachable, yet it still has to be kept compiling and passing tests, so it goes along with the flag that used to choose between the two implementations. Removing the Ember member routes lets the member URLs fall through Ember's existing catch-all into React, which is how every already-migrated screen works. Three surviving templates still linked to the deleted route and would have thrown once it was gone, so they now use plain links to the member URL, the same pattern already used elsewhere for React-owned screens. The browser page object also no longer has to match two sets of markup for the same control.
no ref
- modal closes automatically, so a manual close button click races the automatic close
`MailgunClient#getTargetDeliveryWindow`'s doc comment said the configured value is in seconds, but nothing in the value's actual consumption agrees: it's passed straight through unconverted (`mailgun-client.js`) into `BatchSendingService#getDeliveryDeadline`/`#calculateDeliveryTimes`, where it's added directly to `Date#getTime()` results — i.e. treated as milliseconds. The service's own tests confirm this (e.g. `const targetDeliveryWindow = 300000; // 5 minutes` in batch-sending-service.test.js), so the comment was just describing the wrong unit, not the behavior.

Left as a comment-only change since fixing the actual unit (rather than the doc) would silently change delivery timing for anyone who already set `bulkEmail.targetDeliveryWindow` assuming seconds.
@pull pull Bot locked and limited conversation to collaborators Aug 13, 2026
@pull pull Bot added the ⤵️ pull label Aug 13, 2026
@pull
pull Bot merged commit 1a7fc83 into code:main Aug 13, 2026
4 checks passed
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.

8 participants