From a4b742eb322ddbab5aacc0429089ab3f9065db3e Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Thu, 21 May 2026 07:14:50 -0400 Subject: [PATCH] feat(audit): attribute MP writes to acting user via session context MP write APIs were not receiving $userId, so every Contact_Log and Contacts write was recorded in MP's audit log under the OAuth integration account rather than the user who performed the action. The MPHelper layer supported $userId on create/update/delete; the gap was at the call sites. - Resolve MP User_ID in customSession from session.user.userGuid via a process-wide cache, attach to session.user.userId. Failures are logged but never block session creation. - New SessionContextService.getActingUserIdForWrite({ table, operation }) returns the userId or null. When null, emits a structured mp.write.non_user warn so anonymous/system writes are visible in production logs without hard-failing the request (the app may serve unauthenticated users in the future). - ContactLogService.createContactLog / updateContactLog / deleteContactLog and ContactService.updateContact now resolve and forward $userId. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/auth.ts | 43 ++++++- src/services/contactLogService.test.ts | 143 +++++++++++++++++---- src/services/contactLogService.ts | 27 +++- src/services/contactService.test.ts | 59 +++++++-- src/services/contactService.ts | 9 +- src/services/sessionContextService.test.ts | 137 ++++++++++++++++++++ src/services/sessionContextService.ts | 78 +++++++++++ 7 files changed, 454 insertions(+), 42 deletions(-) create mode 100644 src/services/sessionContextService.test.ts create mode 100644 src/services/sessionContextService.ts diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0a6c98d8..08744c27 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,9 +2,40 @@ import { betterAuth, BetterAuthOptions } from "better-auth"; import { genericOAuth } from "better-auth/plugins"; import { customSession } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; +import { MPHelper } from "@/lib/providers/ministry-platform"; +import { sanitizeGuid } from "@/lib/providers/ministry-platform/utils/filter-sanitize"; const mpBaseUrl = process.env.MINISTRY_PLATFORM_BASE_URL!; +// Process-wide cache of User_GUID → MP User_ID. customSession runs on every +// getSession() call, so without a cache each request would do a dp_Users +// lookup. Mapping is stable per user, so an unbounded Map is fine in practice. +const userIdCache = new Map(); + +async function resolveMpUserId(userGuid: string): Promise { + const cached = userIdCache.get(userGuid); + if (cached !== undefined) return cached; + try { + const mp = new MPHelper(); + const [record] = await mp.getTableRecords<{ User_ID: number }>({ + table: "dp_Users", + filter: `User_GUID = '${sanitizeGuid(userGuid)}'`, + select: "User_ID", + top: 1, + }); + if (record?.User_ID) { + userIdCache.set(userGuid, record.User_ID); + return record.User_ID; + } + return null; + } catch (err) { + // Never block session creation on this — the NonUser Write warning at + // write time will surface the missing attribution. + console.error("[customSession] resolveMpUserId failed", { userGuid, err }); + return null; + } +} + const options = { baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL, secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET, @@ -96,14 +127,20 @@ export const auth = betterAuth({ ...(options.plugins ?? []), customSession( async ({ user, session }) => { - // No API calls here — profile loading is handled by UserProvider - // on the client side via getCurrentUserProfile(). This keeps - // getSession() fast and avoids hitting the MP API on every request. + // Profile loading still happens client-side via UserProvider / + // getCurrentUserProfile(). The only server-side lookup we do here is + // User_ID, cached in-memory after the first resolution per process, + // so it costs at most one MP call per (user × container). + const userGuid = (user as { userGuid?: string | null }).userGuid; + const userId: number | null = userGuid + ? await resolveMpUserId(userGuid) + : null; return { user: { ...user, firstName: user.name?.split(" ")[0] || "", lastName: user.name?.split(" ").slice(1).join(" ") || "", + userId, }, session, }; diff --git a/src/services/contactLogService.test.ts b/src/services/contactLogService.test.ts index 6f8155f6..504a8012 100644 --- a/src/services/contactLogService.test.ts +++ b/src/services/contactLogService.test.ts @@ -6,12 +6,14 @@ const { mockUpdateTableRecords, mockDeleteTableRecords, mockGetDomainInfo, + mockGetActingUserIdForWrite, } = vi.hoisted(() => ({ mockGetTableRecords: vi.fn(), mockCreateTableRecords: vi.fn(), mockUpdateTableRecords: vi.fn(), mockDeleteTableRecords: vi.fn(), mockGetDomainInfo: vi.fn(), + mockGetActingUserIdForWrite: vi.fn(), })); vi.mock('@/lib/providers/ministry-platform', () => { @@ -26,6 +28,14 @@ vi.mock('@/lib/providers/ministry-platform', () => { }; }); +vi.mock('@/services/sessionContextService', () => ({ + SessionContextService: { + getInstance: () => ({ + getActingUserIdForWrite: mockGetActingUserIdForWrite, + }), + }, +})); + import { ContactLogService } from '@/services/contactLogService'; import { DomainTimezoneService } from '@/services/domainTimezoneService'; @@ -36,6 +46,8 @@ describe('ContactLogService', () => { mockUpdateTableRecords.mockReset(); mockDeleteTableRecords.mockReset(); mockGetDomainInfo.mockReset(); + mockGetActingUserIdForWrite.mockReset(); + mockGetActingUserIdForWrite.mockResolvedValue(500); mockGetDomainInfo.mockResolvedValue({ TimeZoneName: 'America/New_York', DisplayName: 'Test', @@ -186,17 +198,49 @@ describe('ContactLogService', () => { Feedback_Entry_ID: null, }); - expect(mockCreateTableRecords).toHaveBeenCalledWith('Contact_Log', [ - expect.objectContaining({ - Contact_ID: 42, - Contact_Date: '2026-05-17 00:00:00', - Notes: 'Test note', - Made_By: 100, - }), - ]); + expect(mockCreateTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + [ + expect.objectContaining({ + Contact_ID: 42, + Contact_Date: '2026-05-17 00:00:00', + Notes: 'Test note', + Made_By: 100, + }), + ], + { $userId: 500 }, + ); + expect(mockGetActingUserIdForWrite).toHaveBeenCalledWith({ + table: 'Contact_Log', + operation: 'create', + }); expect(result).toEqual(mockCreated); }); + it('omits $userId param when SessionContextService resolves to null (anonymous write)', async () => { + mockGetActingUserIdForWrite.mockResolvedValueOnce(null); + mockCreateTableRecords.mockResolvedValueOnce([{ Contact_Log_ID: 1 }]); + + const service = await ContactLogService.getInstance(); + await service.createContactLog({ + Contact_ID: 42, + Contact_Date: '2026-05-17', + Contact_Log_Type_ID: 1, + Made_By: 100, + Notes: 'Test note', + Planned_Contact_ID: null, + Contact_Successful: null, + Original_Contact_Log_Entry: null, + Feedback_Entry_ID: null, + }); + + expect(mockCreateTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + expect.any(Array), + undefined, + ); + }); + it('converts a UTC-tagged Contact_Date into MP-TZ wall-clock', async () => { mockCreateTableRecords.mockResolvedValueOnce([{ Contact_Log_ID: 1 }]); @@ -214,9 +258,11 @@ describe('ContactLogService', () => { Feedback_Entry_ID: null, }); - expect(mockCreateTableRecords).toHaveBeenCalledWith('Contact_Log', [ - expect.objectContaining({ Contact_Date: '2026-05-16 23:33:00' }), - ]); + expect(mockCreateTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + [expect.objectContaining({ Contact_Date: '2026-05-16 23:33:00' })], + { $userId: 500 }, + ); }); it('throws when API returns empty result', async () => { @@ -261,12 +307,20 @@ describe('ContactLogService', () => { const service = await ContactLogService.getInstance(); const result = await service.updateContactLog(1, { Notes: 'Updated note' }); - expect(mockUpdateTableRecords).toHaveBeenCalledWith('Contact_Log', [ - expect.objectContaining({ - Contact_Log_ID: 1, - Notes: 'Updated note', - }), - ]); + expect(mockUpdateTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + [ + expect.objectContaining({ + Contact_Log_ID: 1, + Notes: 'Updated note', + }), + ], + { $userId: 500 }, + ); + expect(mockGetActingUserIdForWrite).toHaveBeenCalledWith({ + table: 'Contact_Log', + operation: 'update', + }); expect(result).toEqual(mockUpdated); }); @@ -276,12 +330,30 @@ describe('ContactLogService', () => { const service = await ContactLogService.getInstance(); await service.updateContactLog(1, { Contact_Date: '2026-05-17' }); - expect(mockUpdateTableRecords).toHaveBeenCalledWith('Contact_Log', [ - expect.objectContaining({ - Contact_Log_ID: 1, - Contact_Date: '2026-05-17 00:00:00', - }), - ]); + expect(mockUpdateTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + [ + expect.objectContaining({ + Contact_Log_ID: 1, + Contact_Date: '2026-05-17 00:00:00', + }), + ], + { $userId: 500 }, + ); + }); + + it('omits $userId param when SessionContextService resolves to null (anonymous update)', async () => { + mockGetActingUserIdForWrite.mockResolvedValueOnce(null); + mockUpdateTableRecords.mockResolvedValueOnce([{ Contact_Log_ID: 1 }]); + + const service = await ContactLogService.getInstance(); + await service.updateContactLog(1, { Notes: 'Anon update' }); + + expect(mockUpdateTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + expect.any(Array), + undefined, + ); }); it('regression: round-tripping the same edit does not shift the date', async () => { @@ -298,6 +370,7 @@ describe('ContactLogService', () => { for (const call of mockUpdateTableRecords.mock.calls) { expect(call[1][0].Contact_Date).toBe('2026-05-17 00:00:00'); + expect(call[2]).toEqual({ $userId: 500 }); } }); @@ -318,7 +391,29 @@ describe('ContactLogService', () => { const service = await ContactLogService.getInstance(); await service.deleteContactLog(42); - expect(mockDeleteTableRecords).toHaveBeenCalledWith('Contact_Log', [42]); + expect(mockDeleteTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + [42], + { $userId: 500 }, + ); + expect(mockGetActingUserIdForWrite).toHaveBeenCalledWith({ + table: 'Contact_Log', + operation: 'delete', + }); + }); + + it('omits $userId param when SessionContextService resolves to null (anonymous delete)', async () => { + mockGetActingUserIdForWrite.mockResolvedValueOnce(null); + mockDeleteTableRecords.mockResolvedValueOnce(undefined); + + const service = await ContactLogService.getInstance(); + await service.deleteContactLog(42); + + expect(mockDeleteTableRecords).toHaveBeenCalledWith( + 'Contact_Log', + [42], + undefined, + ); }); it('should propagate delete errors', async () => { diff --git a/src/services/contactLogService.ts b/src/services/contactLogService.ts index de39d8e4..583c9986 100644 --- a/src/services/contactLogService.ts +++ b/src/services/contactLogService.ts @@ -3,6 +3,7 @@ import { ContactLogTypes } from "@/lib/providers/ministry-platform/models/Contac import { ContactLogSchema, ContactLogInput } from "@/lib/providers/ministry-platform/models/ContactLogSchema"; import { MPHelper } from "@/lib/providers/ministry-platform"; import { DomainTimezoneService } from "@/services/domainTimezoneService"; +import { SessionContextService } from "@/services/sessionContextService"; /** * ContactLogService - Singleton service for managing contact log operations @@ -146,9 +147,15 @@ export class ContactLogService { const mpDate = await tz.toMpSqlDatetime(Contact_Date); console.log('ContactLogService.createContactLog - MP-TZ SQL date:', mpDate); + const $userId = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contact_Log", + operation: "create", + }); + const result = await this.mp!.createTableRecords( "Contact_Log", - [{ ...validatedRest, Contact_Date: mpDate }] + [{ ...validatedRest, Contact_Date: mpDate }], + $userId !== null ? { $userId } : undefined ); if (!result || result.length === 0) { @@ -191,9 +198,15 @@ export class ContactLogService { ...(mpDate !== undefined ? { Contact_Date: mpDate } : {}), }; + const $userId = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contact_Log", + operation: "update", + }); + const result = await this.mp!.updateTableRecords( "Contact_Log", - [updateData] + [updateData], + $userId !== null ? { $userId } : undefined ); if (!result || result.length === 0) { @@ -211,10 +224,16 @@ export class ContactLogService { */ public async deleteContactLog(contactLogId: number): Promise { console.log('ContactLogService.deleteContactLog - Deleting log:', contactLogId); - + + const $userId = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contact_Log", + operation: "delete", + }); + await this.mp!.deleteTableRecords( "Contact_Log", - [contactLogId] + [contactLogId], + $userId !== null ? { $userId } : undefined ); console.log('ContactLogService.deleteContactLog - Successfully deleted'); diff --git a/src/services/contactService.test.ts b/src/services/contactService.test.ts index 88ec4827..3877dcfb 100644 --- a/src/services/contactService.test.ts +++ b/src/services/contactService.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ContactService } from '@/services/contactService'; -const mockGetTableRecords = vi.fn(); -const mockUpdateTableRecords = vi.fn(); +const { + mockGetTableRecords, + mockUpdateTableRecords, + mockGetActingUserIdForWrite, +} = vi.hoisted(() => ({ + mockGetTableRecords: vi.fn(), + mockUpdateTableRecords: vi.fn(), + mockGetActingUserIdForWrite: vi.fn(), +})); vi.mock('@/lib/providers/ministry-platform', () => { return { @@ -13,9 +19,20 @@ vi.mock('@/lib/providers/ministry-platform', () => { }; }); +vi.mock('@/services/sessionContextService', () => ({ + SessionContextService: { + getInstance: () => ({ + getActingUserIdForWrite: mockGetActingUserIdForWrite, + }), + }, +})); + +import { ContactService } from '@/services/contactService'; + describe('ContactService', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetActingUserIdForWrite.mockResolvedValue(500); // eslint-disable-next-line @typescript-eslint/no-explicit-any (ContactService as any).instance = undefined; }); @@ -118,15 +135,21 @@ describe('ContactService', () => { }); describe('updateContact', () => { - it('should update contact with correct record', async () => { + it('should update contact with correct record and $userId from session', async () => { mockUpdateTableRecords.mockResolvedValueOnce([]); const service = await ContactService.getInstance(); await service.updateContact(42, { Email_Address: 'new@example.com' }); - expect(mockUpdateTableRecords).toHaveBeenCalledWith('Contacts', [ - { Contact_ID: 42, Email_Address: 'new@example.com' }, - ]); + expect(mockUpdateTableRecords).toHaveBeenCalledWith( + 'Contacts', + [{ Contact_ID: 42, Email_Address: 'new@example.com' }], + { $userId: 500 }, + ); + expect(mockGetActingUserIdForWrite).toHaveBeenCalledWith({ + table: 'Contacts', + operation: 'update', + }); }); it('should update multiple fields', async () => { @@ -138,9 +161,25 @@ describe('ContactService', () => { Mobile_Phone: '555-9999', }); - expect(mockUpdateTableRecords).toHaveBeenCalledWith('Contacts', [ - { Contact_ID: 42, Email_Address: 'new@example.com', Mobile_Phone: '555-9999' }, - ]); + expect(mockUpdateTableRecords).toHaveBeenCalledWith( + 'Contacts', + [{ Contact_ID: 42, Email_Address: 'new@example.com', Mobile_Phone: '555-9999' }], + { $userId: 500 }, + ); + }); + + it('omits $userId param when SessionContextService resolves to null (anonymous update)', async () => { + mockGetActingUserIdForWrite.mockResolvedValueOnce(null); + mockUpdateTableRecords.mockResolvedValueOnce([]); + + const service = await ContactService.getInstance(); + await service.updateContact(42, { Email_Address: 'anon@example.com' }); + + expect(mockUpdateTableRecords).toHaveBeenCalledWith( + 'Contacts', + [{ Contact_ID: 42, Email_Address: 'anon@example.com' }], + undefined, + ); }); it('should propagate errors from MPHelper', async () => { diff --git a/src/services/contactService.ts b/src/services/contactService.ts index ce150f89..a45c5ac7 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -1,6 +1,7 @@ import { ContactSearch } from "@/lib/dto"; import { MPHelper } from "@/lib/providers/ministry-platform"; import { sanitizeLikeValue, sanitizeGuid } from "@/lib/providers/ministry-platform/utils/filter-sanitize"; +import { SessionContextService } from "@/services/sessionContextService"; /** * ContactService - Singleton service for managing contact-related operations @@ -98,9 +99,15 @@ export class ContactService { ): Promise { const record = { Contact_ID: contactId, ...fields }; + const $userId = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contacts", + operation: "update", + }); + await this.mp!.updateTableRecords( "Contacts", - [record] + [record], + $userId !== null ? { $userId } : undefined ); } } \ No newline at end of file diff --git a/src/services/sessionContextService.test.ts b/src/services/sessionContextService.test.ts new file mode 100644 index 00000000..0a99370e --- /dev/null +++ b/src/services/sessionContextService.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockGetSession, mockHeaders } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockHeaders: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + auth: { api: { getSession: mockGetSession } }, +})); + +vi.mock("next/headers", () => ({ + headers: mockHeaders, +})); + +import { SessionContextService } from "@/services/sessionContextService"; + +describe("SessionContextService", () => { + let warnSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + mockGetSession.mockReset(); + mockHeaders.mockReset(); + mockHeaders.mockResolvedValue(new Headers()); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (SessionContextService as any).instance = null; + }); + + afterEach(() => { + // Restore console spies so they don't stack across tests. + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + describe("getInstance", () => { + it("returns a singleton", () => { + expect(SessionContextService.getInstance()).toBe( + SessionContextService.getInstance(), + ); + }); + }); + + describe("getCurrentUserId", () => { + it("returns userId when session has it", async () => { + mockGetSession.mockResolvedValueOnce({ user: { userId: 42 } }); + const result = await SessionContextService.getInstance().getCurrentUserId(); + expect(result).toBe(42); + }); + + it("returns null when session is null", async () => { + mockGetSession.mockResolvedValueOnce(null); + const result = await SessionContextService.getInstance().getCurrentUserId(); + expect(result).toBeNull(); + }); + + it("returns null when session.user has no userId", async () => { + mockGetSession.mockResolvedValueOnce({ user: { name: "Anon" } }); + const result = await SessionContextService.getInstance().getCurrentUserId(); + expect(result).toBeNull(); + }); + + it("returns null and logs when getSession throws", async () => { + mockGetSession.mockRejectedValueOnce(new Error("session boom")); + const result = await SessionContextService.getInstance().getCurrentUserId(); + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("does not warn on the pure-read path", async () => { + mockGetSession.mockResolvedValueOnce(null); + await SessionContextService.getInstance().getCurrentUserId(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + + describe("getActingUserIdForWrite", () => { + it("returns userId and does not warn when resolved", async () => { + mockGetSession.mockResolvedValueOnce({ user: { userId: 7 } }); + + const result = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contact_Log", + operation: "create", + }); + + expect(result).toBe(7); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("returns null and emits structured warn when no session", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const result = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contacts", + operation: "update", + }); + + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(warnSpy.mock.calls[0][0] as string); + expect(payload).toMatchObject({ + event: "mp.write.non_user", + table: "Contacts", + operation: "update", + }); + expect(payload.message).toContain("NonUser Write"); + }); + + it("emits warn with the correct table/operation per call", async () => { + mockGetSession.mockResolvedValue(null); + + await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contact_Log", + operation: "delete", + }); + + const payload = JSON.parse(warnSpy.mock.calls[0][0] as string); + expect(payload.table).toBe("Contact_Log"); + expect(payload.operation).toBe("delete"); + }); + + it("still warns when getSession throws (treated as anonymous)", async () => { + mockGetSession.mockRejectedValueOnce(new Error("session boom")); + + const result = await SessionContextService.getInstance().getActingUserIdForWrite({ + table: "Contact_Log", + operation: "create", + }); + + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/services/sessionContextService.ts b/src/services/sessionContextService.ts new file mode 100644 index 00000000..a13fc35b --- /dev/null +++ b/src/services/sessionContextService.ts @@ -0,0 +1,78 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; + +/** + * SessionContextService — resolves the current request's acting MP user for + * audit attribution on writes. + * + * Why this exists: MP's audit log keys on the `$userId` passed to write APIs. + * Without it, every change is attributed to the OAuth integration account, + * which destroys the "who did what" trail. This service centralizes pulling + * the acting user's MP `User_ID` from the Better Auth session (where it is + * baked in by `customSession` in `src/lib/auth.ts`). + * + * Anonymous writes are legitimate (the app may serve unauthenticated users in + * the future) but always surfaced: `getActingUserIdForWrite` emits a + * structured `mp.write.non_user` warning so non-user writes are visible in + * production logs (Vercel, log aggregators) and can be investigated. + */ +export class SessionContextService { + private static instance: SessionContextService | null = null; + + private constructor() {} + + public static getInstance(): SessionContextService { + if (!SessionContextService.instance) { + SessionContextService.instance = new SessionContextService(); + } + return SessionContextService.instance; + } + + /** + * Pure read — returns the acting user's MP User_ID for the current request, + * or null when there is no session or no User_ID could be resolved at + * session creation. No side effects. Prefer `getActingUserIdForWrite` at + * MP write boundaries so non-user writes are logged. + */ + public async getCurrentUserId(): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + const userId = ( + session?.user as { userId?: number | null } | undefined + )?.userId; + return userId ?? null; + } catch (err) { + console.error("[SessionContextService] getSession failed", err); + return null; + } + } + + /** + * Returns the acting user's MP User_ID for a write operation. When no user + * is resolved (anonymous / system / session lookup failed) emits a + * structured `mp.write.non_user` warning so the unattributed write is + * visible in production logs. Use this — not `getCurrentUserId` — at every + * MP write boundary. + */ + public async getActingUserIdForWrite(ctx: { + table: string; + operation: "create" | "update" | "delete"; + }): Promise { + const userId = await this.getCurrentUserId(); + if (userId === null) { + // Stable shape — grep / alerts can rely on `event: mp.write.non_user`. + console.warn( + JSON.stringify({ + event: "mp.write.non_user", + message: + "NonUser Write — MP write performed without a resolved acting user", + table: ctx.table, + operation: ctx.operation, + }), + ); + } + return userId; + } +} + +export const sessionContextService = SessionContextService.getInstance();