Skip to content

fix(mp): type CommunicationType as MP's real enum — 'Text' and 'Letter' 500 - #1

Closed
jonnydcakes wants to merge 1 commit into
mainfrom
fix/communication-type-enum
Closed

jonnydcakes wants to merge 1 commit into
mainfrom
fix/communication-type-enum

Conversation

@jonnydcakes

@jonnydcakes jonnydcakes commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Problem

src/lib/providers/ministry-platform/types/provider.types.ts:41

CommunicationType: 'Email' | 'Text' | 'Letter';

MP's Platform.Messaging.CommunicationType is Unknown | Email | SMS | RssFeed | GlobalMFA.
'Text' and 'Letter' were never members, and MP rejects either with an opaque HTTP 500,
not a 400:

{"Message":"Error converting value \"Text\" to type 'Platform.Messaging.CommunicationType'.
  Path 'CommunicationType' ... Requested value 'Text' was not found."}

So the only value the type offered for texting is one that always fails, and the failure reads
as a server fault rather than a bad payload. 'Email' happens to be a real member — and it is
the only value anyone has ever passed, which is why a two-thirds-wrong union sat in the type
layer unnoticed.

Also: SMS requires TextPhoneNumberId

Once CommunicationType: 'SMS' is used, MP additionally requires it:

HTTP 500
{"Message":"Provided communication object is invalid.
  1. (Error) Property 'TextPhoneNumberId' is required and must be populated."}

TextPhoneNumberId is the outbound number's dp_SMS_Numbers.SMS_Number_ID. The field already
exists on CommunicationInfo but is optional — it is effectively required when
CommunicationType === 'SMS'
. MP's own Swagger marks it optional, so the Swagger cannot be
trusted here.

Verified against a live tenant

Not taken from the Swagger alone — re-checked on a production MP instance on 2026-09-08:

Check Result
dp_Communication_Types 1 Email, 2 SMS Text, 3 RSS Feed, 4 GlobalMFA (Unknown is the zero value, no row)
dp_SMS_Numbers one active default number

Originally found on 2026-06-11 building a passwordless sign-in spike that reuses this
/communications pattern: the email delivered; the SMS succeeded only after switching 'Text'
→ 'SMS' and adding TextPhoneNumberId.

Impact

Latent, not a live outage. Nothing in the template calls createCommunication — the
references are the plumbing (helper.ts, provider.ts, communication.service.ts) and the
tests, which use 'Email'. This is a wrong type surface waiting for the first person who tries
to send a text, who gets a 500 with no indication their payload was at fault. Every downstream
fork of the template inherits it.

The change

  1. COMMUNICATION_TYPES as a const [...] as const with CommunicationType derived from
    it, so the enum has one source of truth and a comment naming what verified it.
  2. CommunicationInfo becomes a discriminated union, so the compiler requires
    TextPhoneNumberId exactly when the type is 'SMS'. A runtime 500 becomes a build error.
  3. Runtime guard in CommunicationService.createCommunication, placed above
    ensureValidToken() so a doomed payload costs neither a token refresh nor a round trip. It
    catches callers arriving through an as cast or untyped JSON, and names the accepted values
    so nobody needs the Swagger to fix their call.

Plus a paragraph in the provider docs/README.md Communications section.

provider.types.ts is hand-maintained (generate-types.ts only writes into models/), so this
will not be undone by npm run mp:generate:models.

Tests

Eight cases added to the existing communication.service.test.ts (21 in that file now), plus a
compile-time block.

Each rejection test asserts that nothing was sent — no post, no postFormData, and no
ensureValidToken — not merely that an error came back. Asserting on the message alone would
still pass if the guard were later moved below the POST, which is the failure mode the guard
exists to prevent.

All four mutations were run and each fails the suite it should:

Mutation Result
Delete the assertSendable(...) call 6 tests fail
Move the guard below ensureValidToken() 5 tests fail
Put 'Text' back in COMMUNICATION_TYPES tsc TS2578 — unused @ts-expect-error
Make TextPhoneNumberId optional for 'SMS' tsc TS2578 — unused @ts-expect-error

Verification on this branch: npx vitest run --coverage → 59 files / 1023 tests pass, coverage
gates green (99.74% statements); npx tsc --noEmit clean; npx eslint . clean;
node scripts/check-lockfile.mjs reports no drift (no dependency changes).

…r' 500

`CommunicationInfo.CommunicationType` was typed `'Email' | 'Text' | 'Letter'`.
Ministry Platform's `Platform.Messaging.CommunicationType` is
`Unknown | Email | SMS | RssFeed | GlobalMFA`, so two of the three values the
type offered were never members, and MP rejects either with an opaque HTTP 500
rather than a 400:

    {"Message":"Error converting value \"Text\" to type
      'Platform.Messaging.CommunicationType'. Path 'CommunicationType' ...
      Requested value 'Text' was not found."}

The only value the type offered for texting was one that always fails, and the
failure reads as a server fault rather than a bad payload. `'Email'` happens to
be a real member and is the only value anyone had passed, which is why the
other two went unnoticed.

Once `'SMS'` is used, MP additionally requires `TextPhoneNumberId`
(`dp_SMS_Numbers.SMS_Number_ID` of the outbound number) and answers its absence
with a second 500 — `Property 'TextPhoneNumberId' is required and must be
populated`. MP's own Swagger marks the field optional, so the Swagger cannot be
trusted here.

Three changes:

- `COMMUNICATION_TYPES` as a `const [...] as const` with `CommunicationType`
  derived from it, so the enum has one source of truth.
- `CommunicationInfo` becomes a discriminated union, so the compiler requires
  `TextPhoneNumberId` exactly when the type is `'SMS'`. A runtime 500 becomes a
  build error.
- A runtime guard in `CommunicationService.createCommunication`, placed above
  `ensureValidToken()` so a doomed payload costs neither a token refresh nor a
  round trip. It catches callers arriving through an `as` cast or untyped JSON,
  and names the accepted values so nobody needs the Swagger to fix their call.

Verified against a live MP tenant on 2026-09-08 rather than from the Swagger
alone: `dp_Communication_Types` holds `1 Email`, `2 SMS Text`, `3 RSS Feed`,
`4 GlobalMFA` (`Unknown` is the zero value and has no row), and an email plus an
SMS were both delivered end to end — the SMS only after switching `'Text'` to
`'SMS'` and supplying `TextPhoneNumberId`.

Tests: eight cases added to `communication.service.test.ts`. Each rejection test
asserts that *nothing was sent* — not merely that an error came back — since
asserting on the message alone would still pass if the guard were moved below
the POST. The type half is covered by `@ts-expect-error` cases that fail
`tsc --noEmit`, which `next build` also performs.
@jonnydcakes
jonnydcakes force-pushed the fix/communication-type-enum branch from 9956589 to 66b157e Compare September 19, 2026 15:17
@jonnydcakes

Copy link
Copy Markdown
Owner Author

Superseded by the upstream PR: MinistryPlatform-Community#92. Branch kept — that PR is built from it.

chriskehayias added a commit that referenced this pull request Sep 29, 2026
…okies, sign-out

Close auth-test-gaps #1-MinistryPlatform-Community#5 and MinistryPlatform-Community#8 (security review 2026-09-28, wave 2).

- src/test-utils/mock-oidc.ts: shared mock MP OIDC server (discovery with
  issuer + jwks_uri, local RS256 JWKS, token, userinfo); fetch throws on
  any URL it does not serve. Cookie jar and code-flow helpers.
- auth.origin-check: the real instance refuses https://evil.example,
  "/\t/evil", "//evil.example" and "/\evil.example" callbackURLs (403) and
  a cross-site Origin on a cookie-bearing POST; negative controls show
  disableOriginCheck: true and trustedOrigins ["*"] would let them through.
- auth.code-flow: GET /callback/ministry-platform through auth.handler:
  missing, mismatched, cookie-less, tampered and expired state refused
  before the code is redeemed; code exchange without a PKCE verifier;
  id_token refused when signed by a foreign key, wrong iss/aud or expired;
  sub binding; session minted. Sign-out expires both cookies, and a replayed
  session_token is dead at once (the full pair once the 1 h cache lapses).
  pkce: false and disableIdTokenNonceBinding: true pinned in config and
  behaviourally, with negative controls.
- auth.session-config: cookie names, prefix, HttpOnly/SameSite=Lax/Path/
  Max-Age, __Secure- + Secure over https, no account_data cookie; the
  decrypted session_data holds no customSession output; expiresAt = +12 h.
- auth.rate-limit: AUTH_IP_ADDRESS_HEADERS / AUTH_TRUSTED_PROXIES through
  the real options into the sign-in limiter, with a shared-bucket control.
- auth.test, auth.ip-address, auth.user-id-cache: install the mock OIDC
  server so the instance under test is built on the verified id_token path
  instead of a failed discovery fetch.

Mutation-checked against src/lib/auth.ts (reverted): disableOriginCheck
true/removed, trustedOrigins ["*"], strategy jwt/compact, expiresIn
raised, refreshCache removed, sub binding removed, nonce binding removed,
pkce removed and storeAccountCookie on all turn the suite red.

Secret/baseURL validation tests are tracked with
security-auth-url-env-not-validated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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