21-Apr-2026 — Ramblers Salesforce Mock Server


Architecture has evolved since this article was written (28 Apr 2026). The mock is now one of three repositories that implement the Salesforce Member API — see From Mock to Production for the current architecture. Specifically: the OpenAPI builder, Zod schemas, types, error envelope, Insight Hub columns and the schema-sync script described below have all moved into @ramblers/sf-contract; the production server has its own home at ramblers-salesforce-server. The narrative below is preserved as a snapshot of how the mock looked when it was first written; for the live state, follow the architecture article.

A standalone reference implementation of the day-one Ramblers Salesforce API contract (ngx-ramblers#209), deployed at salesforce-mock.ngx-ramblers.org.uk and tracked in ramblers-salesforce-mock#1. It is a shared pre-production fixture for the NGX Ramblers platform, Charlie Bigley's MailMan, and the Ramblers Head Office Salesforce team. Each consumer signs in as their own operator, owns their own tenants, and loads their own data, with no visibility into anyone else's.

Why it exists

Ramblers Head Office is building the real Salesforce implementation of #209 on Head Office's own cadence. NGX Ramblers and MailMan need a conformant endpoint to code against now, not once Head Office's build completes. A mock server implementing exactly the #209 contract lets both consumers run their integration loops today, and gives Head Office a reference they can test their Salesforce build against as it lands.

Three design choices follow from that framing:

High-level architecture

The mock sits in front of its own MongoDB Atlas M0 cluster, behind a Cloudflare CNAME pointing at a single Fly.io machine in London. The three consumers all hit the same public endpoint with tenant-scoped bearer tokens.

flowchart LR
    NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
    MAILMAN["MailMan"]
    HeadOffice@{ icon: "ngx:ramblers-hq", label: "Ramblers Head Office", pos: "b", h: 48 }

    subgraph mock["Ramblers Salesforce Mock"]
        CF@{ icon: "ngx:cloudflare", label: "Cloudflare DNS", pos: "b", h: 48 }
        FLY@{ icon: "logos:fly-icon", label: "Fly.io (lhr)", pos: "b", h: 48 }
        EXPRESS@{ icon: "ngx:express", label: "Express + TypeScript", pos: "b", h: 48 }
        CF --> FLY
        FLY --> EXPRESS
    end

    NGX --> CF
    MAILMAN --> CF
    HeadOffice --> CF

    EXPRESS --> ATLAS@{ icon: "ngx:mongodb", label: "MongoDB Atlas M0", pos: "b", h: 48 }

    style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

Tech stack

Intentionally minimal, to keep the mock small enough to maintain as a side project.

flowchart LR
    subgraph runtime["Runtime"]
        NODE@{ icon: "logos:nodejs-icon", label: "Node 20", pos: "b", h: 48 }
        TS@{ icon: "logos:typescript-icon", label: "TypeScript (strict)", pos: "b", h: 48 }
        EXPRESS@{ icon: "ngx:express", label: "Express 4", pos: "b", h: 48 }
    end

    subgraph data["Data"]
        MONGOOSE["Mongoose"]
        ATLAS@{ icon: "ngx:mongodb", label: "Atlas M0", pos: "b", h: 48 }
    end

    subgraph docs["Docs & contract"]
        SWAGGER["swagger-ui-express"]
        ZOD["zod (runtime validation)"]
        SCHEMA["JSON Schema + drift check"]
    end

    subgraph build["Build"]
        ESBUILD["esbuild (server + client bundles)"]
        DOCKER@{ icon: "logos:docker-icon", label: "Docker", pos: "b", h: 48 }
    end

    runtime --> data
    runtime --> docs
    build --> runtime

    style runtime fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style data fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style docs fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style build fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

The codebase is 100% TypeScript source on Node 20, Express 4 for the HTTP layer, Mongoose against MongoDB Atlas, swagger-ui-express for the interactive docs, and zod for runtime validation at the edges. esbuild bundles the server into dist/server.js and the tiny admin-UI client into public/admin.js. The admin UI's HTML uses vanilla DOM with no framework; its CSS is hand-written against the NGX Ramblers colour palette.

API contract and drift check

Five JSON Schema blocks live inline in #209: SalesforceMember, MemberListResponse, ConsentUpdateRequest, ConsentUpdateResponse, ApiErrorResponse. The mock's schema/salesforce-api.schema.json holds normalised copies of all five under $defs.

On every push, scripts/check-schema-drift.ts fetches the live #209 body via the GitHub API, extracts each fenced json block, normalises #SalesforceMember-style references to standard #/$defs/SalesforceMember, and canonicalises both sides. Any divergence exits non-zero and fails CI, blocking deploy until either the mock catches up to #209 or #209 is corrected.

The same schema file feeds /api/openapi.json (served as an OpenAPI 3.0 document) and the Swagger UI at /docs. The Swagger info section deliberately flags the mock as a shared fixture so consumers are not misled into thinking they are talking to Salesforce.

The day-one endpoints implemented:

Method Path Purpose
GET /api/groups/{groupCode}/members Full member list, since for incremental sync, includeExpired for renewal reminders
POST /api/members/{membershipNumber}/consent Consent writeback; accepts emailMarketingConsent, groupMarketingConsent, areaMarketingConsent, otherMarketingConsent with source and timestamp

All non-200 responses use the ApiErrorResponse envelope from #209. Auth failures, unknown tenants, missing members and bad requests all come through the same shape, so consumers can write one error handler and be done.

Tenancy model

The tenant discriminator is a groupCode (4 chars) or areaCode (2+ chars). Every member, token and consent event carries a tenantCode field, and every query filters on it. Cross-tenant reads are not possible because the compound indexes on (tenantCode, salesforceId) and (tenantCode, membershipNumber) will never return another tenant's rows.

Each operator account owns one or more tenants. The root operator can see everything for audit, but operators only see tenants they own, tokens under those tenants, and the members ingested under them. That means MailMan's NS03 data is invisible to NGX's operator login, and vice versa, even though both log into the same UI and call the same /admin/api/tenants endpoint.

erDiagram
    Operator ||--o{ Tenant : owns
    Tenant ||--o{ ApiToken : scopes
    Tenant ||--o{ Member : contains
    Tenant ||--o{ ConsentEvent : records

    Operator {
        string username PK
        string passwordHash
        bool isRoot
        string label
    }
    Tenant {
        string code PK
        enum kind
        string ownerOperator FK
        string name
        date lastIngestAt
    }
    ApiToken {
        string tokenHash PK
        string tokenPrefix
        string tenantCode FK
        string label
        date revokedAt
    }
    Member {
        string tenantCode FK
        string salesforceId
        string membershipNumber
        bool emailMarketingConsent
        bool removed
    }
    ConsentEvent {
        string tenantCode FK
        string membershipNumber
        enum source
        date submittedAt
    }

API tokens are generated in the admin UI, shown once to the operator, and persisted only as a SHA-256 hash plus a 16-character non-secret prefix. The plaintext has the form rsm_<tenantCode>_<48-hex-random>, so an operator skimming a log can tell at a glance which tenant issued a token without any lookup.

Request flow

A typical GET /api/groups/KT50/members with an NGX-scoped token passes through four gates before it touches Mongoose.

sequenceDiagram
    participant Consumer as NGX consumer
    participant Auth as bearerAuth
    participant Scope as requireTenantMatch
    participant Handler as members router
    participant Mongo as MongoDB Atlas
    participant Mapper as toSalesforceMember

    Consumer->>Auth: GET /api/groups/KT50/members<br/>Authorization: Bearer rsm_KT50_...
    Auth->>Mongo: ApiToken.findOne({tokenHash})
    Mongo-->>Auth: TokenDoc (scope: KT50)
    Auth->>Scope: pass token + request
    Scope-->>Auth: path KT50 matches scope KT50
    Auth->>Handler: proceed
    Handler->>Mongo: Member.find({tenantCode: "KT50"})
    Mongo-->>Handler: MemberDocs[]
    Handler->>Mapper: toSalesforceMember(doc)<br/>for each
    Mapper-->>Handler: wire-shape members
    Handler-->>Consumer: 200 MemberListResponse

lastUsedAt on the token is updated asynchronously after the request is already in flight, so auth does not add a second round-trip to the critical path.

Data ingest

Two ways to load a tenant with members, both fed by the same parser and upsert path.

flowchart LR
    OP@{ icon: "ngx:user", label: "Operator", pos: "b", h: 48 }

    subgraph admin["Admin UI"]
        UPLOAD["POST /admin/api/tenants/:code/upload<br/>(multipart ExportAll.xlsx)"]
        GEN["POST /admin/api/tenants/:code/generate<br/>(count, seed?, downloadOnly?)"]
    end

    PARSER["parseExportAll<br/>(36-column header match)"]
    SYNTH["generateSyntheticMembers<br/>(mulberry32 PRNG)"]
    WRITE["writeExportAll<br/>(xlsx buffer)"]

    UPSERT["upsertMembers<br/>(tenant-scoped)"]
    MONGO@{ icon: "ngx:mongodb", label: "members<br/>consentEvents", pos: "b", h: 48 }

    OP --> UPLOAD
    OP --> GEN

    UPLOAD --> PARSER
    GEN --> SYNTH
    SYNTH --> WRITE
    WRITE --> PARSER

    PARSER --> UPSERT
    UPSERT --> MONGO

    style admin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

The parser uses ExcelJS and matches Insight Hub header cells case-insensitively with whitespace collapsed, so minor variations in the export do not trip it up. It handles the 36 columns from the #209 field table, synthesises salesforceId as 003MOCK_<membershipNumber> (Insight Hub has no such identifier), and derives groupMemberships[] with primary: true for the top-level groupCode plus a second primary: false entry for the Affiliate Member Primary Group column where present.

The synthetic generator is a deterministic mulberry32 PRNG: given the same seed and count it always produces the same rows, so test scenarios are reproducible. It caps at 50,000 rows to bound memory on the shared-cpu Fly machine.

Ingest is idempotent. Re-uploading the same spreadsheet replaces the tenant's current dataset, soft-removing any existing members whose salesforceId is not in the new batch. The removed=true rows stay visible to incremental-sync consumers using the since query parameter, so a consumer that missed the deletion can still catch up on the removal without needing to diff two full snapshots.

Admin UI

A single-page HTML shell at /admin/login and /admin/dashboard, with the official Ramblers horizontal logo in the header and the NGX Ramblers admin palette (granite, mintcake, sunrise). The client bundle is 8.6 KB of vanilla DOM TypeScript, compiled by esbuild. No framework.

Two login paths: a username and password for established operators, and a one-shot bootstrap-token path for first-run setup (seeded from the BOOTSTRAP_TOKEN environment variable). Once signed in, an operator sees only their own tenants in the sidebar; clicking a tenant opens a detail pane with three panels:

The root operator additionally has API access to create further operator accounts, so new consumers (a new Ramblers Head Office developer, for example) can be brought on without sharing a login.

Deployment pipeline

Trunk-based, push-to-main, auto-deploy. GitHub Actions orchestrates CI and deploy; the ci.yml and deploy.yml workflows live in the repo. Secrets are pulled fresh from the existing NGX infrastructure-secrets store at deploy time and injected via fly secrets set; they never land in .env, shell profile, or the repository.

flowchart LR
    PUSH@{ icon: "logos:github-icon", label: "git push main", pos: "b", h: 48 }

    subgraph ci["GitHub Actions"]
        TYPECHECK["tsc --noEmit"]
        LINT["eslint"]
        BUILD["esbuild bundles"]
        TEST["vitest"]
        DRIFT["schema drift check<br/>(fetches #209)"]
    end

    subgraph deploy["Deploy"]
        SECRETS["fly secrets set<br/>(inline mongosh substitution)"]
        FLYDEPLOY@{ icon: "logos:fly-icon", label: "flyctl deploy --remote-only", pos: "b", h: 48 }
        MACHINE["Fly machine (lhr)<br/>shared-cpu-1x / 512 MB"]
    end

    ATLAS@{ icon: "ngx:mongodb", label: "MongoDB Atlas", pos: "b", h: 48 }

    PUSH --> ci
    ci --> deploy
    deploy --> MACHINE
    MACHINE --> ATLAS

    style ci fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style deploy fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

The deploy job is gated on a FLY_DEPLOY_ENABLED repository variable, so early commits that predate the Fly app do not turn CI red. Once the variable is set, every push to main builds the Docker image, runs the drift check against the live #209 body, and redeploys the Fly machine in place.

Custom hostname handling: the Cloudflare CNAME salesforce-mock.ngx-ramblers.org.uk points at <fly-app>.fly.dev; Fly auto-issues a Let's Encrypt certificate once DNS resolves. Atlas IP access is open (the connection string is already credential-gated, and Fly shared-cpu machines do not have a stable egress IP to pin).

Future work

Any of the above sound useful sooner rather than later? Contact NGX-Ramblers with a nudge and I will bump it up the queue.

References

See also