[pull] main from TryGhost:main - #1411
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )