diff --git a/.claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md b/.claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md
deleted file mode 100644
index 433c63e..0000000
--- a/.claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-title: "Step 8 — Fork-specific low-risk items: address-label caps, error boundaries, tool-params decode, codegen escaping"
-severity: low
-tags: [security, bug]
-area: components
-files: [src/components/address-labels/actions.ts, src/services/addressLabelService.ts, src/app/global-error.tsx, src/lib/tool-params.server.ts, src/lib/providers/ministry-platform/scripts/generate-types.ts, src/lib/providers/ministry-platform/db/]
-discovered: 2026-09-30
-discovered_by: playbook-audit (port-security-review-2026-09-28.md, fork sweep)
-status: open
----
-
-## Problem
-- Address labels: `mergeTemplate`/`generateLabel*` merge client-sent labels (not re-fetched) with no count cap;
- the 5 MB `.docx` template is PizZip-decompressed with no size limit (zip bomb); the 5 MB check is dead because
- Next's default server-action body limit is 1 MB.
-- `global-error.tsx` is retry-button only — no plain ``, button not held until hydration.
-- `tool-params.server.ts` `decodeURIComponent(recordDescription)` throws on a stray `%` and crashes the page.
-- `generate-types.ts:361,414-421` interpolates MP metadata raw into generated source.
-- `api_dev_GetProcedureDefinition` lacks `@DomainID INT` as first parameter (repo rule for `api_*` procs).
-- `UserService.getUserProfile` returns `User_ID`, `Mobile_Phone`, roles and groups to the client — trim to what the UI uses.
-
-## Proposed fix
-Count cap + re-fetch or validate labels server-side; cap uncompressed size / entry count before PizZip; align
-`serverActions.bodySizeLimit` with the documented limit; `global-error` fallback link; safe decode; escape codegen
-strings; add `@DomainID`; minimise profile DTO.
-
-## Impact if not fixed
-Memory exhaustion from a crafted template; a malformed URL crashes a tool page; minor data over-exposure.
diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md
index 3bef953..a4e22a0 100644
--- a/.claude/TODO/INDEX.md
+++ b/.claude/TODO/INDEX.md
@@ -18,10 +18,10 @@ Severity tiers:
- **medium**: doc drift, missing test, refactor with real cost
- **low**: nits, minor doc fixes, stylistic improvements
-Total: **3 open TODOs**.
+Total: **2 open TODOs**.
-> **`step6`** and **`step8`** are the remaining steps from the 2026-09-30 MPNext
-> playbook audit (one PR each). `held-*` items wait on an owner decision.
+> **`step6`** is the remaining step from the 2026-09-30 MPNext playbook audit.
+> `held-*` items wait on an owner decision.
---
@@ -39,22 +39,19 @@ _none open_
| routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) |
| routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) |
-### Low (1)
-| Area | Tags | Title | File |
-|---|---|---|---|
-| components | security, bug | Step 8 — Address-label caps, error boundaries, tool-params decode, codegen escaping | [→](2026-09-30-step8-fork-specific-low-risk-items.md) |
+### Low (0)
+_none open_
---
## By tag
-### security (3)
+### security (2)
- step6-headers-csp-build-guards — medium
- held-csp-customer-deployments — medium
-- step8-fork-specific-low-risk-items — low
-### bug (1)
-- step8-fork-specific-low-risk-items — low
+### bug (0)
+_none open_
### drift (1)
- step6-headers-csp-build-guards — medium
@@ -77,7 +74,7 @@ _none open_
| Area | Count |
|---|---|
-| components | 1 |
+| components | 0 |
| routing | 2 |
| services | 0 |
| auth | 0 |
diff --git a/.claude/references/DECISIONS.md b/.claude/references/DECISIONS.md
index 554d99d..9100dc8 100644
--- a/.claude/references/DECISIONS.md
+++ b/.claude/references/DECISIONS.md
@@ -1,7 +1,7 @@
---
title: Architectural Decisions
type: adr
-last_updated: 2026-04-17
+last_updated: 2026-10-10
---
# Architectural Decisions
@@ -84,6 +84,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4
**Context:** A tempting design is to enrich the session object inside `customSession` with the user's full MP profile (roles, user groups, Contact_ID, Image_GUID). Every consumer would then read a single object. The downside is that `customSession` runs on every cache miss, and MP profile lookups require extra MP API calls (`dp_Users` + `dp_User_Roles` + `dp_User_User_Groups`).
**Decision:** `customSession` in `src/lib/auth.ts:398-413` does only `firstName` / `lastName` splitting from `user.name` — no API calls. MP profile loading moves to the client, behind `UserProvider` (`src/contexts/user-context.tsx`), which calls the parameterless `getCurrentUserProfile()` server action on mount (the action re-derives the GUID from the session — a GUID parameter would be an IDOR) and exposes `useUser()`.
**Consequences:** `getSession()` stays cheap. Sign-in does not break when MP is down. Client components that need roles/groups must mount under `UserProvider`; every page load incurs one extra round-trip. `UserService.getUserProfile()` issues three queries (profile + roles + groups).
+**Note (2026-10-10):** `MPUserProfile` is now display-only (`First_Name`, `Nickname`, `Last_Name`, `Email_Address`) and `getUserProfile()` issues **one** `dp_Users` query; roles and user groups are no longer sent to the client. Security-role checks use `AuthorizationService` server-side. See `services/user-service.md`.
**Alternatives considered:**
- **Enrich in `customSession`** — would hit MP API on every JWT refresh and couple sign-in availability to MP uptime.
- **Load profile server-side in layout** — blocks TTFB on every page render.
diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md
index 2994f15..093a7ba 100644
--- a/.claude/references/GLOSSARY.md
+++ b/.claude/references/GLOSSARY.md
@@ -1,7 +1,7 @@
---
title: Glossary
type: glossary
-last_updated: 2026-04-17
+last_updated: 2026-10-10
---
# Glossary
@@ -238,7 +238,7 @@ Public-API facade class for Ministry Platform operations; normalizes friendly pa
## MPUserProfile
-Enriched client-side user profile combining `dp_Users` row + joined Contact fields + `roles[]` + `userGroups[]`; loaded via `UserService.getUserProfile(userGuid)` and cached in `UserContext`.
+Display-only client-side user profile: `First_Name`, `Nickname`, `Last_Name`, `Email_Address` (joined from the `dp_Users` row's Contact) — no IDs, phone, image, roles, or user groups; loaded via `getCurrentUserProfile()` → `UserService.getUserProfile(userGuid)` (single query) and cached in `UserContext`.
**Not to be confused with:** Better Auth `session.user` (name/email/id only).
**Defined in:** `src/lib/providers/ministry-platform/types/user-profile.types.ts:1`
diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md
index cd97456..b3e8b81 100644
--- a/.claude/references/GOTCHAS.md
+++ b/.claude/references/GOTCHAS.md
@@ -422,7 +422,7 @@ Retired 2026-10-10: applied only to the template editor's merge tokens; the edit
**Symptom:** User configures IMb with a Mailer ID, prints labels, and sees POSTNET (or no barcode) on the output.
**Root cause:** `preEncodeBarcodes` wraps `imbEncode` in a bare `try {} catch {}` with no logging, falling through to POSTNET. Also, if `config.mailerId` is empty (`''`), the `config.barcodeFormat === 'imb' && config.mailerId` guard skips IMb entirely and falls through silently.
**Fix:** (a) Users: enter a valid 6- or 9-digit USPS Mailer ID. (b) Code: fixed — the fallback is logged (`console.warn`) and the Mailer ID is validated.
-**Enforced where:** `src/lib/barcode-helpers.ts` (logged fallback).
+**Enforced where:** `src/lib/barcode-helpers.ts` (logged fallback). Log payload is `{ labelIndex, error:
+ {/* A plain , not next/link, on purpose: it must work before or + without hydration, and a full document load is the recovery we + want when the root layout itself has failed. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + Go to home page + +
{error.digest ? (
Reference: {error.digest}
diff --git a/src/components/address-labels/actions.test.ts b/src/components/address-labels/actions.test.ts
index f03cf83..eb6c8d3 100644
--- a/src/components/address-labels/actions.test.ts
+++ b/src/components/address-labels/actions.test.ts
@@ -8,6 +8,7 @@ const mockToBlob = vi.hoisted(() => vi.fn());
const mockDocxtemplaterRender = vi.hoisted(() => vi.fn());
const mockDocxtemplaterGetZip = vi.hoisted(() => vi.fn());
const mockImageModuleCtor = vi.hoisted(() => vi.fn());
+const mockAssertZip = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth', () => ({
auth: {
@@ -91,6 +92,13 @@ vi.mock('pizzip', () => ({
default: class {},
}));
+// The zip guard has its own tests against real archives (src/lib/zip-guard.test.ts);
+// here it is a seam so the fake templates below need not be valid ZIPs.
+vi.mock('@/lib/zip-guard', () => ({
+ assertZipWithinLimits: mockAssertZip,
+ ZipLimitError: class ZipLimitError extends Error {},
+}));
+
vi.mock('docxtemplater-image', () => ({
default: class {
constructor(...args: unknown[]) {
@@ -111,6 +119,9 @@ vi.mock('@/lib/barcode-image', () => ({
}));
import { fetchAddressLabels, generateLabelPdf, mergeTemplate } from './actions';
+import { preEncodeBarcodes } from '@/lib/barcode-helpers';
+import { ZipLimitError } from '@/lib/zip-guard';
+import { MAX_LABELS } from './limits';
import type { LabelConfig, LabelData } from '@/lib/dto';
import type { ToolParams } from '@/lib/tool-params';
@@ -463,11 +474,12 @@ describe('mergeTemplate', () => {
});
it('should return error for oversized template', async () => {
- const hugeBase64 = Buffer.alloc(6 * 1024 * 1024).toString('base64');
+ const hugeBase64 = Buffer.alloc(3 * 1024 * 1024).toString('base64');
const labels: LabelData[] = [{ name: 'T', addressLine1: 'A', city: 'C', state: 'S', postalCode: '12345' }];
const result = await mergeTemplate(hugeBase64, labels, mergeConfig);
expect(result.success).toBe(false);
- if (!result.success) expect(result.error).toContain('5MB');
+ if (!result.success) expect(result.error).toContain('2MB');
+ expect(mockAssertZip).not.toHaveBeenCalled();
});
it('returns error wrapped for template tag mismatch', async () => {
@@ -568,6 +580,84 @@ describe('mergeTemplate', () => {
});
});
+describe('label input validation (shared by every render action)', () => {
+ const config: LabelConfig = {
+ stockId: '5160',
+ addressMode: 'household',
+ startPosition: 1,
+ includeMissingBarcodes: true,
+ barcodeFormat: 'postnet',
+ mailerId: '',
+ serviceType: '040',
+ };
+ const label: LabelData = { name: 'T', addressLine1: 'A', city: 'C', state: 'S', postalCode: '12345' };
+ const template = Buffer.from('x').toString('base64');
+
+ beforeEach(() => {
+ mockAssertZip.mockReset();
+ mockDocxtemplaterRender.mockClear();
+ vi.mocked(preEncodeBarcodes).mockClear();
+ });
+
+ it('rejects more than MAX_LABELS labels before rendering', async () => {
+ const labels = Array.from({ length: MAX_LABELS + 1 }, () => label);
+ const result = await generateLabelPdf(labels, config);
+ expect(result).toEqual({ success: false, error: expect.stringContaining('Too many labels') });
+ expect(preEncodeBarcodes).not.toHaveBeenCalled();
+ });
+
+ it('rejects a label with an oversized field', async () => {
+ const result = await generateLabelPdf([{ ...label, name: 'x'.repeat(10_000) }], config);
+ expect(result).toEqual({ success: false, error: 'Invalid label data' });
+ });
+
+ it('rejects a non-array labels payload', async () => {
+ const result = await generateLabelPdf('nope' as unknown as LabelData[], config);
+ expect(result).toEqual({ success: false, error: 'Invalid label data' });
+ });
+
+ it('rejects an unknown service type', async () => {
+ const result = await generateLabelPdf([label], { ...config, serviceType: '999' });
+ expect(result).toEqual({ success: false, error: 'Invalid label settings' });
+ });
+
+ it.each([0, 31, 1e9, 1.5])('rejects start position %s outside the 30-up sheet', async (startPosition) => {
+ const result = await generateLabelPdf([label], { ...config, startPosition });
+ expect(result.success).toBe(false);
+ expect(preEncodeBarcodes).not.toHaveBeenCalled();
+ });
+
+ it('drops caller-supplied barStates/barType so only server-encoded bars render', async () => {
+ const hostile = { ...label, barStates: 'FFFF', barType: 'imb' as const };
+ await mergeTemplate(template, [hostile], config);
+ const passed = vi.mocked(preEncodeBarcodes).mock.calls[0][0];
+ expect(passed[0]).not.toHaveProperty('barStates');
+ expect(passed[0]).not.toHaveProperty('barType');
+ });
+
+ it('runs the zip guard before PizZip and returns its message on a limit breach', async () => {
+ mockAssertZip.mockImplementationOnce(() => {
+ throw new ZipLimitError('Template is too large when uncompressed');
+ });
+ const result = await mergeTemplate(template, [label], config);
+ expect(result).toEqual({ success: false, error: 'Template is too large when uncompressed' });
+ expect(mockDocxtemplaterRender).not.toHaveBeenCalled();
+ });
+
+ it('maps any other zip-guard failure to a generic invalid-file error', async () => {
+ mockAssertZip.mockImplementationOnce(() => {
+ throw new RangeError('out of bounds');
+ });
+ const result = await mergeTemplate(template, [label], config);
+ expect(result).toEqual({ success: false, error: 'Not a valid .docx file' });
+ });
+
+ it('rejects a non-string template', async () => {
+ const result = await mergeTemplate(42 as unknown as string, [label], config);
+ expect(result).toEqual({ success: false, error: 'Template file exceeds 2MB limit' });
+ });
+});
+
describe('generateLabelDocx', () => {
const docxConfig: LabelConfig = {
stockId: '5160',
diff --git a/src/components/address-labels/actions.ts b/src/components/address-labels/actions.ts
index 7f509a0..01d8686 100644
--- a/src/components/address-labels/actions.ts
+++ b/src/components/address-labels/actions.ts
@@ -19,6 +19,10 @@ import { buildWordDocument } from './word-document';
import { getLabelStock } from '@/lib/label-stock';
import { preEncodeBarcodes } from '@/lib/barcode-helpers';
import { validateMailerId } from '@/lib/validation';
+import { assertZipWithinLimits, ZipLimitError } from '@/lib/zip-guard';
+import type { LabelStockConfig } from '@/lib/label-stock';
+import { parseLabelInput } from './schemas';
+import { MAX_TEMPLATE_BYTES, MAX_TEMPLATE_LABEL, TEMPLATE_ZIP_LIMITS } from './limits';
import Docxtemplater from 'docxtemplater';
import PizZip from 'pizzip';
import ImageModule from 'docxtemplater-image';
@@ -172,19 +176,36 @@ function describeError(error: unknown): Record