Skip to content

Added a member account module for the members API projection - #30436

Draft
rob-ghost wants to merge 9 commits into
mainfrom
feat/ber-3864-member-account-projection
Draft

Added a member account module for the members API projection#30436
rob-ghost wants to merge 9 commits into
mainfrom
feat/ber-3864-member-account-projection

Conversation

@rob-ghost

@rob-ghost rob-ghost commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

Ghost tells a member one thing about themselves, and tells staff a different thing about that same member. It builds a single set of details for both, and then each place that sends details outward removes whatever that place does not want. Nowhere states what a member is meant to receive.

Several places each do their own removing. None was written with reference to the others, and nothing checks them against each other, so they no longer match. A comment includes its author's internal database identifier. A member's own details leave that identifier out. Anyone can therefore learn the identifier of a person who commented, but not their own. Nobody decided that, and noticing it meant reading several files across unrelated parts of the codebase.

Building one set of details for both audiences also makes members pay for what only staff read. Recognising a signed-in reader runs the same preparation, and that happens on every page of every site, so work no member ever sees runs on every page view. Part of it is a separate database lookup asking which page first brought that member to the site.

Solution

There is now a single place that states what a member receives about themselves and what a member is allowed to change.

What members receive does not change at all. Tests written before anything else, and never edited afterwards, are what establish that; the first commit stands alone and is enough to check the claim. Those tests record whole responses rather than named fields, for a free member, a paying member, and a member given free access, because Ghost builds each of the three differently and because the failure worth catching is a field appearing or disappearing.

Preparing a member's details apart from staff's also means preparing less of them. Ghost no longer collects the private labels staff attach to a member, and no longer asks which page first brought them to the site. Neither has ever been shown to a member. Which page brought them to an individual subscription is still asked, because a site's theme can display it.

The other places keep removing fields exactly where they do today. What a theme can read is a published promise to theme authors; the newsletter preference pages identify a person by a signed link rather than a login, and withhold payment details on purpose; a comment's author belongs next to the staff version of the same thing. Combining them would mean showing at least one of those audiences more than it gets now. They are instead written down together, which is what made the disagreement above visible in the first place.

This covers one of those places on purpose. The aim is a single worked example, so the team can read real code and decide whether the pattern is worth repeating, instead of that being settled by applying it to three other areas at the same time. The remaining places, the comment author identifier, and a separate inconsistency in how the staff API answers its own request to include custom fields, all stay open on the issue.

It is also a prerequisite for letting members edit their own custom fields from their account, which needs one place to decide which fields a member may see and change.

ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

Ghost assembles one member payload and several surfaces narrow it independently
on the way out, each with its own allowlist and no shared definition. Two of
those surfaces are fed by a single read: the members API response Portal
consumes, and the @member data a theme renders. That read is about to move
behind a member-account module with a recipe of its own, so what each surface
currently receives needs to be written down first, while it is still
demonstrably unchanged. Anything that differs afterwards is then a decision
someone made rather than drift nobody noticed.

The members API snapshots cover a free, a paid and a comped member, because
those three assemble differently: a comped member's subscription is synthesized
from their products rather than read from Stripe, and only a paid member
exercises the offer and next-payment steps. Whole bodies are snapshotted rather
than selected keys, since a key appearing or vanishing is the failure worth
catching and asserting named keys would miss it. The theme's shape is pinned by
having the test theme render the keys of @member rather than their values,
which keeps the assertion about the contract instead of the fixture data.

The comped member is built in the test: the comped@test.com fixture is
status paid despite its name, so it never reaches the branch it appears to
cover.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

Reading a member means running a sequence of steps over the loaded model, because
most of what a member payload carries is not a relation that can be eager-loaded:
Stripe subscriptions, tiers, offers and their redemptions, gifts, attributions and
the email suppression list each need their own fetch or their own derivation. The
BREAD service already had those steps as separate methods, so the sequence was
legible, but every one of them read its collaborator off this and could therefore
only ever run as part of the one recipe that service holds.

There is more than one audience for the same steps. Staff reading a member in
Admin want all of them; a member reading their own record wants fewer, and Ghost
runs that second read on every page view of a themed site. The withCustomFields
flag is the shape that need has taken so far: a hole punched through a shared
recipe for a caller that wanted less of it. A step that takes its collaborator as
an argument can be composed into either recipe instead, which is what lets a
second one exist at all.

Nothing about the sequence changes here. The BREAD methods stay as they were and
delegate, so the two ordering constraints that matter still hold and its own tests
still describe it. Browse keeps asking the suppression list once for the whole
page rather than adopting the per-member step, since those are genuinely different
questions.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

