Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ghost/core/core/server/api/endpoints/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ module.exports = {
return apiFramework.pipeline(require('./feedback-members'), localUtils, 'members');
},

get membersAccount() {
return apiFramework.pipeline(require('./members-account'), localUtils, 'members');
},

get giftsMembers() {
return apiFramework.pipeline(require('./gifts-members'), localUtils, 'members');
},
Expand Down
61 changes: 61 additions & 0 deletions ghost/core/core/server/api/endpoints/members-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const membersService = require('../../services/members');

/**
* A member's own record, as they read and change it themselves.
*
* Who is asking comes from the session the route resolves before this runs, so a
* request with no signed-in member reaches here with nobody attached and is
* answered with nothing rather than an error — a themed page asks this on every
* view, and most of those views have no member.
*
* Nothing here decides anything about a member. What they are shown and what they
* may change belong to `services/members/account`; this says which of those two
* questions is being asked.
*/

interface Frame {
data: Record<string, unknown>;
options: {
context?: {
member?: { id: string } | null;
};
};
}

const memberOf = (frame: Frame) => frame.options?.context?.member ?? null;

/** Nobody signed in is not an error, and has no body to send. */
const emptyWhenNobody = (result: unknown) => (result === null ? 204 : 200);

const controller = {
docName: 'members_account',

read: {
headers: { cacheInvalidate: false },
permissions: false,
statusCode: emptyWhenNobody,
query(frame: Frame) {
const member = memberOf(frame);
return member ? membersService.api.account.read({ id: member.id }) : null;
},
},

// `update` rather than `edit`: the framework reserves `edit` for the Admin API's
// enveloped bodies, and this endpoint has always taken a bare one. The members
// endpoints name their own verbs for the same reason.
update: {
headers: { cacheInvalidate: false },
permissions: false,
statusCode: emptyWhenNobody,
query(frame: Frame) {
const member = memberOf(frame);
// The whole body goes to the service, which owns what a member may set about
// themselves and ignores the rest. Nothing is picked here: which fields are
// writable is not a question about HTTP.
return member ? membersService.api.account.edit(frame.data, { id: member.id }) : null;
},
},
};

export default controller;
module.exports = controller;
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ module.exports = {
return require('./members');
},

get members_account() {
return require('./members-account');
},

