The work in this article is the data layer for the Ramblers Email Project, a 2025 General Council motion (Motion 2025[7]) asking Head Office to deliver an email channel that lets Area and Group officers contact their own members. Ramblers has roughly 500 groups across the UK. Other than a small number of groups already running on NGX-Ramblers, none of them has an automated GDPR-compliant way to email its own membership. Groups have walks programmes, social events, AGM notices and committee changes to communicate; there has been no programmatic way to reach the people who have signed up to walk with them.
The technical block has always been the same shape: groups need a way to read their current member list and the consent flags those members have set. No such API exists today. Head Office's April 2026 AGM update (Item 9 — Open Motions Report) confirms a narrower path forward: two member-built systems — NGX-Ramblers and Charlie Bigley's MailMan — being prototyped as the interim route. Both depend on the API described in this article.
That is the wider project. The rest of this piece is about the Salesforce-shaped feed that makes the email work possible.
Status (updated 27 Apr 2026, post-meeting):
- Phase 1 ✓ —
MemberProviderport merged intomainof nbarrett/ramblers-salesforce-mock, tracked in #5. The route handlers no longer reach into Mongoose;MockMemberProviderdoes the data work.- Phase 2 ✓ — wire-format contract moved out into its own repo: nbarrett/ramblers-salesforce-contract, published at
v0.1.0, consumed by the mock via a pinned git tag. Tracked in ramblers-salesforce-mock#7. TypeScript types, Zod request schemas, the OpenAPI builder, the error envelope and the 36 Insight Hub column definitions all live there now.- Phase 3 ✓ — production server skeleton merged: nbarrett/ramblers-salesforce-server now exists, deployed on Fly at
salesforce-server.ngx-ramblers.org.uk(lhr), consumes@ramblers/sf-contract@v0.2.0, serves/healthz,/healthz/salesforce(reports503 not_configureduntil Head Office wire credentials), and the two day-one API endpoints (which currently return401for unauthenticated calls and would return501 NotImplementedonce a bearer token is configured). Same OpenAPI document, same Zod request validation, same error envelope as the mock — built from the same contract module. Tracked in ramblers-salesforce-contract#1.- Phase 4 next — Ramblers Head Office inherit the production-server skeleton and fill in five concrete bits:
buildJwtAssertion(RS256 signing of the JWT bearer assertion to authenticate against Salesforce),SalesforceMemberProvider.listMembers(SOQL query + record-to-wire mapping),SalesforceMemberProvider.applyConsent(Contact update + ConsentEvent__c insert),salesforceToMemberandmemberToSalesforcefield mappers. Each method currently throws aNotImplementedexception with structured hints to the developer about exactly what needs to land where.Wire format, OpenAPI and the public API are unchanged — NGX-Ramblers and MailMan are unaffected. The mock continues to run at salesforce-mock.ngx-ramblers.org.uk.
Companion piece to the existing Ramblers Salesforce Mock Server write-up.
Update (21 Jun 2026): since this article, Head Office confirmed they work in Python, Azure and Entra. The default production path is now a Python reference server on Azure Container Apps, interchangeable on the wire with everything below. The TypeScript server described here stays as a reference and fallback. Full write-up: A Python Reference Server.
If you're Ramblers Head Office: the production server is already standing at
salesforce-server.ngx-ramblers.org.uk. The default is now the Python server (three-method checklist, ramblers-salesforce-server#3); the TypeScript route is the five-function checklist in ramblers-salesforce-server#1. Skip the architecture sections below if you just want to fill the mappers in.
The mock at salesforce-mock.ngx-ramblers.org.uk was built to unblock NGX Ramblers and Charlie Bigley's MailMan against the day-one #209 contract while Ramblers Head Office build the real thing on their own cadence. Structurally, that mock was already 90% of a production server. Replacing its data layer with a port-and-adapter interface meant the same code could run against synthetic data (mock mode) or a real Salesforce org (production mode), with no change to the wire format downstream consumers depend on.
This article describes how that split played out: a shared contract package, the mock server (refactored), and a production server Ramblers Head Office can take over and finish. The first version of the article (27 Apr) was a proposal; this version is a state of play.
From a consumer's perspective, the wire format is identical regardless of which server is on the other end of the cable.
flowchart TB
NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }
Wire(["Shared wire contract<br/>OpenAPI · Zod · TS types"])
subgraph mock["Mock environment"]
MOCKSRV@{ icon: "ngx:express", label: "Mock server", pos: "b", h: 48 }
ATLAS@{ icon: "ngx:mongodb", label: "MongoDB Atlas<br/>synthetic data", pos: "b", h: 48 }
MOCKSRV --> ATLAS
end
subgraph prod["Production environment"]
PRODSRV@{ icon: "ngx:express", label: "Production server", pos: "b", h: 48 }
SF["Salesforce org<br/>real member records"]
PRODSRV --> SF
end
NGX --> Wire
MM --> Wire
Wire --> mock
Wire --> prod
style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style prod fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style Wire fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:8,ry:8,color:#404143
What we are describing is a textbook combination of two well-known patterns.
Hexagonal architecture (ports & adapters). A port is a TypeScript interface — a contract that says "anything wanting to provide member data to this server must offer these methods, with these inputs and outputs." It has no implementation; it is a shape. An adapter is a concrete class that implements the port: the actual code that knows how to fetch data from somewhere specific (a Mongo database, a Salesforce org, an in-memory test fixture). The HTTP and validation layers only ever talk to the port; they do not know or care which adapter is plugged in. Swapping the data source becomes a one-line change at app startup. Think of it like a USB port on a laptop: the port defines the shape and protocol; mice, keyboards and external drives are different adapters that all satisfy the same port.
Contract-first (or OpenAPI-first) development. The OpenAPI spec, the request/response Zod schemas, the error shape, and the TypeScript types all live in a single shared package that both servers depend on. The wire format cannot fall out of sync because there is only one place it lives — change the contract and both servers see the change at compile time.
flowchart TB
NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }
Contract@{ icon: "ngx:file", label: "@ramblers/sf-contract v0.3.0<br/>types · Zod · OpenAPI · errors · columns · MemberProvider", pos: "b", h: 64 }
subgraph mock["Mock — salesforce-mock.ngx-ramblers.org.uk"]
direction LR
MOCKAPI["Express + Zod + auth"]
MOCKADAPTER["MockMemberProvider"]
MOCKDB@{ icon: "ngx:mongodb", label: "MongoDB Atlas<br/>Admin SPA · xlsx · synthetic", pos: "b", h: 48 }
MOCKAPI --> MOCKADAPTER --> MOCKDB
end
subgraph prod["Production — salesforce-server.ngx-ramblers.org.uk"]
direction LR
PRODAPI["Express + Zod + bearer auth"]
PRODADAPTER["SalesforceMemberProvider<br/>methods throw NotImplemented"]
PRODSF["Salesforce org<br/>(Phase 4 — Head Office to wire)"]
PRODAPI --> PRODADAPTER -.Phase 4.-> PRODSF
end
NGX --> Contract
MM --> Contract
Contract --> mock
Contract --> prod
mock ~~~ prod
style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style prod fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style Contract fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:8,ry:8,color:#404143
Three repos, three deployments, one shared contract module versioned at v0.3.0. The MemberProvider interface, the wire-format types, the OpenAPI builder, the Zod request schemas, the error envelope and the 36 Insight Hub column definitions all live in @ramblers/sf-contract. Both servers pull a pinned tag and import from there — neither holds a copy.
The mock and the production server are visually and structurally peers. Wire format goes through the contract; whichever server a consumer points at, the bytes on the response are the same shape. Phase 4 is the dotted arrow: Head Office replaces the NotImplemented exceptions with real SOQL and field mappers.
flowchart TB
NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }
AUTH["Bearer + operator auth<br/>(src/auth)"]
ADMIN["Admin SPA<br/>(src/admin)"]
ROUTES["HTTP routes + validation<br/>(src/api)"]
IMPORTER["xlsx import + synthetic generator<br/>(src/ingest)"]
MONGOOSE["Mongoose models<br/>(src/db/models — directly imported by routes)"]
ATLAS@{ icon: "ngx:mongodb", label: "MongoDB Atlas", pos: "b", h: 48 }
NGX --> ROUTES
MM --> ROUTES
AUTH --> ROUTES
ADMIN --> ROUTES
ROUTES --> MONGOOSE
IMPORTER --> MONGOOSE
MONGOOSE --> ATLAS
style MONGOOSE fill:#FBE3E3,stroke:#C46B6B,stroke-width:3px,color:#404143
The red box was the problem: route handlers import { Member } from "../db/models" and called Mongoose directly. There was no interface — to swap in Salesforce we would have had to fork the repo or rewrite the route layer in place. Phase 1 introduced that interface.
flowchart TB
Contract@{ icon: "ngx:file", label: "@ramblers/sf-contract — OpenAPI · Zod · TS types · Errors", pos: "b", h: 64 }
Core@{ icon: "ngx:express", label: "@ramblers/sf-server-core — Express · Routes · Validation", pos: "b", h: 64 }
Port{{"MemberProvider port<br/>(listMembers · applyConsent · groupExists)"}}
subgraph mock["ramblers-salesforce-mock"]
MOCKADAPTER["MockMemberProvider"]
MOCKMONGO@{ icon: "ngx:mongodb", label: "Mongo · Admin SPA · xlsx import · Synthetic", pos: "b", h: 48 }
MOCKADAPTER --> MOCKMONGO
end
subgraph prod["ramblers-salesforce-server"]
PRODADAPTER["SalesforceMemberProvider"]
PRODSF["Salesforce org<br/>(jsforce · OAuth · Field mappers)"]
PRODADAPTER --> PRODSF
end
Contract --> Core
Core --> Port
Port -.implemented by.-> MOCKADAPTER
Port -.implemented by.-> PRODADAPTER
style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style prod fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style Port fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,color:#404143
Three packages, one contract, two interchangeable adapters. The hexagonal node in the middle is the interface — the MemberProvider that both adapters satisfy. Mock and production are visually and structurally peers: neither is privileged, and either one can be removed or replaced without touching the other or the contract above. The "server core" box collapsed in the actual implementation — the contract package now exposes the port directly, and each server keeps its own thin Express layer; one fewer publishable package without losing the property that matters (the port shape lives in one place).
| Concern | Repo | Lives at |
|---|---|---|
OpenAPI builder (buildOpenApiDocument(opts)) |
contract | packages/.../src/openapi.ts |
Zod request schemas (listMembersQuerySchema, consentUpdateRequestSchema) |
contract | src/schemas.ts |
Wire-format TypeScript types (SalesforceMember, MemberListResponse, etc.) |
contract | src/types.ts |
Error code enum, envelope type, STATUS_BY_API_ERROR_CODE |
contract | src/types.ts + src/errors.ts |
| 36 Insight Hub column definitions + helpers | contract | src/columns.ts |
MemberProvider interface + option/result types |
contract | src/member-provider.ts |
| JSON Schema mirror of #209 | contract | schema/salesforce-api.schema.json |
check:schema-sync script (verifies the JSON Schema matches #209) |
contract | scripts/check-schema-sync.ts |
Express app, route registration, asyncHandler |
mock + server | each repo's src/api/ |
| Validation middleware (uses Zod schemas from the contract) | mock + server | each repo's src/api/members.router.ts |
| Logging conventions (pino) | mock + server | each repo's src/logger.ts |
| Mongoose models | mock | src/db/models/ |
MockMemberProvider (Mongo-backed adapter) |
mock | src/providers/mock-member-provider.ts |
| Synthetic data generator | mock | src/ingest/synthetic.ts |
| xlsx upload + download (round-trip) | mock | src/ingest/ |
| Admin SPA | mock | src/admin/client/ |
Multi-tenant scoping (tenantCode on every model) |
mock | mock-only concern |
| Operator-password auth + per-tenant bearer tokens | mock | src/auth/ |
| Salesforce client wiring (jsforce) | server | src/salesforce/connection.ts |
| JWT-bearer connection setup | server | same file (Phase 4 fills buildJwtAssertion) |
SalesforceMemberProvider adapter |
server | src/providers/salesforce-member-provider.ts (Phase 4 fills the bodies) |
SOQL query builders + salesforceToMember / memberToSalesforce mappers |
server | same file (Phase 4) |
| Single-org tenant model + bearer-token auth | server | src/auth/bearer-auth.ts |
The shape that ended up in @ramblers/sf-contract is slightly tighter than the original sketch — groupExists and groupName collapsed into a discriminated-union return type that the router translates into apiError envelopes. It is grounded in the two endpoints both servers serve: GET /api/groups/{groupCode}/members and POST /api/members/{membershipNumber}/consent.
// @ramblers/sf-contract — src/member-provider.ts
import type {
ConsentUpdateRequest,
ConsentUpdateResponse,
MemberListResponse,
} from "./types.js";
export interface ListMembersOptions {
groupCode: string;
since?: Date;
includeExpired?: boolean;
}
export interface ApplyConsentOptions {
tenantCode: string;
membershipNumber: string;
request: ConsentUpdateRequest;
appliedAt: Date;
}
export type ListMembersResult =
| { kind: "ok"; response: MemberListResponse }
| { kind: "groupNotFound" };
export type ApplyConsentResult =
| { kind: "ok"; response: ConsentUpdateResponse }
| { kind: "memberNotFound" };
export interface MemberProvider {
listMembers(options: ListMembersOptions): Promise<ListMembersResult>;
applyConsent(options: ApplyConsentOptions): Promise<ApplyConsentResult>;
}
The mock provider is what members.router.ts used to do inline, lifted behind the interface. The Salesforce provider replaces the Mongo queries with SOQL (Salesforce Object Query Language — Salesforce's SQL-like query language for fetching records, e.g. SELECT Id, FirstName FROM Contact WHERE...) calls and a field mapper:
// ramblers-salesforce-server — src/providers/salesforceMemberProvider.ts
export class SalesforceMemberProvider implements MemberProvider {
constructor(private readonly conn: jsforce.Connection) {}
async listMembers({ groupCode, since, includeExpired }: ListMembersOptions) {
const soql = buildMemberSoql({ groupCode, since, includeExpired });
const records = await this.conn.query<SfMemberRecord>(soql);
return {
groupCode,
groupName: await this.groupName(groupCode),
totalCount: records.totalSize,
members: records.records.map(salesforceToMember),
};
}
async applyConsent(opts: ApplyConsentOptions) {
// composite request: update Contact + insert ConsentEvent__c
}
}
Crucially: salesforceToMember() is the only function whose contents Ramblers Head Office truly need to write from scratch. Everything else is structure, types and plumbing we can hand to them already working.
sequenceDiagram
participant C as Consumer
participant M as Middleware
participant Z as Zod
participant P as Port
participant A as Adapter
participant D as Store
Note over C: NGX or MailMan
Note over A: Mock or Salesforce
Note over D: Mongo or Salesforce
C->>M: GET /api/groups/EKWG/members?since=...
M->>Z: validate query params
Z-->>M: { since, includeExpired }
M->>P: listMembers(opts)
P->>A: delegate
A->>D: .find() / SOQL
D-->>A: raw records
A-->>P: MemberListResponse
P-->>M: response
M-->>C: 200 application/json
The path through a mock deployment and the path through a production deployment are byte-identical from the consumer's perspective. The only difference is what is behind the port.
flowchart LR
P1["Phase 1 ✓<br/>In-repo refactor<br/>introduce port"] --> P2["Phase 2 ✓<br/>Extract contract<br/>into shared package"]
P2 --> P3["Phase 3 ✓<br/>Stand up production<br/>repo + Salesforce stub"]
P3 --> P4["Phase 4<br/>(next — Head Office)<br/>fill in mappers"]
style P1 fill:#9BC8AB,stroke:#5E9170,stroke-width:2px,rx:12,ry:12,color:#FFFFFF
style P2 fill:#9BC8AB,stroke:#5E9170,stroke-width:2px,rx:12,ry:12,color:#FFFFFF
style P3 fill:#9BC8AB,stroke:#5E9170,stroke-width:2px,rx:12,ry:12,color:#FFFFFF
style P4 fill:#FBE3F1,stroke:#C46B9C,stroke-width:2px,rx:12,ry:12,color:#404143
Phase 1 — Introduce the port (done; ramblers-salesforce-mock#5). MemberProvider defined under src/ports/. Mongo logic from members.router.ts lifted into src/providers/mock-member-provider.ts. Routes became provider-agnostic, receiving a MemberProvider from createApp(). Wire format unchanged.
Phase 2 — Extract the contract (done; ramblers-salesforce-mock#7). New repo nbarrett/ramblers-salesforce-contract, tagged v0.2.0. The mock declares it as a dependency at a pinned tag and consumes everything — types, schemas, OpenAPI builder, error envelope, columns, port — from one place. The repo commits its built dist/ so consumers can install from a git tag without running a build step themselves.
Phase 3 — Production server skeleton (done; ramblers-salesforce-contract#1). New repo nbarrett/ramblers-salesforce-server imports @ramblers/sf-contract@v0.2.0. SalesforceMemberProvider is wired through to the routes; every method throws NotImplemented with a structured hint about exactly which file and which function Head Office fills in next. jsforce + JWT-bearer connection plumbing is in place; the /healthz/salesforce smoke endpoint reports 503 not_configured until Head Office supply credentials. Live on Fly at salesforce-server.ngx-ramblers.org.uk.
Phase 4 — Hand-over (next; ramblers-salesforce-server#1). Ramblers Head Office inherit the production-server skeleton, the contract package, and a checklist of five concrete functions to fill: buildJwtAssertion, SalesforceMemberProvider.listMembers, SalesforceMemberProvider.applyConsent, salesforceToMember, memberToSalesforce. NGX-Ramblers remains available for contract questions and will offer paired sessions on the first endpoint.
What we hand them, working:
SalesforceMemberProvider class with every method scaffolded and signed.salesforceToMember(record) mapper with the contract type on one side and a typed unknown SF record on the other./docs, consumer-facing identical to the mock.What only Head Office can do:
Contact? Member__c? person accounts?) and custom field API names.salesforceToMember and memberToSalesforce field mappers.| Question | Resolution |
|---|---|
Multi-tenancy. Mock is multi-tenant by tenantCode. Real Salesforce is one org. |
✓ Resolved. groupCode stays in the request URL path. The production server validates it against ALLOWED_GROUP_CODES from env config; the mock looks it up in Tenant. The port shape is identical for both. |
| Auth model. Mock uses operator passwords + per-tenant bearer tokens. Production needed something different. | ✓ Resolved. Production uses long-lived bearer tokens whose sha256 hashes live in API_TOKEN_HASHES. Not Auth0, not OAuth on the consumer side. The org is the tenant boundary; tokens carry no per-tenant scope. |
| Admin UI scope. Synthetic data, xlsx imports, tenant management only make sense for the mock. | ✓ Resolved. Admin SPA stayed in the mock repo only. The production server is API-only. |
| Wire format integrity. What stops the production server going off-spec once we hand it over? | ✓ Resolved. Both servers depend on @ramblers/sf-contract at a pinned tag. The check:schema-sync script in the contract's CI verifies the JSON Schema in this repo still matches the body of ngx-ramblers#209 — schema changes have to come through the contract repo first. |
| Versioning strategy. Will breaking contract changes happen? | ✓ Resolved. Semver on the contract package. Major version = breaking wire change. Both servers and both consumers pin a tag and update deliberately. v0.2.0 is the current shipping version. |
| Field-mapping size. Is it 36 lines or 300? | Open. The 36 Insight Hub fields are exhaustively documented in src/columns.ts. What's open is Head Office's Salesforce-side inventory: object name (Contact? Member__c? person accounts?), custom field API names, picklist coercion rules. Tracked in the Phase 4 ticket. |
| Sandbox availability for the smoke test. | Open. The production server boots without Salesforce credentials and /healthz/salesforce reports 503 not_configured. As soon as Head Office supply a connected app, SF_CLIENT_ID, SF_USERNAME and a JWT private key, the smoke endpoint goes live without a redeploy. |
| Repo ownership. | Open. All three repos live under nbarrett/ for now. Transfer to a Ramblers Head Office org is part of the Phase 4 handover. |
The original article advised holding at Phase 1 and treating Phases 2 to 4 as conditional on Ramblers Head Office buying in. After the 27 Apr morning meeting that condition was met immediately, so the work continued the same day.
Phase 1 merged in under an hour on 27 Apr. Phase 2 followed straight after: the contract is now its own repo at nbarrett/ramblers-salesforce-contract, versioned v0.2.0, consumed by the mock and the production server. Phase 3 went in on 28 Apr: the production server scaffolded with the full Express + jsforce skeleton, the SalesforceMemberProvider adapter wired through to the routes (every method throwing structured NotImplemented exceptions), CI and Fly deploy live at salesforce-server.ngx-ramblers.org.uk. Total work across all three phases: roughly a working day. Phase 4 is Head Office's seat: fill in five functions, all signed and documented, none of them more than a few dozen lines.
Have a view on any of the above, or want to discuss before the work starts? Contact NGX-Ramblers and we will pick it up.
check:schema-sync script verifies the local schema mirrors this issue body.v0.2.0.SalesforceMemberProvider would use.