Word Add-in — Best-Practice Audit
Audit pass against the 28-section annotated source list compiled the same day. 5 fixes applied, 2 of them caught only by Microsoft's official manifest validator.0 errors, 0 warnings), the production end-to-end smoke is green, and two long-standing manifest bugs that I had explicitly defended earlier in the project have been fixed.
Method
For every gotcha listed in the best-practice sources doc, check the current Hugo Word add-in implementation. Mark each finding as one of:
- ✓ ALIGNED — already follows the canonical pattern
- ⚠️ NEEDS FIX — actionable defect, real or potential
- 🛑 NOT FIXING — known wart, leaving as-is with rationale
- N/A — does not apply to our architecture
Audit covered the manifest, all src/ TypeScript and HTML, the webpack and tsconfig files, the wrangler asset hosting, and the CF Access bypass app.
✓ Aligned (already canonical)
| # | Finding | Where |
|---|---|---|
| 1 | Webpack 5 + ts-loader + html-webpack-plugin (Microsoft's reference bundler) | webpack.config.js |
| 2 | office-addin-dev-certs for local HTTPS (not the dead office-toolbox) | webpack.config.js:10 |
| 3 | @types/office-js in devDependencies | package.json |
| 4 | Office.js loaded synchronously from the official CDN | taskpane.html:7 |
| 5 | Office.onReady bootstraps the UI; gracefully handles non-Word host | taskpane.ts:109 |
| 6 | Single Word.run with one sync() — no N+1, no untracked retained references, no loops with sync | taskpane.ts:589-602 |
| 7 | Word.InsertLocation.replace always — avoids the after-on-collapsed quirk that throws GeneralException | taskpane.ts:595 |
| 8 | SourceLocation returns a literal HTTPS 200, not a 307 (Pretty URLs disabled) | wrangler.toml html_handling = "none" |
| 9 | CF Access path-scoped bypass app on /addin/* so Word fetches the manifest + bundle without an Access challenge | Access app f7369463-… |
| 10 | Same-origin credentials: 'same-origin' so the user's CF Access JWT cookie carries into /api/* | api.ts:55-71 |
| 11 | Cross-origin (dev server) credentials: 'omit' so Hugo's origin: '*' CORS isn't tripped | api.ts:67 |
| 12 | TypeScript strict: true, noEmit: true (webpack handles emit) | tsconfig.json |
| 13 | <Permissions>ReadWriteDocument</Permissions> — appropriate for insertion | manifest.xml:40 |
| 14 | Single fresh manifest GUID, not borrowed from Gustav | manifest.xml:15 |
| 15 | Webpack dev server on HTTPS port 3000, Access-Control-Allow-Origin: * for browser smoke | webpack.config.js:59-67 |
| 16 | No imports of AI / Vectorize / Anthropic / AI Gateway anywhere in src/ — structural cost-safety guarantee | grep src/ |
| 17 | Stale-response guard via fetchToken increment-before-await + check-after-await | taskpane.ts:454-480 |
| 18 | Mac perf — single insertText round-trip; nothing to batch | taskpane.ts:589 |
⚠️ Needed fix → applied
1. Manifest element ordering — SupportUrl was before IconUrl
<Description … />
<SupportUrl … />
<IconUrl … />
<HighResolutionIconUrl … /><Description … />
<IconUrl … />
<HighResolutionIconUrl … />
<SupportUrl … />The OfficeApp XSD requires the canonical order. Office's runtime parser is permissive enough to accept both, but office-addin-manifest validate and AppSource's stricter validators do not.
2. Requirements WordApi MinVersion lowered from 1.3 → 1.1
The only Word JS APIs we actually use are Word.run, selection.insertText, and Word.InsertLocation.replace — all in WordApi 1.1. Setting MinVersion to 1.3 unnecessarily excluded older Word installs.
After the fix, the validator confirms the add-in will run on 8 Word platforms: Word on iPad, Mac (M365), Mac 2016+, Mac 2019+, Word on the web, Windows (M365), Windows 2016+, Windows 2019+.
3. Removed two stale <FunctionFile> elements
<FunctionFile> is meant to point at an HTML page that hosts JS functions invoked via <Action xsi:type="ExecuteFunction">. We don't use ExecuteFunction — our only ribbon button is <Action xsi:type="ShowTaskpane">. The two elements (one in V1_0, one in V1_1) were harmless dead code copied from Gustav. Removed.
4. Manifest <Version> bumped from 0.1.0 → 1.0.0.0
The validator rejected 0.1.0 with "Manifest Version Too Low: The manifest has unsupported version number less than 1.0." Bumped to 4-part 1.0.0.0 (the AppSource-conformant format). Also reflects reality — the add-in is past PoC (filters, chat dispatcher, docs).
5. VersionOverrides V1_1 namespace — mailappversionoverrides/1.1 → taskpaneappversionoverrides/1.1
The element 'VersionOverrides' in namespace 'taskpaneappversionoverrides'
has invalid child element 'VersionOverrides' in namespace
'mailappversionoverrides/1.1'. List of possible elements expected: …
in namespace 'taskpaneappversionoverrides/1.1'.
The mail namespace is for Outlook mail apps. For a Word task pane, the V1_1 nested block must use the task pane namespace. Office's runtime parser is permissive enough to load the wrong namespace silently, which is why Gustav has shipped fine in production — but office-addin-manifest validate and AppSource's stricter validators reject it. Fixed in Hugo; should be backported to Gustav.
⚠️ Tooling additions
6. Added office-addin-manifest + npm run validate script
Microsoft's canonical manifest validator. Now wired into the project so any future manifest edit gets a one-command sanity check before deploy. This is what caught fixes #4 and #5.
cd office-addins/word
npm run validate # → "The manifest is valid."
7. Added eslint-plugin-office-addins (the official Office Add-in linter)
Catches Word.run / context.sync mistakes, missing load() property pruning, unused tracked objects. Wired up via ESLint v9 flat config (eslint.config.mjs) alongside typescript-eslint.
npm run lint # → 0 errors, 0 warnings
The lint pass on the existing code returned clean on first run — confirms the Word.run pattern is canonical. (One trivial unused eslint-disable-next-line directive was removed.)
🛑 Not fixing
| # | Finding | Why |
|---|---|---|
| 1 | No native Office.context.requirements.isSetSupported() feature detect — we use a simple typeof Word !== 'undefined' guard | Our only Word JS API is in the universally-available 1.1 set; per-API feature detection would be theatre. |
| 2 | No CSP <meta> tag on taskpane.html | The best-practice doc explicitly notes "There is no canonical 'CSP for taskpanes' page on Learn." Adding one without a documented spec to align against would be guesswork. |
| 3 | No eslint-config-office-addins (only the plugin) | The plugin's recommended config is sufficient; the separate config package adds opinions we don't need. |
| 4 | office-js script tag is synchronous (blocks render) | Microsoft's templates do this. Async load races with Office.onReady. |
| 5 | No NAA / SSO wiring | We don't authenticate inside the add-in — the user's CF Access cookie carries through, and /api/* is gated by CF Access at the perimeter. NAA would be redundant. |
N/A
- Yo Office vs Microsoft 365 Agents Toolkit scaffold — we hand-built from Gustav's manifest pattern; no scaffold is in play.
- Centralized Deployment 24h propagation SLA — we sideload, not Centrally Deploy.
- AppSource validation 3-5 day review — we don't ship via AppSource.
- Intune mass deployment — we sideload directly into
wef/. - Mac per-call API overhead — our
Word.runis oneinsertText+ onesync. Nothing to batch. - Memory leak from untracked ContentControl/Range refs — we never retain proxy objects across
synccalls. - OOXML insertion fidelity bugs — we use plain text
insertText, notinsertOoxml. - Shared runtime lifecycle quirks — we declare
<Runtime lifetime="long">but don't store any state on the runtime — every fetch goes through the API. Lifecycle resets are invisible to the user.
Validator output (after fixes)
Acceptance Test Completed: Acceptance test service has finished checking provided add-in.
Based on the requirements specified in your manifest, your add-in can run on
the following platforms; your add-in will be tested on these platforms when
you submit it to the Office Store:
- Word on iPad
- Word on Mac (Microsoft 365)
- Word 2016 or later on Mac
- Word 2019 or later on Mac
- Word on the web
- Word 2016 or later on Windows
- Word 2019 or later on Windows
- Word on Windows (Microsoft 365)
The manifest is valid.
Lint output
> @hugo/word-addin@0.2.0 lint
> eslint src --ext .ts
(0 errors, 0 warnings)
End-to-end smoke (against prod)
manifest.xml 200
taskpane.html 200
taskpane.bundle.js 200
/api/clauses/search {"count":157}
What I got wrong before this audit
Two things I had explicitly defended earlier in the project turned out to be real schema violations that the official validator catches:
- The V1_1 namespace (
mailappversionoverridesfor aTaskPaneApp). - Element ordering (
SupportUrlbeforeIconUrl).
In both cases I had cited "Gustav has the same and ships in production" — true but irrelevant. Office's runtime parser is permissive; the schema validator is strict.
Lesson: when a reviewer flags a manifest issue, run office-addin-manifest validate before dismissing.
The third thing I had no excuse for: the canonical Microsoft validator wasn't even wired into the project until this audit. That's now fixed (npm run validate).
Recommended follow-ups
- Backport the V1_1 namespace + element ordering fixes to Gustav's
office-addins/word/manifest.xml. Same fix, same root cause, same one-shot validator confirmation. - Add
npm run validateto a CI gate so future manifest edits can't ship without the validator passing. Trivial GitHub Action:cd office-addins/word && npm install && npm run validate && npm run lint && npm run build. - Consider unified-manifest migration when the M365 unified manifest goes GA for Word (currently preview as of Apr 2026). The conversion tool is
office-addin-manifest-converter.
Related
- Word Add-in — implementation reference
- Word Add-in — Sources — the 28-section best-practice doc this audit checked against
Source markdown: office-addins/word/docs/audit-2026-04-13.md.