get members_metafields() {
return require('./member-metafields');
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { toAccountResponse } from '../../../../../services/members/account';

interface Frame {
response?: unknown;
}

const serialize = (account: unknown, _apiConfig: unknown, frame: Frame): void => {
frame.response = toAccountResponse(account as never);
};

// The API framework loads this file with `require()`, so it exports CommonJS-style;
// `export default` would not be picked up.
module.exports = {
read: serialize,
update: serialize,
};
99 changes: 99 additions & 0 deletions ghost/core/core/server/services/members/account/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Member Account

Member Account covers the member's own view of their own record: what Ghost hands
a signed-in member about themselves, and what it accepts back from them.

## Language

**Account**:
A member as they see themselves. The same row a staff member reads through the
Admin API, projected for its subject rather than for an observer. An account is a
viewpoint on a member, not a second record.
_Avoid_: Profile, member payload

**Member**:
The record itself, and the entity every other part of Ghost means by the word.
Reading a member is not reading an account; the two carry different fields, in
both directions.
_Avoid_: User, subscriber

**Projection**:
The set of fields one audience may see or set. Named and declared rather than
applied ad hoc at each call site, so that what an audience receives is a stated
contract instead of the residue of whichever pick ran last.
_Avoid_: Serializer, allowlist, whitelist

**Public projection**:
The account projection: what a member receives about themselves over their own
session. Not a subset of the admin one — `firstname` and `paid` exist only here.
_Avoid_: Member fields, safe fields

**Admin projection**:
What staff receive about a member through the Admin API, assembled by
`MemberBREADService`. Owned there, named here only to say that this module does
not touch it.
_Avoid_: Private projection, internal fields

**Query**:
A read against the tables this module owns, returning flat rows. Every query takes
a list of member ids and returns rows carrying the id they belong to, so the same
query serves one member and a page of them.
_Avoid_: Fetch, loader

**Decode**:
Turning rows into the payload: grouping them by member, supplying the literals a
granted subscription is made of, and asking other domains for what they own.
_Avoid_: Hydration, assembly, mapping

## Shape

`schema.ts` describes rows, `queries.ts` reads them, `models.ts` turns them into
an account, `serializers.ts` writes an account the way the API spells one, and
`service.ts` is what an endpoint calls. `commands.ts` holds what a member may ask
to change, one command per request rather than one list of writable columns.

The model in the middle is what lets storage and the wire move independently. A
response key cannot be withdrawn once clients read it, so the wire shape is a
promise; a column can be added or renamed, so the row shape is not. Neither is
free to drag the other along.

The serializer is written by hand rather than run through a key converter, because
half of what an account carries belongs to other domains — an offer, an
attribution, what the next payment comes to — and arrives already in the shape
those domains publish, some of it camelCase. Converting keys wholesale would
rewrite them into something no client has been sent.

Collections are separate queries rather than one statement. A member has two
independent collections, and joining both returns their product; the alternative
is JSON aggregation, which is spelled differently in MySQL and SQLite and which
MySQL will not let state an order. Split this way every query compiles identically
for both engines, and the newsletter order lives in SQL where it belongs.

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 during decode. The
unsubscribe link is an HMAC over a secret, and the avatar is a gravatar URL; both
are supplied to the codec rather than computed inside it.

`index.ts` is a barrel rather than a composition root: the service's collaborators
are built inside `members-api.js` and handed to it there, next to
`MemberBREADService`, so there is no boot step of its own to own.

## Boundaries

This projection serves the members API that Portal reads, and nothing else yet.

Identifying a signed-in member is a different question and still goes through the
staff read. A session needs `transient_id` and `last_seen_at`, which a member is
deliberately never shown, so widening this projection to cover it would stop it
being the member's view. That surface wants a member-session projection of its
own, decoupled from this one.

The account projection is applied here. Three other surfaces narrow a member for
their own audiences and keep doing so at their own call sites: the theme
`@member` data (a versioned part of Ghost's theme API), the newsletter preference
endpoints (authenticated by uuid and HMAC rather than by session), and the
comments author shape. Consolidating them is a decision this module does not make,
and their field lists are deliberately not copied here — a copy that nothing
checks against the call site it describes goes stale while still reading as
authoritative.
39 changes: 39 additions & 0 deletions ghost/core/core/server/services/members/account/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { z } from 'zod';

/**
* What a member may ask Ghost to change about themselves.
*
* A command per thing a member can do, rather than one list of writable columns:
* what a member may send is a property of the request they are making, and the
* next one — setting their own metafields — accepts a different body under
* different rules.
*
* The read side has no counterpart here on purpose. What a member is shown is
* expressed by `queries.ts` and `models.ts`, so a list restating it would be a
* copy that nothing checks.
*/

/**
* Anything a member sends that is not named here is dropped rather than refused,
* which is what an object schema does with unknown keys anyway.
*
* Dropping rather than refusing is deliberate, and `email` is the precedent: a
* member's own response carries it, a member sending it back has it ignored, and
* changing it goes through a route of its own that verifies the new address by
* magic link. A field a member may change under different terms gets its own
* route rather than a condition inside this one.
*
* The values are 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.
*/
export const UpdateAccount = z.object({
name: z.unknown().optional(),
expertise: z.unknown().optional(),
subscribed: z.unknown().optional(),
newsletters: z.unknown().optional(),
enable_comment_notifications: z.unknown().optional(),
enable_updates_and_announcements: z.unknown().optional(),
});

export type UpdateAccount = z.infer<typeof UpdateAccount>;
12 changes: 12 additions & 0 deletions ghost/core/core/server/services/members/account/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* A member's own view of their own record, as the members API serves it to Portal.
* See CONTEXT.md for the language.
*
* A barrel rather than a composition root: this module owns no boot step, and its
* collaborators are the ones `members-api.js` already builds, so the service is
* constructed there beside `MemberBREADService`.
*/
export { MemberAccountService } from './service';
export { MemberAccount, type DecodeDependencies } from './models';
export { toAccountResponse } from './serializers';
export { UpdateAccount } from './commands';
Loading
Loading