27-Apr-2026 — From Mock to Production: a Pluggable Salesforce Member API
Background — what this API is for
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.
Why this matters
- Two consumers (NGX Ramblers, MailMan) are already integrating against the mock contract. Whatever Ramblers Head Office ship in production must speak the same wire format on day one, or those integrations break.
- A mock that proves the contract is the cheapest possible insurance against the production server quietly going out of step with the spec. It stays alive as a regression-test harness even after production exists.
- Head Office's job shrinks dramatically. Instead of designing and building a member API from scratch, they inherit a working server skeleton and a defined wire contract. Their work reduces to: connect to Salesforce, map their fields to ours, and ship.
- NGX and MailMan can run their entire CI against the mock. No Salesforce dev-org access needed, no sandbox quotas, no flakiness — and the contract is guaranteed to match production because both servers consume the same contract package.
Two deployment realities, one wire contract
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
The pattern
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.
Current architecture (28 Apr 2026)
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.
Where the mock started (pre-Phase 1)
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.
Where we wanted to be (the original target — now the implementation)
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).
What lives where
| 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 port — as built
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.
Request flow — both servers, identical until the port
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.
Phasing
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's pre-built versus what Head Office fill in
What we hand them, working:
- A running Express server with the exact wire format NGX and MailMan are already integrating against.
- jsforce/REST connection lifecycle, OAuth/JWT auth flow, retry, rate-limit handling, structured logging.
- The full validation surface (no hand-rolled input checking — Zod from the contract).
- Type-safe inputs and outputs in their adapter — they cannot return the wrong shape without TypeScript yelling at them.
- A
SalesforceMemberProviderclass with every method scaffolded and signed. - An empty
salesforceToMember(record)mapper with the contract type on one side and a typedunknownSF record on the other. - OpenAPI docs at
/docs, consumer-facing identical to the mock.
What only Head Office can do:
- Provide the connected-app credentials and Salesforce org URL.
- Confirm the actual Salesforce object names (
Contact?Member__c? person accounts?) and custom field API names. - Write SOQL queries against their schema.
- Fill in the
salesforceToMemberandmemberToSalesforcefield mappers. - Decide handling for picklists, validation rules, and any denormalisation specific to their org.
- Production deployment, monitoring, alerting (their infra team's call).
Open questions and risks — what's resolved and what remains
| 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. |
Status (as of 29 Apr 2026)
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.
Three questions worth answering at the meeting
- Does anyone at Ramblers Head Office have a Salesforce-side member field inventory yet? Even a rough one. This is the single biggest unknown for hand-over effort.
- Who would own the production server once handed over? Internal Head Office developer, an external consultancy, or unstaffed? This changes how much hand-holding the skeleton needs.
- Is there an existing connected app / OAuth setup we can target a sandbox against? If yes, Phase 3 can include a real round-trip smoke test. If no, we stub harder and the smoke test waits.
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.
References
- Ramblers Salesforce Mock Server — the existing mock this proposal evolves.
- ngx-ramblers#209 — Day-one Salesforce API specification — the contract being mocked. The authoritative version of the five JSON Schema blocks; the contract repo's
check:schema-syncscript verifies the local schema mirrors this issue body. - ngx-ramblers#211 — Phase 2 endpoints — future extension (training detail, area endpoints, accreditation fields).
- nbarrett/ramblers-salesforce-mock — the mock server's repository. Live at salesforce-mock.ngx-ramblers.org.uk.
- nbarrett/ramblers-salesforce-contract — the shared wire-format package. Tagged
v0.2.0. - nbarrett/ramblers-salesforce-server — the production server skeleton. Live at salesforce-server.ngx-ramblers.org.uk.
- Hexagonal architecture (Wikipedia) — Alistair Cockburn's original framing of the ports-and-adapters pattern.
- Salesforce Object Query Language reference — the query language a
SalesforceMemberProviderwould use. - jsforce — the most popular Node.js Salesforce client; the obvious choice for the production adapter.