A member has two views: the one Ghost serves them about themselves, and the one it
serves staff about them. Only the second was assembled deliberately. One payload
was built for both, and each surface narrowed it on the way out through a pick of
its own, so what a member receives was the residue of whichever list ran last
rather than anything stated. The lists had already drifted apart with nothing to
compare them against.

This gives the first of those views a module. It owns its own read, its own
projection and its own writable fields, and it says in one place what a member may
see and set. The projection is unchanged, byte for byte, and the tests taken
beforehand are what say so; only the code behind it moves.

The read it runs is narrower than the staff one, which is the point of separating
them. Ghost identifies a signed-in reader through this path on every page view of
a themed site, so a relation loaded here is loaded on all of them: it stops loading
labels and stops asking where the member came from, neither of which any
member-facing surface reads. Where each subscription came from is still asked,
because a theme can reach it. The withCustomFields flag goes with them, since it
existed only to let one caller opt out of part of a read it shared with another
audience, and that caller now has a read of its own.

Three other surfaces narrow a member for their own audiences and keep doing so
where they are: the theme data, which is a versioned part of Ghost's theme API,
the newsletter endpoints, which authenticate by uuid rather than by session and
withhold billing data on purpose, and the comments author shape. They are recorded
in projections.ts so the four can be read side by side, which is how the one
genuine disagreement between them became visible: a comment carries its author's
internal id, and a member's own response does not.
@nx-cloud

nx-cloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 6e26c29

Command Status Duration Result
nx run ghost:test:ci:e2e ✅ Succeeded 4m 26s View ↗
nx run ghost:test:legacy ✅ Succeeded 3m 19s View ↗
nx run ghost:test:e2e ✅ Succeeded 2m 48s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 27s View ↗
nx run-many -t test:unit -p ghost ✅ Succeeded 26s View ↗
nx run-many -t lint -p ghost,ghost-monorepo ✅ Succeeded 21s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded 2s View ↗
nx run @tryghost/admin:build ✅ Succeeded 5s View ↗
nx run @tryghost/e2e:test:fixtures ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-09-01 20:31:58 UTC

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.81481% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.44%. Comparing base (33ae1b4) to head (6e26c29).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...es/members/members-api/services/member-assembly.ts 80.90% 13 Missing and 8 partials ⚠️
...re/core/server/services/members/account/service.ts 81.81% 9 Missing and 1 partial ⚠️
...re/core/server/services/members/account/queries.ts 91.42% 0 Missing and 3 partials ⚠️
...ore/server/services/members/account/serializers.ts 75.00% 2 Missing and 1 partial ⚠️
...ore/core/server/services/members/account/models.ts 92.00% 1 Missing and 1 partial ⚠️
...st/core/core/server/services/members/middleware.js 80.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main   #30436       +/-   ##
===========================================
- Coverage   76.17%   67.44%    -8.73%     
===========================================
  Files        1679     1663       -16     
  Lines      160375    60086   -100289     
  Branches    19709    10381     -9328     
