SharePointVault ~/gotchas/spfx ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ read-only-mode-must-cover-graph-too.md
sharepoint-vault›gotchas›spfx›read-only-mode-must-cover-graph-too.mdhistoryedit on GitHub
● GOTCHAspfx· 4 min read

A read-only mode that wraps SPHttpClient still sends e-mail — Graph is a second channel

applies to: SPFx web parts (SPHttpClient + MSGraphClientFactory)reviewed: 2026-08-31#spfx#graph#permissions#patterns

The cheapest way to make a web part read-only — an admin previewing the app as someone else, a demo mode, a locked licence — is to wrap SPHttpClient once and let it reject everything that isn't a GET. It works, and it's much safer than sprinkling if (readOnly) through twenty handlers. But a typical SPFx app writes through two independent clients: SPHttpClient for SharePoint and MSGraphClientFactory for mail, calendar, Planner and groups. The wrapper never sees the second one, so "read-only" mode happily sends notification e-mail on somebody else's behalf.

Nejlevnější způsob, jak z webpartu udělat režim jen pro čtení (náhled správce očima jiného uživatele, demo, uzamčená licence), je obalit SPHttpClient a odmítnout všechno kromě GET. Funguje to a je to bezpečnější než dvacet if (readOnly) v handlerech. Jenže SPFx appka běžně zapisuje dvěma nezávislými klienty: SPHttpClient do SharePointu a MSGraphClientFactory na poštu, kalendář, Planner a skupiny. Obal ten druhý nikdy neuvidí – a „režim jen pro čtení“ v klidu odešle notifikační e-mail jménem někoho jiného.

Symptom

You add a preview/read-only mode, verify it by trying to save a list item (correctly refused), and ship. Later someone notices e-mail going out from a session that was supposed to write nothing — an approval notification, a reminder, a "task assigned" message. The list stayed untouched, so nothing in SharePoint hints at what happened.

The same hole covers anything routed through Graph: creating a Planner task, adding a calendar event, patching a group.

Cause

The wrapper is a choke point for exactly one transport:

// blocks writes — for SharePoint only
function readOnlySpClient(real) {
  return {
    get:   (u, c, o) => real.get(u, c, o),
    post:  (u, c, o) => ALLOWED_READ_POSTS.test(u) ? real.post(u, c, o) : Promise.reject(new Error(MSG)),
    fetch: (u, c, o) => ((o?.method || 'GET').toUpperCase() === 'GET' || ALLOWED_READ_POSTS.test(u))
      ? real.fetch(u, c, o) : Promise.reject(new Error(MSG))
  };
}

MSGraphClient doesn't go through it. It's built from context.msGraphClientFactory, holds its own token and its own HTTP stack — a service doing client.api('/me/sendMail').post(...) is invisible to the code above.

Fix

Wrap every write-capable client, not just the SharePoint one. For Graph, the muted version should return the value the callers already handle for "sending failed" — but build it by subclassing the real service, not by casting an object literal to its type:

// mail service used by views; in read-only mode it just reports failure
export class MutedMailService extends GraphMailService {
  public async send(): Promise<boolean> { return false; }   // callers already handle false
}

const mail = readOnly ? mutedMail : realMail;

⚠️ The obvious shortcut rots silently. The tempting version is a literal with the one method you need, cast into place:

function readOnlyMail(): GraphMailService {
  const stub = { send: () => Promise.resolve(false) };
  return stub as unknown as GraphMailService;   // ← don't
}

as unknown as T is a double assertion — it switches off every structural check, so the compiler never compares what the stub implements against what T promises. It is correct the day you write it and decays with the next method added to the service. Six months later notifyDecision() exists, views call it, the read-only path throws notifyDecision is not a function, and neither the compiler nor the linter nor the build says a word. Worse, it only fires in the mode nobody routinely clicks through. Subclassing makes the compiler enforce the contract for you; Object.create(real) with a single overridden method is the other safe shape, because everything you didn't override survives on the prototype chain. Keep double assertions for real instances and for data (DTOs, style objects, the shape of a dynamic import()) — never to fake behaviour you haven't written.

Two things worth doing while you're there:

  • Enumerate the outbound channels once, in the shell. Grep the app for msGraphClientFactory, fetch(, axios, navigator.sendBeacon and any SDK client. Each one is a separate hole; the list is short and it doesn't change often.
  • Watch for silent write side effects on read paths. View counters, "last opened" stamps and telemetry rows are writes too — in a preview they leave a trace that says the wrong person opened something.

Verifying it

Don't test the mode by clicking Save — a disabled button proves nothing about the layer underneath. Reach the client the views actually receive (in React, via the fiber on your root node) and issue a write to a harmless URL:

await ctx.spHttpClient.post(webUrl + '/_api/web/DOES_NOT_EXIST', {}, { headers: {} })
// wrapper working  → rejected locally, no request leaves the browser
// wrapper bypassed → HTTP 404 from SharePoint (it reached the server)

The difference between "rejected by the client" and "404 from the server" is what tells you the choke point is real. Repeat for the Graph path with whatever your app's send/create call is, on a target you own.

Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-08-31last updated: 2026-09-24historyedit on GitHubview source