Response Minimization & Cross-Tenant Masking β
How the backend keeps internal and cross-tenant data out of HTTP responses β and the one invariant that ties the two mechanisms together.
The two mechanisms β
Every response that can carry a Mongoose document passes through up to two transforms:
Minimization (DTO mappers) β
toWorkspaceResponse,toConfigurationResponse,toCompanyResponse,toDraftDetailResponse(in each module'sutils/). These are allowlist mappers: they project the document down to the client-facing fields, so server-only fields (AI prompts,__v,userId,parentWorkspaceId, embeddings, β¦) never serialize. This applies to every response of that type, own-tenant or not.Cross-tenant masking β applied only to documents returned to a foreign tenant (a company that received a share). Two functions:
WorkspaceService.maskWorkspaceForViewerβ for a shared workspace, deletesrowSharing,memberAccess,sharedWithCompanies,sharedWithUsers, and filtersrowSharing.hiddenFieldscolumns out oftableHeader.SharedAccessService.maskForViewerβ for shared record rows (ParseResults), strips each foreign row's workspacehiddenFields(the per-workspace column mask).
A third piece, the MongooseLeakInterceptor (see the DTO-leak guard), is a dev/test tripwire that flags any response still carrying a raw doc (Document / __v / ObjectId _id). It's opt-in (DTO_GUARD_WARN / DTO_GUARD_THROW) and a prod no-op.
The invariant β
A field that a mask can strip must be
.optional()with NO.default([]), and its mapper must emitundefined(not[]) when the document lacks it.
Minimization and masking run in sequence: the service masks the document, then the controller maps it to a DTO. If the mapper supplies a default for a field the mask just deleted, the default resurfaces it β leaking its presence (an empty array where the field should be absent) to a tenant who should never see it.
Wrong β the default defeats the mask β
// schema
memberAccess: z.array(MemberAccessSchema).optional().default([]), // β default forces presence
// mapper
memberAccess: (doc.memberAccess ?? []).map(toEntry), // β masked (undefined) β []A foreign viewer's masked workspace comes back with memberAccess: [] instead of the field being absent. The empty array is itself a cross-tenant signal, and it broke the workspace-discovery integration spec (expect(res.memberAccess).toBeUndefined()).
Right β absence survives the round-trip β
// schema
memberAccess: z.array(MemberAccessSchema).optional(), // β
no default; output type allows undefined
// mapper
memberAccess: doc.memberAccess?.map(toEntry), // β
masked β undefined, owned β the arrayThis works because an owned document has memberAccess: [] (Mongoose array default), while a masked one has it undefined (deleted). doc.field?.map(...) distinguishes the two: owned keeps its array, masked stays absent.
Where the pattern applies (and where it can't) β
The bug can only occur where both are true: the entity is returned cross-tenant, and a mask deletes a fixed-name field the mapper defaults. An audit of all four mappers:
| Entity | Cross-tenant masked? | Vulnerable? |
|---|---|---|
| Workspace | Yes (maskWorkspaceForViewer) | Was β memberAccess + sharedWithCompanies fixed. rowSharing/sharedWithUsers were never emitted; visibility/authorScopedVisibility are deliberately kept. |
| Records / ParseResults | Yes (maskForViewer) | No β the mask strips dynamic column keys (hiddenFields), which no static schema default can name. Rows are returned post-mask with no defaulting mapper. Structurally immune. |
| Company | No | No β parcels/regions/goals are intentional empty placeholders (clients use dedicated endpoints); they leak nothing even in a connected-company view. |
| Configuration | No | No β per-company, never masked. phoneNumberIds ?? [] is a benign default. |
| Draft | No | No β own-company; messages: [] is intentional content removal. |
Takeaway: the vulnerability converges on one entity (workspace), not a class of them β because records mask dynamic keys and company/config/draft aren't masked at all.
Checklist for a new (or edited) response mapper β
- Allowlist, don't denylist. Project the fields clients read; never spread the doc.
- Audit the clients first.
grepthe frontend/mobile for each field before dropping it β a field absent from the DTO type can still be read at runtime via a looser local type (this is how the workspacecompanyIddrop slipped past type-checking). - Stringify ids, drop
__v. ObjectId β string; never emit__vor hydrated subdocs. - For any field a mask can strip: schema
.optional()without.default(...), and mapperdoc.field?.map(...)β never?? []. - Verify with the guard + a masking spec. Run the owning module's E2E with
DTO_GUARD_THROW=true(leaks β 500), and assert masked-viewer responses omit the stripped fields (seeworkspaces/workspace-discovery.integration.spec.ts).
Related β
- Sharing / masking model β root
CLAUDE.mdβ Permissions, Company Connections & Sharing - Records vs References β
records-vs-references.md