===========================================
- Hits       122165    40526    -81639     
+ Misses      37219    17268    -19951     
- Partials      991     2292     +1301     
Flag Coverage Δ
e2e-tests 70.22% <84.81%> (-7.62%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

Three other surfaces narrow a member for their own audiences, and their field
lists were copied into this module so the four could be read side by side. The
copies were never imported and nothing checked them against the call sites they
described, so they were commentary in the shape of code: free to drift while still
reading as authoritative, which is a worse failure than the drift they were meant
to expose. The finding they were carrying belongs in the issue, where it can be
argued with.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

The members API served Portal a member assembled in the application: load the row
through Bookshelf, then walk it through a sequence of services that each attach
another piece. What a member receives was the result of that walk rather than
anything stated, and the walk pulled in relations only staff ever read.

The projection is a query now. The member and each of its collections are read
directly, the rows are decoded into the payload, and what a member is shown is
described in one place by the query that fetches it and the codec that shapes it.
Nothing a member receives changes; the tests taken before any of this moved are
what say so, and they pass unedited.

Collections are separate queries rather than one statement. A member has two
independent collections, and joining both returns their product, so each
subscription would repeat once per newsletter. The alternative is aggregating each
collection into a JSON column, and that cannot be written once: MySQL and SQLite
spell the aggregate differently, and MySQL will not accept an ordering inside it,
so the newsletter order this projection promises could not be expressed in SQL at
all. Split this way every query compiles identically for both engines and the
ordering lives where it belongs. It also means one member and a page of them cost
the same number of statements, which is what the admin list will need.

The subscription a comped or gifted member holds is derived rather than stored, so
it is derived by query too, from the products they hold and the event that granted
them. Writing it that way forced a rule into the open that the application had
been leaving to chance: a member can hold more than one redeemed gift, and the
previous lookup took whichever the database happened to return last. The query
ranks them and says why.

What the queries do not read is anything another domain owns. An offer carries its
own redemption counts and an attribution resolves a URL through routing
configuration rather than a column, so both are asked for while decoding. The
unsubscribe link is an HMAC over a secret and the avatar is a gravatar URL; both
are handed to the codec rather than computed inside it.

Identifying a signed-in member stays where it was. A session needs transient_id
and last_seen_at, which a member is deliberately never shown, so covering it here
would stop this being the member's own view — that surface wants a projection of
its own.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

This module had its own pair of column codecs for the two things the engines
disagree about. Both already exist in lib/db-types, both are better, and one of
mine was wrong.

SQLite has no date type and returns a date as `yyyy-MM-dd HH:mm:ss`. Handing that
to `new Date()` reads it in whatever timezone the machine happens to be set to, so
every date a member sees would have been shifted by the local offset — four hours
out on a US East Coast development machine, and correct only where the offset is
zero. Nothing here would have caught it: the test suite runs against MySQL, which
returns dates already parsed. The shared codec goes through luxon with an explicit
UTC zone, and a test now pins the SQLite spelling against a non-zero offset.

The plan on a subscription has the same fix behind it. Every part of a plan is its
own column, recording what the member agreed to when they subscribed, and that is
deliberately not the price they are joined to today. The amount was already read
from the right place; the identifier was not, and only matched because the two
happen to be equal in the fixtures.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

The service was wiring the projection together by hand: group the rows, decode
each kind, then walk the result three more times attaching offers, then payments,
then attributions. Every one of those steps had to run, in that order, and the
knowledge of what a member's payload contains was spread across the service rather
than stated anywhere.

Schemas nest, so the decode nests with them. A newsletter, a tier and each kind of
subscription are now schemas that parse their own rows and return their own part
of the payload, and the member schema simply says it has some of each. Reading a
member is a query and a parse: `queries.read` gathers the rows that make one, and
`MemberAccount` turns them into what a member receives.

What a decode needs and a row cannot carry — what the next payment comes to, where
an unsubscribe link points, whether the site shows gravatars — is passed in rather
than reached for, so nothing in the schemas does any IO and all of them can be
exercised with plain objects. The one ordering rule that is real, that a payment
cannot be worked out before the offer it depends on, now sits inside the schema
that needs it instead of being a comment on a method a caller had to remember.

Offers and attributions still come from the domains that own them, but they are
gathered once alongside the rows and travel with the subscription they describe,
rather than being attached afterwards by walking the result again.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

The fields a member may change about themselves were a list of column names and a
pick. That is what an object schema already does with unknown keys, so the list
was a hand-rolled version of a mechanism this module uses everywhere else.

Naming it for the request it belongs to rather than for the columns it happens to
allow also makes room for the next one. What a member may send is a property of
what they are asking to do, and setting their own metafields will accept a
different body under different rules; a second command sits beside this one, where
a second list of writable columns would have had to merge into it.

The values stay unknown rather than typed. Typing them would start refusing a
wrongly-typed value that Ghost currently accepts and coerces, which is a change to
what the API does and belongs in a change that says so.
ref https://linear.app/ghost/issue/BER-3864/differentiate-public-and-private-member-projections

What this module called a model was not one. It produced snake_case keys and ISO
strings, which is how the API writes an account down rather than what an account
is, and the response was then picked over a second time on the way out. Two
mappings, one of them accidental, and no shape in between that anything could be
reasoned about.

There is now a model in the middle. It speaks the domain — dates are dates, names
are camelCase, and the commenting record is the object its own codec produces
rather than the form that codec publishes. The serializer turns one into the
response the members API has always sent. Each can move without the other: a
response key cannot be withdrawn once clients read it, so the wire shape is a
promise, while a column can be added or renamed, so the row shape is not.

`firstname` and `paid` moved to the serializer, where they belong. Neither is
stored and neither is a fact about a member; they are conveniences for whoever
renders one, and they appear in this response and nowhere else. `subscribed` moved
with them and is now written down as the constant it has always been: a member has
no such column, and the Admin API derives its own meaning for the word rather than
reading one from here.

The serializer is written out rather than run through a key converter. Half of
what an account carries belongs to other domains and arrives in the shape those
domains publish, some of it camelCase; converting keys wholesale would rewrite an
offer or an attribution into something no client has been sent.

The unit tests for the old function went with it. They asserted its behaviour
against a member shape that no longer occurs — one carrying `subscribed`, which
nothing produces — and what they were really covering is now split between the
serializer's own tests and the response snapshots.
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