From fd0969e5cefbc3237fde5d526ea6ee5b6d416c7b Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:29:19 +0200 Subject: [PATCH 01/16] feat(admin): owner allowlist + isAdmin (config-in-code) --- src/lib/admin.test.ts | 19 +++++++++++++++++++ src/lib/admin.ts | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/lib/admin.test.ts create mode 100644 src/lib/admin.ts diff --git a/src/lib/admin.test.ts b/src/lib/admin.test.ts new file mode 100644 index 00000000..ae0bd6b1 --- /dev/null +++ b/src/lib/admin.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { isAdmin, ADMIN_EMAILS } from './admin' + +describe('isAdmin', () => { + it('includes the owner in the allowlist', () => { + expect(ADMIN_EMAILS).toContain('paul@kilbot.com') + }) + + it('matches an allowlisted email case-insensitively, trimming whitespace', () => { + expect(isAdmin('Paul@Kilbot.com')).toBe(true) + expect(isAdmin(' paul@kilbot.com ')).toBe(true) + }) + + it('rejects non-allowlisted or missing emails (fail closed)', () => { + expect(isAdmin('someone@else.com')).toBe(false) + expect(isAdmin(undefined)).toBe(false) + expect(isAdmin('')).toBe(false) + }) +}) diff --git a/src/lib/admin.ts b/src/lib/admin.ts new file mode 100644 index 00000000..a2837e4d --- /dev/null +++ b/src/lib/admin.ts @@ -0,0 +1,16 @@ +/** + * Owner allowlist for super-admin "view as" (read-only impersonation). + * + * Config-in-code on purpose (no env var): this is a solo-run service where the + * flip mechanism is git, and the value fails loudly if wrong. Add owner emails + * here. Empty ⇒ nobody is admin (fail closed). + */ +export const ADMIN_EMAILS: readonly string[] = ['paul@kilbot.com'] + +const NORMALIZED = new Set(ADMIN_EMAILS.map((e) => e.trim().toLowerCase())) + +/** True only for an allowlisted email. Undefined/empty ⇒ false (fail closed). */ +export function isAdmin(email: string | null | undefined): boolean { + if (!email) return false + return NORMALIZED.has(email.trim().toLowerCase()) +} From dfba0efb9925af629fe3fbfb26c86040f3d69a7c Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:30:52 +0200 Subject: [PATCH 02/16] feat(admin): findAdminCustomerByEmail + getAdminCustomerById --- src/lib/discord/medusa-admin.test.ts | 53 ++++++++++++++++++++++++++++ src/lib/discord/medusa-admin.ts | 41 +++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/lib/discord/medusa-admin.test.ts diff --git a/src/lib/discord/medusa-admin.test.ts b/src/lib/discord/medusa-admin.test.ts new file mode 100644 index 00000000..b769c1d5 --- /dev/null +++ b/src/lib/discord/medusa-admin.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/utils/env', () => ({ + env: { MEDUSA_ADMIN_API_TOKEN: 'admin-token' }, +})) +vi.mock('@/lib/store-environment', () => ({ + getLiveStoreEnvironment: () => ({ medusaBackendUrl: 'https://api.test' }), +})) + +import { findAdminCustomerByEmail, getAdminCustomerById } from './medusa-admin' + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +afterEach(() => { + fetchMock.mockReset() +}) + +function ok(body: unknown) { + return { ok: true, json: async () => body } as Response +} + +describe('findAdminCustomerByEmail', () => { + it('queries /admin/customers by email and returns the first match', async () => { + fetchMock.mockResolvedValueOnce( + ok({ customers: [{ id: 'cus_1', email: 'a@b.com' }] }) + ) + const customer = await findAdminCustomerByEmail('a@b.com') + expect(customer?.id).toBe('cus_1') + const url = fetchMock.mock.calls[0][0] as string + expect(url).toContain('/admin/customers?') + expect(url).toContain('a%40b.com') + }) + + it('returns null when no customer matches', async () => { + fetchMock.mockResolvedValueOnce(ok({ customers: [] })) + expect(await findAdminCustomerByEmail('missing@b.com')).toBeNull() + }) +}) + +describe('getAdminCustomerById', () => { + it('fetches /admin/customers/:id', async () => { + fetchMock.mockResolvedValueOnce(ok({ customer: { id: 'cus_9' } })) + const customer = await getAdminCustomerById('cus_9') + expect(customer?.id).toBe('cus_9') + expect(fetchMock.mock.calls[0][0]).toContain('/admin/customers/cus_9') + }) + + it('returns null on a non-ok response', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' } as Response) + expect(await getAdminCustomerById('cus_x')).toBeNull() + }) +}) diff --git a/src/lib/discord/medusa-admin.ts b/src/lib/discord/medusa-admin.ts index 5df51907..2e71b7f7 100644 --- a/src/lib/discord/medusa-admin.ts +++ b/src/lib/discord/medusa-admin.ts @@ -91,3 +91,44 @@ export async function listAdminCustomerOrders( return orders } + +interface AdminCustomerResponse { + customer?: MedusaCustomer +} + +/** + * Find a single customer by exact email via the admin API. Returns null when + * there is no match. Uses the `email` filter so we never page all customers. + */ +export async function findAdminCustomerByEmail( + email: string +): Promise { + const query = new URLSearchParams({ + email: email.trim().toLowerCase(), + limit: '1', + fields: 'id,email,first_name,last_name,phone,has_account,metadata,created_at,updated_at', + }) + const page = await medusaAdminFetch( + `/admin/customers?${query.toString()}` + ) + return page.customers?.[0] ?? null +} + +/** + * Fetch one customer by id via the admin API. Returns null if not found. + */ +export async function getAdminCustomerById( + id: string +): Promise { + const query = new URLSearchParams({ + fields: 'id,email,first_name,last_name,phone,has_account,metadata,created_at,updated_at', + }) + try { + const data = await medusaAdminFetch( + `/admin/customers/${id}?${query.toString()}` + ) + return data.customer ?? null + } catch { + return null + } +} From 3e6d6cf64a912ff682912b35a8cd891c0b98d31f Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:32:38 +0200 Subject: [PATCH 03/16] refactor(auth): extract getSessionCustomer; getCustomer delegates getSessionCustomer() is the real logged-in owner identity, reachable independently of getCustomer's cached seam. Behavior unchanged in this task; a later task adds an impersonation branch to getCustomer. --- src/lib/medusa-auth.test.ts | 34 ++++++++++++++++++++++ src/lib/medusa-auth.ts | 57 ++++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/lib/medusa-auth.test.ts b/src/lib/medusa-auth.test.ts index 748a4901..0feaf353 100644 --- a/src/lib/medusa-auth.test.ts +++ b/src/lib/medusa-auth.test.ts @@ -58,6 +58,7 @@ import { requestPasswordReset, resetPassword, getCustomer, + getSessionCustomer, getAuthToken, setAuthToken, clearAuthToken, @@ -570,3 +571,36 @@ describe('session cookie options', () => { expect(mockCookieStore.delete).toHaveBeenCalledWith('medusa-token') }) }) + +// getSessionCustomer is the extracted body of the old getCustomer: the REAL +// logged-in customer resolved from the session cookie. getCustomer (tested +// above) now delegates to it — this file already provides the shared +// next/headers + fetch mocks, so we reuse those rather than re-declaring +// conflicting vi.mock() calls. +describe('getSessionCustomer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the /store/customers/me customer for the session token', async () => { + mockCookieStore.get.mockReturnValue({ value: 'valid_token' }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ customer: { id: 'cus_me', email: 'me@x.com' } }), + }) + + const customer = await getSessionCustomer() + + expect(customer?.id).toBe('cus_me') + expect(mockFetch.mock.calls[0][0]).toContain('/store/customers/me') + }) + + it('returns null when there is no session token', async () => { + mockCookieStore.get.mockReturnValue(undefined) + + const customer = await getSessionCustomer() + + expect(customer).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/medusa-auth.ts b/src/lib/medusa-auth.ts index 428a034d..b4095042 100644 --- a/src/lib/medusa-auth.ts +++ b/src/lib/medusa-auth.ts @@ -327,34 +327,39 @@ export async function logout(): Promise { // dedupes them to a single request. It stays dynamic (reads cookies via // getAuthToken) — callers keep it inside Suspense, so PPR is unaffected; the // null-on-failure result is safe to memoize within a request. -export const getCustomer = cache( - async (): Promise => { - const token = await getAuthToken() - if (!token) return null - - try { - const response = await fetch( - `${await getMedusaBackendUrl()}/store/customers/me`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - 'x-publishable-api-key': await getMedusaPublishableKey(), - }, - } - ) - - if (!response.ok) { - return null - } +/** + * The REAL logged-in customer, resolved from the session cookie. This is the + * acting identity — used for the admin gate, audit, and the "you are X" banner. + * Not memoized itself; `getCustomer` (its default caller) is the cached seam. + */ +export async function getSessionCustomer(): Promise { + const token = await getAuthToken() + if (!token) return null - const data = await response.json() - return data.customer - } catch (error) { - authLogger.error`Failed to get customer: ${error}` - return null - } + try { + const response = await fetch( + `${await getMedusaBackendUrl()}/store/customers/me`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'x-publishable-api-key': await getMedusaPublishableKey(), + }, + } + ) + if (!response.ok) return null + const data = await response.json() + return data.customer + } catch (error) { + authLogger.error`Failed to get customer: ${error}` + return null } +} + +// Memoized per request with React `cache()`. Task 6 adds the impersonation +// branch; for now it is the session customer. +export const getCustomer = cache( + async (): Promise => getSessionCustomer() ) /** From 2cf11cbbf50c77b378bbb9cdcf76f18aa3e9ca2f Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:36:44 +0200 Subject: [PATCH 04/16] feat(account): stamp x-wcpos-account-request header for impersonation scope --- src/middleware.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index 47821013..6a2d1486 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,4 @@ -import { NextResponse } from 'next/server' -import type { NextRequest } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import createIntlMiddleware from 'next-intl/middleware' import { routing } from '@/i18n/routing' import { @@ -17,6 +16,10 @@ const COOKIE_NAME = MEDUSA_TOKEN_COOKIE const UPDATES_HOSTNAME = 'updates.wcpos.com' const MAIN_SITE_ORIGIN = 'https://wcpos.com' +// Set on requests inside the account area so server code can scope +// impersonation to /account only (see src/lib/impersonation.ts). +const ACCOUNT_REQUEST_HEADER = 'x-wcpos-account-request' + const intlMiddleware = createIntlMiddleware(routing) /** @@ -106,8 +109,14 @@ export function middleware(request: NextRequest) { return withDistinctIdCookie(request, NextResponse.redirect(redirectUrl, 301)) } - // API routes don't need locale processing + // API routes don't need locale processing. Account APIs get the + // account-request header so impersonation is scoped to /account. if (pathname.startsWith('/api/')) { + if (pathname.startsWith('/api/account/')) { + const headers = new Headers(request.headers) + headers.set(ACCOUNT_REQUEST_HEADER, '1') + return NextResponse.next({ request: { headers } }) + } return NextResponse.next() } @@ -143,6 +152,18 @@ export function middleware(request: NextRequest) { } } + // Inside /account, stamp the account-request header before locale routing so + // impersonation is honored only here. next-intl copies the incoming request's + // headers onto the response it forwards (`NextResponse.rewrite`/`.next` with + // `{ request: { headers } }`), so a header set on the request it receives + // propagates to the RSC layer via `headers()`. + if (pathnameWithoutLocale.startsWith('/account')) { + const headers = new Headers(request.headers) + headers.set(ACCOUNT_REQUEST_HEADER, '1') + const requestWithHeader = new NextRequest(request, { headers }) + return withDistinctIdCookie(request, intlMiddleware(requestWithHeader)) + } + return withDistinctIdCookie(request, intlMiddleware(request)) } From ff552725de82f33d0753accff15fda17eab27cd8 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:39:11 +0200 Subject: [PATCH 05/16] feat(impersonation): getImpersonation resolver + assertViewOnly guard --- src/lib/impersonation.test.ts | 92 +++++++++++++++++++++++++++++++++++ src/lib/impersonation.ts | 81 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/lib/impersonation.test.ts create mode 100644 src/lib/impersonation.ts diff --git a/src/lib/impersonation.test.ts b/src/lib/impersonation.test.ts new file mode 100644 index 00000000..2cf9cf90 --- /dev/null +++ b/src/lib/impersonation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('server-only', () => ({})) + +const { cookieStore, headerStore, getSessionCustomer } = vi.hoisted(() => { + const cookieStore = { + value: undefined as string | undefined, + get: (name: string) => + name === 'wcpos-impersonate' && cookieStore.value + ? { value: cookieStore.value } + : undefined, + set: vi.fn((_n: string, v: string) => { + cookieStore.value = v + }), + delete: vi.fn(() => { + cookieStore.value = undefined + }), + } + const headerStore = { value: undefined as string | undefined } + const getSessionCustomer = vi.fn() + return { cookieStore, headerStore, getSessionCustomer } +}) + +vi.mock('next/headers', () => ({ + cookies: async () => cookieStore, + headers: async () => ({ + get: (n: string) => + n === 'x-wcpos-account-request' ? headerStore.value ?? null : null, + }), +})) + +vi.mock('@/lib/medusa-auth', () => ({ getSessionCustomer })) +vi.mock('@/lib/admin', () => ({ + isAdmin: (email?: string | null) => email === 'paul@kilbot.com', +})) + +import { getImpersonation, assertViewOnly, ViewOnlyError } from './impersonation' + +beforeEach(() => { + cookieStore.value = undefined + headerStore.value = undefined + getSessionCustomer.mockReset() + cookieStore.set.mockClear() + cookieStore.delete.mockClear() +}) + +describe('getImpersonation', () => { + it('returns the target when admin session + cookie + account header all present', async () => { + cookieStore.value = 'cus_target' + headerStore.value = '1' + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + expect(await getImpersonation()).toEqual({ + adminEmail: 'paul@kilbot.com', + targetId: 'cus_target', + }) + }) + + it('returns null outside the account area (no header)', async () => { + cookieStore.value = 'cus_target' + headerStore.value = undefined + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + expect(await getImpersonation()).toBeNull() + }) + + it('returns null and clears the cookie when the real session is NOT admin', async () => { + cookieStore.value = 'cus_target' + headerStore.value = '1' + getSessionCustomer.mockResolvedValue({ email: 'attacker@evil.com' }) + expect(await getImpersonation()).toBeNull() + expect(cookieStore.delete).toHaveBeenCalledWith('wcpos-impersonate') + }) + + it('returns null when there is no cookie', async () => { + headerStore.value = '1' + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + expect(await getImpersonation()).toBeNull() + }) +}) + +describe('assertViewOnly', () => { + it('throws ViewOnlyError while impersonating', async () => { + cookieStore.value = 'cus_target' + headerStore.value = '1' + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + await expect(assertViewOnly()).rejects.toBeInstanceOf(ViewOnlyError) + }) + + it('is a no-op when not impersonating', async () => { + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + await expect(assertViewOnly()).resolves.toBeUndefined() + }) +}) diff --git a/src/lib/impersonation.ts b/src/lib/impersonation.ts new file mode 100644 index 00000000..a387d9f4 --- /dev/null +++ b/src/lib/impersonation.ts @@ -0,0 +1,81 @@ +import 'server-only' + +import { cache } from 'react' +import { cookies, headers } from 'next/headers' +import { getSessionCustomer } from '@/lib/medusa-auth' +import { isAdmin } from '@/lib/admin' + +export const IMPERSONATION_COOKIE = 'wcpos-impersonate' +const ACCOUNT_REQUEST_HEADER = 'x-wcpos-account-request' + +const IMPERSONATION_COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/', + maxAge: 60 * 60, // 1 hour — inspection is a short, deliberate session +} + +export interface Impersonation { + adminEmail: string + targetId: string +} + +/** Thrown by a mutating route entered while impersonating. */ +export class ViewOnlyError extends Error { + constructor() { + super('This action is disabled during read-only inspection.') + this.name = 'ViewOnlyError' + } +} + +/** + * Resolve the active impersonation, request-scoped. Returns a target ONLY when + * all three hold: the account-request header is present (scopes to /account), + * the impersonation cookie names a target, AND the REAL session customer is an + * admin. Authority derives solely from the real-session admin check — never the + * cookie. If a cookie is present but the session is not admin, the cookie is + * cleared. + */ +export const getImpersonation = cache( + async (): Promise => { + const headerStore = await headers() + if (headerStore.get(ACCOUNT_REQUEST_HEADER) !== '1') return null + + const cookieStore = await cookies() + const targetId = cookieStore.get(IMPERSONATION_COOKIE)?.value + if (!targetId) return null + + const session = await getSessionCustomer() + if (!isAdmin(session?.email)) { + // A cookie with a non-admin session is inert; clear it defensively. + try { + cookieStore.delete(IMPERSONATION_COOKIE) + } catch { + // read-only cookie context (RSC) — ignore; it is inert regardless. + } + return null + } + + return { adminEmail: session!.email, targetId } + } +) + +/** Throw if the current request is a read-only inspection. Call at the top of + * every mutating account entry point. */ +export async function assertViewOnly(): Promise { + if (await getImpersonation()) throw new ViewOnlyError() +} + +/** Begin inspecting a target customer (called from the admin action, which has + * already verified the caller is an admin). */ +export async function startImpersonation(targetId: string): Promise { + const cookieStore = await cookies() + cookieStore.set(IMPERSONATION_COOKIE, targetId, IMPERSONATION_COOKIE_OPTIONS) +} + +/** Stop inspecting. */ +export async function stopImpersonation(): Promise { + const cookieStore = await cookies() + cookieStore.delete(IMPERSONATION_COOKIE) +} From 2f87aaf83695bbf5a15190b7a4a49e1eb4f13475 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:42:56 +0200 Subject: [PATCH 06/16] feat(impersonation): getCustomer + order fetchers resolve the target --- src/lib/customer-orders.test.ts | 88 ++++++++++++++++++++++++++++++++- src/lib/customer-orders.ts | 23 +++++++++ src/lib/medusa-auth.test.ts | 37 ++++++++++++++ src/lib/medusa-auth.ts | 16 ++++-- 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/src/lib/customer-orders.test.ts b/src/lib/customer-orders.test.ts index 68b25cc4..7c6a1c3c 100644 --- a/src/lib/customer-orders.test.ts +++ b/src/lib/customer-orders.test.ts @@ -34,11 +34,20 @@ vi.mock('@/lib/store-environment', () => { } }) -const { mockGetAuthToken, mockWarn, mockError, mockFetch } = vi.hoisted(() => ({ +const { + mockGetAuthToken, + mockWarn, + mockError, + mockFetch, + mockGetImpersonation, + mockListAdminCustomerOrders, +} = vi.hoisted(() => ({ mockGetAuthToken: vi.fn(), mockWarn: vi.fn(), mockError: vi.fn(), mockFetch: vi.fn(), + mockGetImpersonation: vi.fn(), + mockListAdminCustomerOrders: vi.fn(), })) vi.mock('@/lib/medusa-auth', () => ({ @@ -49,6 +58,14 @@ vi.mock('@/lib/logger', () => ({ storeLogger: { warn: mockWarn, error: mockError }, })) +vi.mock('@/lib/impersonation', () => ({ + getImpersonation: () => mockGetImpersonation(), +})) + +vi.mock('@/lib/discord/medusa-admin', () => ({ + listAdminCustomerOrders: (id: string) => mockListAdminCustomerOrders(id), +})) + global.fetch = mockFetch import { getOrdersPage, getAllOrders, getOrderById } from './customer-orders' @@ -56,6 +73,9 @@ import { getOrdersPage, getAllOrders, getOrderById } from './customer-orders' describe('customer-orders', () => { beforeEach(() => { vi.clearAllMocks() + // Default: not impersonating, so the existing session-scoped tests below + // all exercise the non-impersonating path unchanged. + mockGetImpersonation.mockResolvedValue(null) }) describe('getOrdersPage', () => { @@ -274,4 +294,70 @@ describe('customer-orders', () => { expect(mockFetch).not.toHaveBeenCalled() }) }) + + // When inspecting, every order read must resolve the TARGET customer's + // orders via the admin API — Medusa scopes /store/orders to the session's + // own actor_id, so the session token would return the ADMIN's orders. + describe('under impersonation', () => { + beforeEach(() => { + mockGetImpersonation.mockResolvedValue({ + adminEmail: 'p@k.com', + targetId: 'cus_t', + }) + }) + + it('getAllOrders fetches the TARGET orders via the admin API, not the session', async () => { + mockListAdminCustomerOrders.mockResolvedValue([ + { id: 'ord_t', display_id: 1 }, + ]) + + const orders = await getAllOrders() + + expect(mockListAdminCustomerOrders).toHaveBeenCalledWith('cus_t') + expect(orders).toEqual([{ id: 'ord_t', display_id: 1 }]) + expect(mockGetAuthToken).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('getOrdersPage slices the TARGET order set for the requested window', async () => { + mockListAdminCustomerOrders.mockResolvedValue([ + { id: 'ord_1', display_id: 1 }, + { id: 'ord_2', display_id: 2 }, + { id: 'ord_3', display_id: 3 }, + ]) + + const page = await getOrdersPage(2, 1) + + expect(mockListAdminCustomerOrders).toHaveBeenCalledWith('cus_t') + expect(page).toEqual([ + { id: 'ord_2', display_id: 2 }, + { id: 'ord_3', display_id: 3 }, + ]) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('getOrderById finds the order within the TARGET order set', async () => { + mockListAdminCustomerOrders.mockResolvedValue([ + { id: 'ord_1', display_id: 1 }, + { id: 'ord_2', display_id: 2 }, + ]) + + const order = await getOrderById('ord_2') + + expect(mockListAdminCustomerOrders).toHaveBeenCalledWith('cus_t') + expect(order).toEqual({ id: 'ord_2', display_id: 2 }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('getOrderById returns null when the id is not in the TARGET set', async () => { + mockListAdminCustomerOrders.mockResolvedValue([ + { id: 'ord_1', display_id: 1 }, + ]) + + const order = await getOrderById('ord_missing') + + expect(order).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/lib/customer-orders.ts b/src/lib/customer-orders.ts index 431cce61..be85b756 100644 --- a/src/lib/customer-orders.ts +++ b/src/lib/customer-orders.ts @@ -6,6 +6,8 @@ import { getMedusaBackendUrl, getMedusaPublishableKey, } from '@/lib/store-environment' +import { getImpersonation } from '@/lib/impersonation' +import { listAdminCustomerOrders } from '@/lib/discord/medusa-admin' // ============================================================================ // Types — the order shape this module owns @@ -90,6 +92,18 @@ async function fetchOrders( } } +/** + * When inspecting, all order reads must resolve the TARGET customer's orders + * via the admin API (Medusa scopes /store/orders to the session's own + * actor_id, so the session token would return the admin's orders). Returns the + * full target order set, or null when not impersonating. + */ +async function fetchImpersonatedOrders(): Promise { + const impersonation = await getImpersonation() + if (!impersonation) return null + return listAdminCustomerOrders(impersonation.targetId) +} + // ============================================================================ // Public interface // ============================================================================ @@ -99,6 +113,9 @@ export async function getOrdersPage( limit: number = 10, offset: number = 0 ): Promise { + const impersonated = await fetchImpersonatedOrders() + if (impersonated) return impersonated.slice(offset, offset + limit) + const token = await getAuthToken() if (!token) return [] @@ -122,6 +139,9 @@ export async function getAllOrders( batchSize: number = DEFAULT_BATCH_SIZE, maxBatches: number = DEFAULT_MAX_BATCHES ): Promise { + const impersonated = await fetchImpersonatedOrders() + if (impersonated) return impersonated + const token = await getAuthToken() if (!token) return [] @@ -168,6 +188,9 @@ export async function getAllOrders( export async function getOrderById( orderId: string ): Promise { + const impersonated = await fetchImpersonatedOrders() + if (impersonated) return impersonated.find((o) => o.id === orderId) ?? null + const token = await getAuthToken() if (!token) return null diff --git a/src/lib/medusa-auth.test.ts b/src/lib/medusa-auth.test.ts index 0feaf353..b99b75f1 100644 --- a/src/lib/medusa-auth.test.ts +++ b/src/lib/medusa-auth.test.ts @@ -47,6 +47,22 @@ vi.mock('next/headers', () => ({ cookies: vi.fn(async () => mockCookieStore), })) +// Impersonation seam (Task 6): getCustomer branches on getImpersonation(). +// Default null so every existing session-path test below is unaffected; +// the impersonation describe overrides it per-case. +const { mockGetImpersonation, mockGetAdminCustomerById } = vi.hoisted(() => ({ + mockGetImpersonation: vi.fn(), + mockGetAdminCustomerById: vi.fn(), +})) + +vi.mock('@/lib/impersonation', () => ({ + getImpersonation: () => mockGetImpersonation(), +})) + +vi.mock('@/lib/discord/medusa-admin', () => ({ + getAdminCustomerById: (id: string) => mockGetAdminCustomerById(id), +})) + // Mock fetch globally const mockFetch = vi.fn() global.fetch = mockFetch @@ -77,6 +93,9 @@ import { describe('medusa-auth', () => { beforeEach(() => { vi.clearAllMocks() + // Not impersonating by default: getCustomer falls through to the session + // customer, so every getCustomer test below exercises the session path. + mockGetImpersonation.mockResolvedValue(null) }) describe('login', () => { @@ -416,6 +435,24 @@ describe('medusa-auth', () => { expect(customer).toBeNull() }) + + it('returns the TARGET customer via the admin API when impersonating', async () => { + mockGetImpersonation.mockResolvedValue({ + adminEmail: 'p@k.com', + targetId: 'cus_t', + }) + mockGetAdminCustomerById.mockResolvedValue({ + id: 'cus_t', + email: 't@x.com', + }) + + const customer = await getCustomer() + + expect(customer?.id).toBe('cus_t') + expect(mockGetAdminCustomerById).toHaveBeenCalledWith('cus_t') + // The session cookie path must NOT be consulted when inspecting. + expect(mockFetch).not.toHaveBeenCalled() + }) }) describe('getAuthToken', () => { diff --git a/src/lib/medusa-auth.ts b/src/lib/medusa-auth.ts index b4095042..697e51c7 100644 --- a/src/lib/medusa-auth.ts +++ b/src/lib/medusa-auth.ts @@ -13,6 +13,8 @@ import { InvalidCredentialsError, InvalidResetTokenError, } from '@/lib/api/errors' +import { getImpersonation } from '@/lib/impersonation' +import { getAdminCustomerById } from '@/lib/discord/medusa-admin' // ============================================================================ // Types @@ -356,10 +358,18 @@ export async function getSessionCustomer(): Promise { } } -// Memoized per request with React `cache()`. Task 6 adds the impersonation -// branch; for now it is the session customer. +// Memoized per request. When inspecting (admin + account scope + cookie), +// returns the TARGET customer via the admin API; otherwise the session +// customer. Every account page/read flows through here, so the whole area +// renders as the target. export const getCustomer = cache( - async (): Promise => getSessionCustomer() + async (): Promise => { + const impersonation = await getImpersonation() + if (impersonation) { + return getAdminCustomerById(impersonation.targetId) + } + return getSessionCustomer() + } ) /** From 9154955250cc59dbf5d28c655d0c3dcb891ddf71 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:49:07 +0200 Subject: [PATCH 07/16] feat(impersonation): block mutating account routes during inspection --- .../discord/members/[memberId]/route.test.ts | 5 ++++ .../discord/members/[memberId]/route.ts | 13 ++++++++++ .../machines/[machineId]/route.test.ts | 5 ++++ .../[licenseId]/machines/[machineId]/route.ts | 13 ++++++++++ src/app/api/account/profile/route.test.ts | 24 +++++++++++++++++- src/app/api/account/profile/route.ts | 13 ++++++++++ src/app/api/store/cart/complete/route.test.ts | 5 ++++ src/app/api/store/cart/complete/route.ts | 13 ++++++++++ .../api/store/cart/line-items/route.test.ts | 5 ++++ src/app/api/store/cart/line-items/route.ts | 13 ++++++++++ .../store/cart/payment-sessions/route.test.ts | 5 ++++ .../api/store/cart/payment-sessions/route.ts | 13 ++++++++++ src/app/api/store/cart/route.test.ts | 5 ++++ src/app/api/store/cart/route.ts | 25 +++++++++++++++++++ 14 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts b/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts index 6916c7ef..c8238f18 100644 --- a/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts +++ b/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts @@ -8,6 +8,11 @@ const mockUpdateLicenseMetadata = vi.fn() const mockSyncDiscordProRoleForMember = vi.fn() const mockCreateDiscordRoleSyncDependencies = vi.fn() +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly: async () => {}, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) + vi.mock('@/lib/customer-licenses', () => ({ getResolvedCustomerLicenses: (...args: unknown[]) => mockGetResolvedCustomerLicenses(...args), })) diff --git a/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.ts b/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.ts index 60705b81..12f068e3 100644 --- a/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.ts +++ b/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.ts @@ -10,6 +10,7 @@ import { syncDiscordProRoleForMember } from '@/lib/discord/sync' import type { LicenseLifecycle } from '@/lib/license' import { licenseClient } from '@/services/core/external/license-client' import { infraLogger } from '@/lib/logger' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' const UNVERIFIABLE_DISCORD_ENTITLEMENT: LicenseLifecycle[] = [ { status: 'unknown', expiry: null }, @@ -19,6 +20,18 @@ export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ licenseId: string; memberId: string }> } ) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + const { licenseId, memberId } = await params const { authenticated, licenses } = await getResolvedCustomerLicenses() diff --git a/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.test.ts b/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.test.ts index 6858f3c7..c638229e 100644 --- a/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.test.ts +++ b/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.test.ts @@ -7,6 +7,11 @@ const mockExtractLicenseIdsFromOrders = vi.fn() const mockGetLicenseMachines = vi.fn() const mockDeactivateMachine = vi.fn() +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly: async () => {}, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) + vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), })) diff --git a/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.ts b/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.ts index 8ad53631..e7eee5e2 100644 --- a/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.ts +++ b/src/app/api/account/licenses/[licenseId]/machines/[machineId]/route.ts @@ -5,6 +5,7 @@ import { getCustomer } from '@/lib/medusa-auth' import { extractLicenseIdsFromOrders } from '@/lib/licenses' import { licenseClient } from '@/services/core/external/license-client' import { licenseLogger } from '@/lib/logger' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' /** * Machine Deactivation API @@ -21,6 +22,18 @@ export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ licenseId: string; machineId: string }> } ) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + try { const { licenseId, machineId } = await params diff --git a/src/app/api/account/profile/route.test.ts b/src/app/api/account/profile/route.test.ts index 9ce7937c..52785332 100644 --- a/src/app/api/account/profile/route.test.ts +++ b/src/app/api/account/profile/route.test.ts @@ -3,10 +3,15 @@ import { NextRequest } from 'next/server' const mockGetCustomer = vi.fn() const mockUpdateCustomer = vi.fn() -const { errorMock } = vi.hoisted(() => ({ +const { errorMock, assertViewOnly } = vi.hoisted(() => ({ errorMock: vi.fn(), + assertViewOnly: vi.fn(), })) +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), updateCustomer: (...args: unknown[]) => mockUpdateCustomer(...args), @@ -22,6 +27,23 @@ describe('PATCH /api/account/profile', () => { vi.clearAllMocks() }) + it('returns 403 read_only_inspection while impersonating', async () => { + const { ViewOnlyError } = await import('@/lib/impersonation') + assertViewOnly.mockRejectedValueOnce(new ViewOnlyError('read only')) + + const response = await PATCH( + new NextRequest('http://localhost:3000/api/account/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }) + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'read_only_inspection' }) + expect(mockGetCustomer).not.toHaveBeenCalled() + }) + it('returns 401 when not authenticated', async () => { mockGetCustomer.mockResolvedValueOnce(null) diff --git a/src/app/api/account/profile/route.ts b/src/app/api/account/profile/route.ts index 6d0951b5..6be464a8 100644 --- a/src/app/api/account/profile/route.ts +++ b/src/app/api/account/profile/route.ts @@ -5,6 +5,7 @@ import { type UpdateCustomerInput, } from '@/lib/medusa-auth' import { apiLogger } from '@/lib/logger' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' import { mergeAccountProfileMetadataPatch, projectProfileMetadataForClient, @@ -27,6 +28,18 @@ function normalizeField(value: unknown): string | undefined { } export async function PATCH(request: NextRequest) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + const currentCustomer = await getCustomer() if (!currentCustomer) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/src/app/api/store/cart/complete/route.test.ts b/src/app/api/store/cart/complete/route.test.ts index a960d58d..6ca63f21 100644 --- a/src/app/api/store/cart/complete/route.test.ts +++ b/src/app/api/store/cart/complete/route.test.ts @@ -10,6 +10,11 @@ const mockGetAnalyticsConfig = vi.fn() const mockCookieGet = vi.fn() const mockGetProOfferCatalog = vi.fn() +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly: async () => {}, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) + vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), })) diff --git a/src/app/api/store/cart/complete/route.ts b/src/app/api/store/cart/complete/route.ts index 41babcbf..8c225612 100644 --- a/src/app/api/store/cart/complete/route.ts +++ b/src/app/api/store/cart/complete/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { completeCart, getCart } from '@/services/core/external/medusa-client' import { getCustomer } from '@/lib/medusa-auth' import { storeLogger } from '@/lib/logger' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' import { resolveProCheckoutVariant, trackServerEvent, @@ -21,6 +22,18 @@ import { ORDER_PENDING_CODE } from '@/lib/checkout-failure-taxonomy' * POST /api/store/cart/complete - Complete cart and create order */ export async function POST(request: NextRequest) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + try { const customer = await getCustomer() if (!customer) { diff --git a/src/app/api/store/cart/line-items/route.test.ts b/src/app/api/store/cart/line-items/route.test.ts index 4e779653..cd2a4565 100644 --- a/src/app/api/store/cart/line-items/route.test.ts +++ b/src/app/api/store/cart/line-items/route.test.ts @@ -6,6 +6,11 @@ const mockAddLineItem = vi.fn() const mockGetCart = vi.fn() const mockGetProOfferCatalog = vi.fn() +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly: async () => {}, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) + vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), })) diff --git a/src/app/api/store/cart/line-items/route.ts b/src/app/api/store/cart/line-items/route.ts index 17bdbc30..3727b16d 100644 --- a/src/app/api/store/cart/line-items/route.ts +++ b/src/app/api/store/cart/line-items/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { addLineItem, getCart } from '@/services/core/external/medusa-client' import { getCustomer } from '@/lib/medusa-auth' import { storeLogger } from '@/lib/logger' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' import { getProOfferCatalog, resolveProOfferCheckoutSelection, @@ -12,6 +13,18 @@ import { * POST /api/store/cart/line-items - Add item to cart */ export async function POST(request: NextRequest) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + try { const customer = await getCustomer() if (!customer) { diff --git a/src/app/api/store/cart/payment-sessions/route.test.ts b/src/app/api/store/cart/payment-sessions/route.test.ts index 43122ca4..a8c7287b 100644 --- a/src/app/api/store/cart/payment-sessions/route.test.ts +++ b/src/app/api/store/cart/payment-sessions/route.test.ts @@ -7,6 +7,11 @@ const mockCreatePaymentSession = vi.fn() const mockGetCart = vi.fn() const mockGetProOfferCatalog = vi.fn() +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly: async () => {}, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) + vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), })) diff --git a/src/app/api/store/cart/payment-sessions/route.ts b/src/app/api/store/cart/payment-sessions/route.ts index e7f4f739..f0036b42 100644 --- a/src/app/api/store/cart/payment-sessions/route.ts +++ b/src/app/api/store/cart/payment-sessions/route.ts @@ -6,6 +6,7 @@ import { } from '@/services/core/external/medusa-client' import { getCustomer } from '@/lib/medusa-auth' import { storeLogger } from '@/lib/logger' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' import { getProOfferCatalog, resolveProOfferCartSelection, @@ -22,6 +23,18 @@ import { * Returns: { cart, paymentCollectionId, clientSecret, paymentSessionId } */ export async function POST(request: NextRequest) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + try { const customer = await getCustomer() if (!customer) { diff --git a/src/app/api/store/cart/route.test.ts b/src/app/api/store/cart/route.test.ts index 96e396ac..37a4c5d0 100644 --- a/src/app/api/store/cart/route.test.ts +++ b/src/app/api/store/cart/route.test.ts @@ -7,6 +7,11 @@ const mockCreateCart = vi.fn() const mockGetCart = vi.fn() const mockUpdateCart = vi.fn() +vi.mock('@/lib/impersonation', () => ({ + assertViewOnly: async () => {}, + ViewOnlyError: class ViewOnlyError extends Error {}, +})) + vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), updateCustomer: (...args: unknown[]) => mockUpdateCustomer(...args), diff --git a/src/app/api/store/cart/route.ts b/src/app/api/store/cart/route.ts index ba2b42fb..26f9397d 100644 --- a/src/app/api/store/cart/route.ts +++ b/src/app/api/store/cart/route.ts @@ -13,6 +13,7 @@ import { storeLogger } from '@/lib/logger' import { mergeAccountProfileMetadataPatch } from '@/lib/customer-profile-metadata' import { profilePatchFromBillingAddress } from '@/lib/billing-profile' import { deliver } from '@/lib/sinks/deliver' +import { assertViewOnly, ViewOnlyError } from '@/lib/impersonation' import type { CreateCartInput } from '@/types/medusa' function isRecord(value: unknown): value is Record { @@ -48,6 +49,18 @@ async function syncBillingToProfile( * POST /api/store/cart - Create a new cart */ export async function POST(request: NextRequest) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + try { const customer = await getCustomer() if (!customer) { @@ -139,6 +152,18 @@ export async function GET(request: NextRequest) { * session customer). */ export async function PATCH(request: NextRequest) { + try { + await assertViewOnly() + } catch (error) { + if (error instanceof ViewOnlyError) { + return NextResponse.json( + { error: 'read_only_inspection' }, + { status: 403 } + ) + } + throw error + } + try { const customer = await getCustomer() if (!customer) { From fce463f02ac04da382b3933d8e9767fe558dd77b Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:51:25 +0200 Subject: [PATCH 08/16] feat(impersonation): owner-only /account/admin entry + start action --- .../[locale]/account/admin/actions.test.ts | 57 +++++++++++++++++++ src/app/[locale]/account/admin/actions.ts | 52 +++++++++++++++++ src/app/[locale]/account/admin/page.tsx | 45 +++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/app/[locale]/account/admin/actions.test.ts create mode 100644 src/app/[locale]/account/admin/actions.ts create mode 100644 src/app/[locale]/account/admin/page.tsx diff --git a/src/app/[locale]/account/admin/actions.test.ts b/src/app/[locale]/account/admin/actions.test.ts new file mode 100644 index 00000000..fd4ff00f --- /dev/null +++ b/src/app/[locale]/account/admin/actions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { getSessionCustomer, findAdminCustomerByEmail, startImpersonation, redirect } = + vi.hoisted(() => ({ + getSessionCustomer: vi.fn(), + findAdminCustomerByEmail: vi.fn(), + startImpersonation: vi.fn(), + redirect: vi.fn(() => { + throw new Error('REDIRECT') + }), + })) + +vi.mock('@/lib/medusa-auth', () => ({ getSessionCustomer })) +vi.mock('@/lib/admin', () => ({ + isAdmin: (e?: string | null) => e === 'paul@kilbot.com', +})) +vi.mock('@/lib/discord/medusa-admin', () => ({ findAdminCustomerByEmail })) +vi.mock('@/lib/impersonation', () => ({ startImpersonation })) +vi.mock('@/lib/rate-limit', () => ({ + createRateLimiter: () => ({ consume: async () => ({ success: true, remaining: 9 }) }), + clientIp: () => 'ip', +})) +vi.mock('@/lib/logger', () => ({ authLogger: { info: vi.fn(), warn: vi.fn() } })) +vi.mock('next/navigation', () => ({ redirect })) +vi.mock('next/headers', () => ({ headers: async () => ({ get: () => 'ip' }) })) + +import { startImpersonationAction } from './actions' + +beforeEach(() => { + getSessionCustomer.mockReset() + findAdminCustomerByEmail.mockReset() + startImpersonation.mockReset() +}) + +describe('startImpersonationAction', () => { + it('rejects a non-admin session', async () => { + getSessionCustomer.mockResolvedValue({ email: 'nope@x.com' }) + const result = await startImpersonationAction({ email: 't@x.com' }) + expect(result).toEqual({ error: 'forbidden' }) + expect(startImpersonation).not.toHaveBeenCalled() + }) + + it('reports when the target email is not found', async () => { + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + findAdminCustomerByEmail.mockResolvedValue(null) + const result = await startImpersonationAction({ email: 'ghost@x.com' }) + expect(result).toEqual({ error: 'not_found' }) + expect(startImpersonation).not.toHaveBeenCalled() + }) + + it('starts impersonation + redirects when admin targets a real customer', async () => { + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + findAdminCustomerByEmail.mockResolvedValue({ id: 'cus_t', email: 't@x.com' }) + await expect(startImpersonationAction({ email: 't@x.com' })).rejects.toThrow('REDIRECT') + expect(startImpersonation).toHaveBeenCalledWith('cus_t') + }) +}) diff --git a/src/app/[locale]/account/admin/actions.ts b/src/app/[locale]/account/admin/actions.ts new file mode 100644 index 00000000..996858f5 --- /dev/null +++ b/src/app/[locale]/account/admin/actions.ts @@ -0,0 +1,52 @@ +'use server' + +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { getSessionCustomer } from '@/lib/medusa-auth' +import { isAdmin } from '@/lib/admin' +import { findAdminCustomerByEmail } from '@/lib/discord/medusa-admin' +import { startImpersonation } from '@/lib/impersonation' +import { createRateLimiter, clientIp } from '@/lib/rate-limit' +import { authLogger } from '@/lib/logger' + +const limiter = createRateLimiter({ + prefix: 'impersonate-lookup', + limit: 10, + window: '10 m', +}) + +export type StartImpersonationResult = + | { error: 'forbidden' } + | { error: 'not_found' } + | { error: 'rate_limited' } + +/** + * Owner-only. Verifies the REAL session is an admin, rate-limits by IP (email + * enumeration guard), looks up the target by email, then starts impersonation + * and redirects to /account. Returns an error shape on failure (redirect throws + * on success, so the happy path never returns). + */ +export async function startImpersonationAction(input: { + email: string +}): Promise { + const session = await getSessionCustomer() + if (!isAdmin(session?.email)) { + authLogger.warn`Non-admin impersonation attempt by ${session?.email ?? 'anon'}` + return { error: 'forbidden' } + } + + const ip = clientIp(new Request('http://local', { headers: await headers() })) + const { success } = await limiter.consume(ip) + if (!success) return { error: 'rate_limited' } + + const email = input.email.trim().toLowerCase() + const target = await findAdminCustomerByEmail(email) + if (!target) { + authLogger.info`Impersonation lookup miss: admin=${session!.email} target=${email}` + return { error: 'not_found' } + } + + authLogger.info`Impersonation START: admin=${session!.email} target=${target.email} (${target.id})` + await startImpersonation(target.id) + redirect('/account') +} diff --git a/src/app/[locale]/account/admin/page.tsx b/src/app/[locale]/account/admin/page.tsx new file mode 100644 index 00000000..f34d8235 --- /dev/null +++ b/src/app/[locale]/account/admin/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from 'next/navigation' +import { getSessionCustomer } from '@/lib/medusa-auth' +import { isAdmin } from '@/lib/admin' +import { startImpersonationAction } from './actions' + +export const metadata = { robots: { index: false, follow: false } } + +export default async function AdminInspectPage() { + // Gate on the REAL session — never getCustomer (which could be a target). + const session = await getSessionCustomer() + if (!isAdmin(session?.email)) notFound() + + async function submit(formData: FormData) { + 'use server' + const email = String(formData.get('email') ?? '') + await startImpersonationAction({ email }) + // On success the action redirects; a returned error re-renders this page. + // (For inline error messaging, upgrade to useActionState in a follow-up.) + } + + return ( +
+

Inspect a customer (read-only)

+

+ Enter a customer email to view their account exactly as they see it. You + cannot change their data while inspecting. +

+
+ + +
+
+ ) +} From c43d2078491a2c2b145dd63f43b2c215be596536 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:53:10 +0200 Subject: [PATCH 09/16] feat(impersonation): read-only banner + exit route --- src/app/[locale]/account/layout.tsx | 4 +++ src/app/api/account/impersonate/exit/route.ts | 11 +++++++ .../account/impersonation-banner.tsx | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/app/api/account/impersonate/exit/route.ts create mode 100644 src/components/account/impersonation-banner.tsx diff --git a/src/app/[locale]/account/layout.tsx b/src/app/[locale]/account/layout.tsx index aefceb2e..21796f5b 100644 --- a/src/app/[locale]/account/layout.tsx +++ b/src/app/[locale]/account/layout.tsx @@ -3,6 +3,7 @@ import { Suspense } from 'react' import { getCustomer } from '@/lib/medusa-auth' import { redirectToLoginClearingSession } from '@/lib/login-redirect' import { AccountSidebar } from '@/components/account/account-sidebar' +import { ImpersonationBanner } from '@/components/account/impersonation-banner' import { SiteHeader } from '@/components/main/site-header' import { SiteFooter } from '@/components/main/site-footer' import { Skeleton } from '@/components/ui/skeleton' @@ -75,6 +76,9 @@ export default async function AccountLayout({ }> + + + diff --git a/src/app/api/account/impersonate/exit/route.ts b/src/app/api/account/impersonate/exit/route.ts new file mode 100644 index 00000000..ac07ee72 --- /dev/null +++ b/src/app/api/account/impersonate/exit/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server' +import { stopImpersonation } from '@/lib/impersonation' +import { getSessionCustomer } from '@/lib/medusa-auth' +import { authLogger } from '@/lib/logger' + +export async function POST(request: Request) { + const session = await getSessionCustomer() + await stopImpersonation() + authLogger.info`Impersonation STOP: admin=${session?.email ?? 'unknown'}` + return NextResponse.redirect(new URL('/account', request.url), { status: 303 }) +} diff --git a/src/components/account/impersonation-banner.tsx b/src/components/account/impersonation-banner.tsx new file mode 100644 index 00000000..6aa9fd48 --- /dev/null +++ b/src/components/account/impersonation-banner.tsx @@ -0,0 +1,29 @@ +import { getImpersonation } from '@/lib/impersonation' +import { getCustomer } from '@/lib/medusa-auth' + +/** + * Shown across the whole account area while inspecting. `getCustomer()` here is + * the TARGET (impersonation is active), so its email is the inspected account. + */ +export async function ImpersonationBanner() { + const impersonation = await getImpersonation() + if (!impersonation) return null + const target = await getCustomer() + + return ( +
+ + 🔍 Viewing {target?.email ?? impersonation.targetId} as{' '} + read-only — you cannot change their account. + +
+ +
+
+ ) +} From daebe42c8cb0af912481cd4725bc33420c48c2be Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:54:01 +0200 Subject: [PATCH 10/16] fix(test): ViewOnlyError takes no constructor args --- src/app/api/account/profile/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/account/profile/route.test.ts b/src/app/api/account/profile/route.test.ts index 52785332..95cd169c 100644 --- a/src/app/api/account/profile/route.test.ts +++ b/src/app/api/account/profile/route.test.ts @@ -29,7 +29,7 @@ describe('PATCH /api/account/profile', () => { it('returns 403 read_only_inspection while impersonating', async () => { const { ViewOnlyError } = await import('@/lib/impersonation') - assertViewOnly.mockRejectedValueOnce(new ViewOnlyError('read only')) + assertViewOnly.mockRejectedValueOnce(new ViewOnlyError()) const response = await PATCH( new NextRequest('http://localhost:3000/api/account/profile', { From 0d6f7fd68de53d2b0b4b05dcf067e38a601e79f9 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 21:56:26 +0200 Subject: [PATCH 11/16] test(impersonation): end-to-end lib-layer resolve + orders --- src/lib/impersonation.integration.test.ts | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/lib/impersonation.integration.test.ts diff --git a/src/lib/impersonation.integration.test.ts b/src/lib/impersonation.integration.test.ts new file mode 100644 index 00000000..4f77811d --- /dev/null +++ b/src/lib/impersonation.integration.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('server-only', () => ({})) + +// Simulate: account header present, admin session, cookie → target. +// Hoisted so the mock factories below can close over shared state without +// tripping vitest's mock-hoisting (bare consts referenced in a factory throw). +const { state } = vi.hoisted(() => ({ + state: { header: '1' as string | null, cookie: 'cus_target' as string | null }, +})) + +vi.mock('next/headers', () => ({ + headers: async () => ({ + get: (n: string) => + n === 'x-wcpos-account-request' ? state.header : null, + }), + cookies: async () => ({ + get: (n: string) => + n === 'wcpos-impersonate' && state.cookie + ? { value: state.cookie } + : undefined, + set: vi.fn(), + delete: vi.fn(() => { + state.cookie = null + }), + }), +})) +vi.mock('@/lib/admin', () => ({ + isAdmin: (e?: string | null) => e === 'paul@kilbot.com', +})) +vi.mock('@/lib/medusa-auth', () => ({ + getSessionCustomer: async () => ({ email: 'paul@kilbot.com' }), +})) +vi.mock('@/lib/discord/medusa-admin', () => ({ + getAdminCustomerById: async (id: string) => ({ id, email: 'target@x.com' }), + listAdminCustomerOrders: async () => [ + { id: 'ord_1', display_id: 1, metadata: { license_key: 'KEY-1' } }, + ], +})) + +import { getImpersonation } from './impersonation' +import { getAllOrders } from './customer-orders' + +beforeEach(() => { + state.header = '1' + state.cookie = 'cus_target' +}) + +describe('impersonation end-to-end (lib layer)', () => { + it('resolves the target and returns the target orders', async () => { + expect(await getImpersonation()).toEqual({ + adminEmail: 'paul@kilbot.com', + targetId: 'cus_target', + }) + const orders = await getAllOrders() + expect(orders[0].id).toBe('ord_1') + }) + + it('is inert outside the account area', async () => { + state.header = null + expect(await getImpersonation()).toBeNull() + }) +}) From 213d71ee93537fcf154e5e6e8d9f0fc95fc38696 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 22:05:22 +0200 Subject: [PATCH 12/16] fix(impersonation): strip spoofable scope header, session-safe OAuth callback, stale-target auto-exit --- src/app/[locale]/account/layout.tsx | 10 +++ src/app/api/account/impersonate/exit/route.ts | 14 +++- .../auth/[provider]/callback/route.test.ts | 8 +- src/app/api/auth/[provider]/callback/route.ts | 9 ++- src/middleware.test.ts | 73 ++++++++++++++++++- src/middleware.ts | 19 ++++- 6 files changed, 119 insertions(+), 14 deletions(-) diff --git a/src/app/[locale]/account/layout.tsx b/src/app/[locale]/account/layout.tsx index 21796f5b..9b3ce12c 100644 --- a/src/app/[locale]/account/layout.tsx +++ b/src/app/[locale]/account/layout.tsx @@ -1,6 +1,8 @@ import { setRequestLocale } from 'next-intl/server' +import { redirect } from 'next/navigation' import { Suspense } from 'react' import { getCustomer } from '@/lib/medusa-auth' +import { getImpersonation } from '@/lib/impersonation' import { redirectToLoginClearingSession } from '@/lib/login-redirect' import { AccountSidebar } from '@/components/account/account-sidebar' import { ImpersonationBanner } from '@/components/account/impersonation-banner' @@ -20,6 +22,14 @@ export const metadata: Metadata = { async function AccountGate({ locale }: { locale: string }) { const customer = await getCustomer() if (!customer) { + // A null customer while impersonating means the target no longer resolves + // (stale/deleted). Don't nuke the admin's own session — bounce to the exit + // route (a route handler CAN delete the cookie) so the owner lands back on + // their own /account. Only a genuinely signed-out visitor gets the + // session-clearing login redirect. + if (await getImpersonation()) { + redirect('/api/account/impersonate/exit') + } return redirectToLoginClearingSession(locale) } return null diff --git a/src/app/api/account/impersonate/exit/route.ts b/src/app/api/account/impersonate/exit/route.ts index ac07ee72..d5b67982 100644 --- a/src/app/api/account/impersonate/exit/route.ts +++ b/src/app/api/account/impersonate/exit/route.ts @@ -3,9 +3,21 @@ import { stopImpersonation } from '@/lib/impersonation' import { getSessionCustomer } from '@/lib/medusa-auth' import { authLogger } from '@/lib/logger' -export async function POST(request: Request) { +async function exit(request: Request) { const session = await getSessionCustomer() await stopImpersonation() authLogger.info`Impersonation STOP: admin=${session?.email ?? 'unknown'}` return NextResponse.redirect(new URL('/account', request.url), { status: 303 }) } + +export async function POST(request: Request) { + return exit(request) +} + +// GET variant so a server-component render (which cannot delete cookies) can +// bounce a stale/deleted impersonation target here to clear the cookie and +// land the owner back on their own account. See AccountGate in +// src/app/[locale]/account/layout.tsx. +export async function GET(request: Request) { + return exit(request) +} diff --git a/src/app/api/auth/[provider]/callback/route.test.ts b/src/app/api/auth/[provider]/callback/route.test.ts index cd43b814..5ac47d17 100644 --- a/src/app/api/auth/[provider]/callback/route.test.ts +++ b/src/app/api/auth/[provider]/callback/route.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/logger', () => ({ // responsibilities: provider validation, param forwarding, profile sync, and // the redirect. The ordering invariant is pinned in oauth.test.ts. const mockEstablishOAuthSession = vi.fn() -const mockGetCustomer = vi.fn() +const mockGetSessionCustomer = vi.fn() const mockUpdateCustomer = vi.fn() vi.mock('@/lib/oauth', () => ({ @@ -32,7 +32,7 @@ vi.mock('@/lib/oauth', () => ({ })) vi.mock('@/lib/medusa-auth', () => ({ - getCustomer: (...args: unknown[]) => mockGetCustomer(...args), + getSessionCustomer: (...args: unknown[]) => mockGetSessionCustomer(...args), updateCustomer: (...args: unknown[]) => mockUpdateCustomer(...args), })) @@ -58,7 +58,7 @@ function session( describe('OAuth callback route', () => { beforeEach(() => { vi.clearAllMocks() - mockGetCustomer.mockResolvedValue({ id: 'cust_1', metadata: {} }) + mockGetSessionCustomer.mockResolvedValue({ id: 'cust_1', metadata: {} }) mockUpdateCustomer.mockResolvedValue(undefined) }) @@ -156,7 +156,7 @@ describe('OAuth callback route', () => { }) it('does not re-write metadata when the provider is already the latest and the avatar is unchanged', async () => { - mockGetCustomer.mockResolvedValueOnce({ + mockGetSessionCustomer.mockResolvedValueOnce({ id: 'cust_1', metadata: { auth_providers: ['google'], last_sign_in_provider: 'google' }, }) diff --git a/src/app/api/auth/[provider]/callback/route.ts b/src/app/api/auth/[provider]/callback/route.ts index 9776f627..97547b7f 100644 --- a/src/app/api/auth/[provider]/callback/route.ts +++ b/src/app/api/auth/[provider]/callback/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { getCustomer, updateCustomer } from '@/lib/medusa-auth' +import { getSessionCustomer, updateCustomer } from '@/lib/medusa-auth' import { establishOAuthSession } from '@/lib/oauth' import { authLogger } from '@/lib/logger' import { getConnectedAvatarUrlFromUserMetadata } from '@/lib/avatar' @@ -29,7 +29,9 @@ async function syncOauthProfile( userMetadata: Record ) { try { - const customer = await getCustomer() + // Use the real session identity, never an active impersonation target, so + // OAuth profile sync always writes to the signed-in owner's own customer. + const customer = await getSessionCustomer() if (!customer) return const metadata = isRecord(customer.metadata) ? customer.metadata : {} @@ -94,7 +96,8 @@ export async function GET( // establishOAuthSession owns the link-then-refresh-then-persist ordering // and writes the session cookie; the route only drives profile sync and // the redirect. Profile sync runs after the session exists (it reads the - // cookie via getCustomer) and is best-effort — it must not block sign-in. + // session identity via getSessionCustomer) and is best-effort — it must + // not block sign-in. const { payload } = await establishOAuthSession(provider, callbackParams) await syncOauthProfile(provider, payload.user_metadata) diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 0f91b6fa..60997489 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,12 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { NextRequest, NextResponse } from 'next/server' import { ANALYTICS_DISTINCT_ID_COOKIE } from '@/lib/analytics/distinct-id' import { ANALYTICS_CONSENT_COOKIE } from '@/lib/analytics/consent' +// Capture the request next-intl receives so tests can inspect the headers the +// middleware forwards to the RSC render path. +const intlRequests: NextRequest[] = [] vi.mock('next-intl/middleware', () => ({ - default: () => () => NextResponse.next(), + default: () => (request: NextRequest) => { + intlRequests.push(request) + return NextResponse.next() + }, })) +const ACCOUNT_REQUEST_HEADER = 'x-wcpos-account-request' + import { middleware } from './middleware' describe('middleware', () => { @@ -94,6 +102,67 @@ describe('middleware', () => { expect(response?.cookies.get(ANALYTICS_DISTINCT_ID_COOKIE)).toBeUndefined() }) + describe('account-request header sanitization', () => { + beforeEach(() => { + intlRequests.length = 0 + }) + + it('strips a client-supplied account-request header on non-account pages', () => { + const request = new NextRequest('https://wcpos.com/', { + headers: { [ACCOUNT_REQUEST_HEADER]: '1' }, + }) + + middleware(request) + + // next-intl received the render request; the spoofed header is gone. + expect(intlRequests).toHaveLength(1) + expect(intlRequests[0].headers.get(ACCOUNT_REQUEST_HEADER)).toBeNull() + }) + + it('sets the account-request header on /account pages', () => { + const request = new NextRequest('https://wcpos.com/account', { + headers: { + cookie: 'medusa-token=test-token', + // Even a spoofed inbound value must be replaced (not trusted) with '1'. + [ACCOUNT_REQUEST_HEADER]: 'spoofed', + }, + }) + + middleware(request) + + expect(intlRequests).toHaveLength(1) + expect(intlRequests[0].headers.get(ACCOUNT_REQUEST_HEADER)).toBe('1') + }) + + it('strips a client-supplied account-request header on non-account API routes', () => { + const request = new NextRequest('https://wcpos.com/api/legacy/wc-am', { + headers: { [ACCOUNT_REQUEST_HEADER]: '1' }, + }) + + const response = middleware(request) + + // The forwarded request headers (NextResponse.next({ request })) carry the + // overwrite directive; the account-request header must not be among them. + const overwrite = response?.headers.get('x-middleware-override-headers') + expect(overwrite ?? '').not.toContain(ACCOUNT_REQUEST_HEADER) + expect( + response?.headers.get(`x-middleware-request-${ACCOUNT_REQUEST_HEADER}`) + ).toBeNull() + }) + + it('sets the account-request header on /api/account API routes', () => { + const request = new NextRequest('https://wcpos.com/api/account/impersonate', { + headers: { [ACCOUNT_REQUEST_HEADER]: 'spoofed' }, + }) + + const response = middleware(request) + + expect( + response?.headers.get(`x-middleware-request-${ACCOUNT_REQUEST_HEADER}`) + ).toBe('1') + }) + }) + describe('analytics consent gating', () => { it('does not set a distinct-id cookie when no consent decision exists', () => { const request = new NextRequest('https://wcpos.com/') diff --git a/src/middleware.ts b/src/middleware.ts index 6a2d1486..deb37cdb 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -76,6 +76,14 @@ export function middleware(request: NextRequest) { const { pathname } = request.nextUrl const hostname = request.nextUrl.hostname.toLowerCase() + // Only THIS middleware may set the account-request header. Strip any + // client-supplied value up front so a spoofed `x-wcpos-account-request: 1` + // on a non-account route can never reach server code via `headers()` and + // defeat the /account impersonation scoping. Downstream branches thread this + // sanitized copy (setting the header back on only for account paths). + const sanitizedHeaders = new Headers(request.headers) + sanitizedHeaders.delete(ACCOUNT_REQUEST_HEADER) + // Legacy WooCommerce API Manager licence calls from the deployed Pro plugin // fleet still target wcpos.com/?wc-api=am-software-api (activation, etc.). // Bridge them to the Keygen-backed compatibility shim. See @@ -113,11 +121,11 @@ export function middleware(request: NextRequest) { // account-request header so impersonation is scoped to /account. if (pathname.startsWith('/api/')) { if (pathname.startsWith('/api/account/')) { - const headers = new Headers(request.headers) + const headers = new Headers(sanitizedHeaders) headers.set(ACCOUNT_REQUEST_HEADER, '1') return NextResponse.next({ request: { headers } }) } - return NextResponse.next() + return NextResponse.next({ request: { headers: sanitizedHeaders } }) } // Strip locale prefix for auth checks (e.g., /fr/account -> /account) @@ -158,13 +166,16 @@ export function middleware(request: NextRequest) { // `{ request: { headers } }`), so a header set on the request it receives // propagates to the RSC layer via `headers()`. if (pathnameWithoutLocale.startsWith('/account')) { - const headers = new Headers(request.headers) + const headers = new Headers(sanitizedHeaders) headers.set(ACCOUNT_REQUEST_HEADER, '1') const requestWithHeader = new NextRequest(request, { headers }) return withDistinctIdCookie(request, intlMiddleware(requestWithHeader)) } - return withDistinctIdCookie(request, intlMiddleware(request)) + // Non-account render path: forward the sanitized headers (spoofed + // account-request header removed) so `headers()` never sees it off /account. + const sanitizedRequest = new NextRequest(request, { headers: sanitizedHeaders }) + return withDistinctIdCookie(request, intlMiddleware(sanitizedRequest)) } export const config = { From 7e7472c406f6d01d945d867645cd1a7e673e33e0 Mon Sep 17 00:00:00 2001 From: "wcpos-agents[bot]" <2860316+wcpos-agents[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:45:59 +0000 Subject: [PATCH 13/16] fix: address impersonation review feedback --- .../[locale]/account/admin/actions.test.ts | 38 +++++++++--- src/app/[locale]/account/admin/actions.ts | 24 +++++--- src/app/[locale]/account/admin/page.tsx | 9 ++- src/app/[locale]/account/layout.tsx | 2 +- src/app/api/account/impersonate/exit/route.ts | 23 +++++++- .../discord/members/[memberId]/route.test.ts | 19 +++++- src/lib/customer-orders.test.ts | 36 ++++++++--- src/lib/customer-orders.ts | 29 +++++++-- src/lib/discord/medusa-admin.test.ts | 59 ++++++++++++++++++- src/lib/discord/medusa-admin.ts | 37 ++++++++++-- src/lib/impersonation.test.ts | 41 ++++++++++++- src/lib/impersonation.ts | 3 + src/middleware.test.ts | 54 +++++++++++++++++ src/middleware.ts | 19 ++++-- 14 files changed, 345 insertions(+), 48 deletions(-) diff --git a/src/app/[locale]/account/admin/actions.test.ts b/src/app/[locale]/account/admin/actions.test.ts index fd4ff00f..964304cf 100644 --- a/src/app/[locale]/account/admin/actions.test.ts +++ b/src/app/[locale]/account/admin/actions.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -const { getSessionCustomer, findAdminCustomerByEmail, startImpersonation, redirect } = +const { + getSessionCustomer, + findAdminCustomerByEmail, + startImpersonation, + redirect, + consume, +} = vi.hoisted(() => ({ getSessionCustomer: vi.fn(), findAdminCustomerByEmail: vi.fn(), @@ -8,20 +14,21 @@ const { getSessionCustomer, findAdminCustomerByEmail, startImpersonation, redire redirect: vi.fn(() => { throw new Error('REDIRECT') }), + consume: vi.fn(async () => ({ success: true, remaining: 9 })), })) vi.mock('@/lib/medusa-auth', () => ({ getSessionCustomer })) vi.mock('@/lib/admin', () => ({ - isAdmin: (e?: string | null) => e === 'paul@kilbot.com', + isAdmin: (e?: string | null) => e?.trim().toLowerCase() === 'paul@kilbot.com', })) vi.mock('@/lib/discord/medusa-admin', () => ({ findAdminCustomerByEmail })) vi.mock('@/lib/impersonation', () => ({ startImpersonation })) vi.mock('@/lib/rate-limit', () => ({ - createRateLimiter: () => ({ consume: async () => ({ success: true, remaining: 9 }) }), + createRateLimiter: () => ({ consume }), clientIp: () => 'ip', })) vi.mock('@/lib/logger', () => ({ authLogger: { info: vi.fn(), warn: vi.fn() } })) -vi.mock('next/navigation', () => ({ redirect })) +vi.mock('@/i18n/navigation', () => ({ redirect })) vi.mock('next/headers', () => ({ headers: async () => ({ get: () => 'ip' }) })) import { startImpersonationAction } from './actions' @@ -30,20 +37,34 @@ beforeEach(() => { getSessionCustomer.mockReset() findAdminCustomerByEmail.mockReset() startImpersonation.mockReset() + consume.mockReset() + consume.mockResolvedValue({ success: true, remaining: 9 }) }) describe('startImpersonationAction', () => { it('rejects a non-admin session', async () => { getSessionCustomer.mockResolvedValue({ email: 'nope@x.com' }) - const result = await startImpersonationAction({ email: 't@x.com' }) + const result = await startImpersonationAction({ email: 't@x.com', locale: 'en' }) expect(result).toEqual({ error: 'forbidden' }) expect(startImpersonation).not.toHaveBeenCalled() }) + it('rate limits trusted admin identity before looking up the target', async () => { + getSessionCustomer.mockResolvedValue({ email: 'Paul@Kilbot.com ' }) + consume.mockResolvedValueOnce({ success: false, remaining: 0 }) + + const result = await startImpersonationAction({ email: 't@x.com', locale: 'en' }) + + expect(result).toEqual({ error: 'rate_limited' }) + expect(consume).toHaveBeenCalledWith('paul@kilbot.com') + expect(findAdminCustomerByEmail).not.toHaveBeenCalled() + expect(startImpersonation).not.toHaveBeenCalled() + }) + it('reports when the target email is not found', async () => { getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) findAdminCustomerByEmail.mockResolvedValue(null) - const result = await startImpersonationAction({ email: 'ghost@x.com' }) + const result = await startImpersonationAction({ email: 'ghost@x.com', locale: 'en' }) expect(result).toEqual({ error: 'not_found' }) expect(startImpersonation).not.toHaveBeenCalled() }) @@ -51,7 +72,10 @@ describe('startImpersonationAction', () => { it('starts impersonation + redirects when admin targets a real customer', async () => { getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) findAdminCustomerByEmail.mockResolvedValue({ id: 'cus_t', email: 't@x.com' }) - await expect(startImpersonationAction({ email: 't@x.com' })).rejects.toThrow('REDIRECT') + await expect( + startImpersonationAction({ email: 't@x.com', locale: 'fr' }) + ).rejects.toThrow('REDIRECT') expect(startImpersonation).toHaveBeenCalledWith('cus_t') + expect(redirect).toHaveBeenCalledWith({ href: '/account', locale: 'fr' }) }) }) diff --git a/src/app/[locale]/account/admin/actions.ts b/src/app/[locale]/account/admin/actions.ts index 996858f5..7cbdb2d6 100644 --- a/src/app/[locale]/account/admin/actions.ts +++ b/src/app/[locale]/account/admin/actions.ts @@ -1,12 +1,11 @@ 'use server' -import { headers } from 'next/headers' -import { redirect } from 'next/navigation' +import { redirect } from '@/i18n/navigation' import { getSessionCustomer } from '@/lib/medusa-auth' import { isAdmin } from '@/lib/admin' import { findAdminCustomerByEmail } from '@/lib/discord/medusa-admin' import { startImpersonation } from '@/lib/impersonation' -import { createRateLimiter, clientIp } from '@/lib/rate-limit' +import { createRateLimiter } from '@/lib/rate-limit' import { authLogger } from '@/lib/logger' const limiter = createRateLimiter({ @@ -15,6 +14,11 @@ const limiter = createRateLimiter({ window: '10 m', }) +function redirectToAccount(locale: string): never { + redirect({ href: '/account', locale }) + throw new Error('Redirect failed') +} + export type StartImpersonationResult = | { error: 'forbidden' } | { error: 'not_found' } @@ -28,25 +32,27 @@ export type StartImpersonationResult = */ export async function startImpersonationAction(input: { email: string + locale: string }): Promise { const session = await getSessionCustomer() - if (!isAdmin(session?.email)) { + if (!session?.email || !isAdmin(session.email)) { authLogger.warn`Non-admin impersonation attempt by ${session?.email ?? 'anon'}` return { error: 'forbidden' } } + const adminEmail = session.email - const ip = clientIp(new Request('http://local', { headers: await headers() })) - const { success } = await limiter.consume(ip) + const rateLimitKey = adminEmail.trim().toLowerCase() + const { success } = await limiter.consume(rateLimitKey) if (!success) return { error: 'rate_limited' } const email = input.email.trim().toLowerCase() const target = await findAdminCustomerByEmail(email) if (!target) { - authLogger.info`Impersonation lookup miss: admin=${session!.email} target=${email}` + authLogger.info`Impersonation lookup miss: admin=${adminEmail} target=${email}` return { error: 'not_found' } } - authLogger.info`Impersonation START: admin=${session!.email} target=${target.email} (${target.id})` + authLogger.info`Impersonation START: admin=${adminEmail} target=${target.email} (${target.id})` await startImpersonation(target.id) - redirect('/account') + redirectToAccount(input.locale) } diff --git a/src/app/[locale]/account/admin/page.tsx b/src/app/[locale]/account/admin/page.tsx index f34d8235..311ee63b 100644 --- a/src/app/[locale]/account/admin/page.tsx +++ b/src/app/[locale]/account/admin/page.tsx @@ -5,7 +5,12 @@ import { startImpersonationAction } from './actions' export const metadata = { robots: { index: false, follow: false } } -export default async function AdminInspectPage() { +export default async function AdminInspectPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params // Gate on the REAL session — never getCustomer (which could be a target). const session = await getSessionCustomer() if (!isAdmin(session?.email)) notFound() @@ -13,7 +18,7 @@ export default async function AdminInspectPage() { async function submit(formData: FormData) { 'use server' const email = String(formData.get('email') ?? '') - await startImpersonationAction({ email }) + await startImpersonationAction({ email, locale }) // On success the action redirects; a returned error re-renders this page. // (For inline error messaging, upgrade to useActionState in a follow-up.) } diff --git a/src/app/[locale]/account/layout.tsx b/src/app/[locale]/account/layout.tsx index 9b3ce12c..e5b4fad0 100644 --- a/src/app/[locale]/account/layout.tsx +++ b/src/app/[locale]/account/layout.tsx @@ -28,7 +28,7 @@ async function AccountGate({ locale }: { locale: string }) { // their own /account. Only a genuinely signed-out visitor gets the // session-clearing login redirect. if (await getImpersonation()) { - redirect('/api/account/impersonate/exit') + redirect(`/api/account/impersonate/exit?locale=${locale}`) } return redirectToLoginClearingSession(locale) } diff --git a/src/app/api/account/impersonate/exit/route.ts b/src/app/api/account/impersonate/exit/route.ts index d5b67982..da0b8c04 100644 --- a/src/app/api/account/impersonate/exit/route.ts +++ b/src/app/api/account/impersonate/exit/route.ts @@ -2,12 +2,33 @@ import { NextResponse } from 'next/server' import { stopImpersonation } from '@/lib/impersonation' import { getSessionCustomer } from '@/lib/medusa-auth' import { authLogger } from '@/lib/logger' +import { defaultLocale, locales } from '@/i18n/config' + +function accountUrl(request: Request): URL { + const requestUrl = new URL(request.url) + const explicitLocale = requestUrl.searchParams.get('locale') + const referer = request.headers.get('referer') + let refererPathname = '' + try { + refererPathname = referer ? new URL(referer).pathname : '' + } catch { + refererPathname = '' + } + const refererLocale = refererPathname.split('/')[1] + const locale = locales.find( + (candidate) => candidate === explicitLocale || candidate === refererLocale + ) + return new URL( + locale && locale !== defaultLocale ? `/${locale}/account` : '/account', + request.url + ) +} async function exit(request: Request) { const session = await getSessionCustomer() await stopImpersonation() authLogger.info`Impersonation STOP: admin=${session?.email ?? 'unknown'}` - return NextResponse.redirect(new URL('/account', request.url), { status: 303 }) + return NextResponse.redirect(accountUrl(request), { status: 303 }) } export async function POST(request: Request) { diff --git a/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts b/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts index c8238f18..74fdbcf2 100644 --- a/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts +++ b/src/app/api/account/licenses/[licenseId]/discord/members/[memberId]/route.test.ts @@ -7,9 +7,10 @@ const mockGetLicense = vi.fn() const mockUpdateLicenseMetadata = vi.fn() const mockSyncDiscordProRoleForMember = vi.fn() const mockCreateDiscordRoleSyncDependencies = vi.fn() +const mockAssertViewOnly = vi.fn(async () => {}) vi.mock('@/lib/impersonation', () => ({ - assertViewOnly: async () => {}, + assertViewOnly: () => mockAssertViewOnly(), ViewOnlyError: class ViewOnlyError extends Error {}, })) @@ -56,7 +57,21 @@ function callDelete(licenseId = 'lic_1', memberId = 'member_1') { } describe('DELETE /api/account/licenses/[licenseId]/discord/members/[memberId]', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + mockAssertViewOnly.mockResolvedValue(undefined) + }) + + it('returns 403 read_only_inspection while impersonating', async () => { + const { ViewOnlyError } = await import('@/lib/impersonation') + mockAssertViewOnly.mockRejectedValueOnce(new ViewOnlyError()) + + const response = await callDelete() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'read_only_inspection' }) + expect(mockGetResolvedCustomerLicenses).not.toHaveBeenCalled() + }) it('returns 401 when the holder is not authenticated', async () => { mockGetResolvedCustomerLicenses.mockResolvedValueOnce({ authenticated: false, licenses: [] }) diff --git a/src/lib/customer-orders.test.ts b/src/lib/customer-orders.test.ts index 7c6a1c3c..6331f2b9 100644 --- a/src/lib/customer-orders.test.ts +++ b/src/lib/customer-orders.test.ts @@ -40,6 +40,7 @@ const { mockError, mockFetch, mockGetImpersonation, + mockGetAdminCustomerOrderById, mockListAdminCustomerOrders, } = vi.hoisted(() => ({ mockGetAuthToken: vi.fn(), @@ -47,6 +48,7 @@ const { mockError: vi.fn(), mockFetch: vi.fn(), mockGetImpersonation: vi.fn(), + mockGetAdminCustomerOrderById: vi.fn(), mockListAdminCustomerOrders: vi.fn(), })) @@ -63,6 +65,8 @@ vi.mock('@/lib/impersonation', () => ({ })) vi.mock('@/lib/discord/medusa-admin', () => ({ + getAdminCustomerOrderById: (customerId: string, orderId: string) => + mockGetAdminCustomerOrderById(customerId, orderId), listAdminCustomerOrders: (id: string) => mockListAdminCustomerOrders(id), })) @@ -337,27 +341,41 @@ describe('customer-orders', () => { }) it('getOrderById finds the order within the TARGET order set', async () => { - mockListAdminCustomerOrders.mockResolvedValue([ - { id: 'ord_1', display_id: 1 }, - { id: 'ord_2', display_id: 2 }, - ]) + mockGetAdminCustomerOrderById.mockResolvedValue({ id: 'ord_2', display_id: 2 }) const order = await getOrderById('ord_2') - expect(mockListAdminCustomerOrders).toHaveBeenCalledWith('cus_t') + expect(mockGetAdminCustomerOrderById).toHaveBeenCalledWith('cus_t', 'ord_2') expect(order).toEqual({ id: 'ord_2', display_id: 2 }) + expect(mockListAdminCustomerOrders).not.toHaveBeenCalled() expect(mockFetch).not.toHaveBeenCalled() }) - it('getOrderById returns null when the id is not in the TARGET set', async () => { - mockListAdminCustomerOrders.mockResolvedValue([ - { id: 'ord_1', display_id: 1 }, - ]) + it('getOrderById returns null when the TARGET order lookup misses', async () => { + mockGetAdminCustomerOrderById.mockResolvedValue(null) const order = await getOrderById('ord_missing') expect(order).toBeNull() + expect(mockGetAdminCustomerOrderById).toHaveBeenCalledWith( + 'cus_t', + 'ord_missing' + ) expect(mockFetch).not.toHaveBeenCalled() }) + + it('order pages degrade to an empty list when admin order lookup fails', async () => { + mockListAdminCustomerOrders.mockRejectedValueOnce(new Error('admin down')) + + await expect(getAllOrders()).resolves.toEqual([]) + expect(mockError).toHaveBeenCalledTimes(1) + }) + + it('single order lookup degrades to null when admin order lookup fails', async () => { + mockGetAdminCustomerOrderById.mockRejectedValueOnce(new Error('admin down')) + + await expect(getOrderById('ord_1')).resolves.toBeNull() + expect(mockError).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/src/lib/customer-orders.ts b/src/lib/customer-orders.ts index be85b756..7f403f0b 100644 --- a/src/lib/customer-orders.ts +++ b/src/lib/customer-orders.ts @@ -7,7 +7,10 @@ import { getMedusaPublishableKey, } from '@/lib/store-environment' import { getImpersonation } from '@/lib/impersonation' -import { listAdminCustomerOrders } from '@/lib/discord/medusa-admin' +import { + getAdminCustomerOrderById, + listAdminCustomerOrders, +} from '@/lib/discord/medusa-admin' // ============================================================================ // Types — the order shape this module owns @@ -101,7 +104,25 @@ async function fetchOrders( async function fetchImpersonatedOrders(): Promise { const impersonation = await getImpersonation() if (!impersonation) return null - return listAdminCustomerOrders(impersonation.targetId) + try { + return await listAdminCustomerOrders(impersonation.targetId) + } catch (error) { + storeLogger.error`Failed to fetch impersonated orders: ${error}` + return [] + } +} + +async function fetchImpersonatedOrderById( + orderId: string +): Promise { + const impersonation = await getImpersonation() + if (!impersonation) return undefined + try { + return await getAdminCustomerOrderById(impersonation.targetId, orderId) + } catch (error) { + storeLogger.error`Failed to fetch impersonated order ${orderId}: ${error}` + return null + } } // ============================================================================ @@ -188,8 +209,8 @@ export async function getAllOrders( export async function getOrderById( orderId: string ): Promise { - const impersonated = await fetchImpersonatedOrders() - if (impersonated) return impersonated.find((o) => o.id === orderId) ?? null + const impersonated = await fetchImpersonatedOrderById(orderId) + if (impersonated !== undefined) return impersonated const token = await getAuthToken() if (!token) return null diff --git a/src/lib/discord/medusa-admin.test.ts b/src/lib/discord/medusa-admin.test.ts index b769c1d5..28219814 100644 --- a/src/lib/discord/medusa-admin.test.ts +++ b/src/lib/discord/medusa-admin.test.ts @@ -1,19 +1,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +const { mockInfraError } = vi.hoisted(() => ({ + mockInfraError: vi.fn(), +})) + vi.mock('@/utils/env', () => ({ env: { MEDUSA_ADMIN_API_TOKEN: 'admin-token' }, })) vi.mock('@/lib/store-environment', () => ({ getLiveStoreEnvironment: () => ({ medusaBackendUrl: 'https://api.test' }), })) +vi.mock('@/lib/logger', () => ({ + infraLogger: { error: mockInfraError }, +})) -import { findAdminCustomerByEmail, getAdminCustomerById } from './medusa-admin' +import { + findAdminCustomerByEmail, + getAdminCustomerById, + getAdminCustomerOrderById, +} from './medusa-admin' const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) afterEach(() => { fetchMock.mockReset() + mockInfraError.mockReset() }) function ok(body: unknown) { @@ -36,6 +48,29 @@ describe('findAdminCustomerByEmail', () => { fetchMock.mockResolvedValueOnce(ok({ customers: [] })) expect(await findAdminCustomerByEmail('missing@b.com')).toBeNull() }) + + it('prefers a registered customer when guest and account emails collide', async () => { + fetchMock.mockResolvedValueOnce( + ok({ + customers: [ + { id: 'guest', email: 'a@b.com', has_account: false }, + { id: 'registered', email: 'a@b.com', has_account: true }, + ], + }) + ) + + const customer = await findAdminCustomerByEmail('a@b.com') + + expect(customer?.id).toBe('registered') + expect(fetchMock.mock.calls[0][0]).toContain('limit=2') + }) + + it('returns null and logs when the admin lookup fails', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'down' } as Response) + + expect(await findAdminCustomerByEmail('a@b.com')).toBeNull() + expect(mockInfraError).toHaveBeenCalledTimes(1) + }) }) describe('getAdminCustomerById', () => { @@ -49,5 +84,27 @@ describe('getAdminCustomerById', () => { it('returns null on a non-ok response', async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' } as Response) expect(await getAdminCustomerById('cus_x')).toBeNull() + expect(mockInfraError).toHaveBeenCalledTimes(1) + }) +}) + +describe('getAdminCustomerOrderById', () => { + it('fetches a single customer order by id', async () => { + fetchMock.mockResolvedValueOnce(ok({ orders: [{ id: 'ord_1' }] })) + + const order = await getAdminCustomerOrderById('cus_1', 'ord_1') + + expect(order?.id).toBe('ord_1') + const url = fetchMock.mock.calls[0][0] as string + expect(url).toContain('/admin/orders?') + expect(url).toContain('customer_id=cus_1') + expect(url).toContain('id=ord_1') + expect(url).toContain('limit=1') + }) + + it('returns null when the returned order id does not match', async () => { + fetchMock.mockResolvedValueOnce(ok({ orders: [{ id: 'ord_other' }] })) + + expect(await getAdminCustomerOrderById('cus_1', 'ord_1')).toBeNull() }) }) diff --git a/src/lib/discord/medusa-admin.ts b/src/lib/discord/medusa-admin.ts index 2e71b7f7..43176da8 100644 --- a/src/lib/discord/medusa-admin.ts +++ b/src/lib/discord/medusa-admin.ts @@ -2,6 +2,7 @@ import 'server-only' import { env } from '@/utils/env' import { getLiveStoreEnvironment } from '@/lib/store-environment' +import { infraLogger } from '@/lib/logger' import type { MedusaOrder } from '@/lib/customer-orders' import type { MedusaCustomer } from '@/lib/medusa-auth' @@ -92,6 +93,23 @@ export async function listAdminCustomerOrders( return orders } +export async function getAdminCustomerOrderById( + customerId: string, + orderId: string +): Promise { + const query = new URLSearchParams({ + limit: '1', + customer_id: customerId, + id: orderId, + }) + + const page = await medusaAdminFetch( + `/admin/orders?${query.toString()}` + ) + const [order] = page.orders ?? [] + return order?.id === orderId ? order : null +} + interface AdminCustomerResponse { customer?: MedusaCustomer } @@ -105,13 +123,19 @@ export async function findAdminCustomerByEmail( ): Promise { const query = new URLSearchParams({ email: email.trim().toLowerCase(), - limit: '1', + limit: '2', fields: 'id,email,first_name,last_name,phone,has_account,metadata,created_at,updated_at', }) - const page = await medusaAdminFetch( - `/admin/customers?${query.toString()}` - ) - return page.customers?.[0] ?? null + try { + const page = await medusaAdminFetch( + `/admin/customers?${query.toString()}` + ) + const customers = page.customers ?? [] + return customers.find((customer) => customer.has_account) ?? customers[0] ?? null + } catch (error) { + infraLogger.error`Failed to find admin customer by email: ${error}` + return null + } } /** @@ -128,7 +152,8 @@ export async function getAdminCustomerById( `/admin/customers/${id}?${query.toString()}` ) return data.customer ?? null - } catch { + } catch (error) { + infraLogger.error`Failed to fetch admin customer ${id}: ${error}` return null } } diff --git a/src/lib/impersonation.test.ts b/src/lib/impersonation.test.ts index 2cf9cf90..8291a3f2 100644 --- a/src/lib/impersonation.test.ts +++ b/src/lib/impersonation.test.ts @@ -34,7 +34,12 @@ vi.mock('@/lib/admin', () => ({ isAdmin: (email?: string | null) => email === 'paul@kilbot.com', })) -import { getImpersonation, assertViewOnly, ViewOnlyError } from './impersonation' +import { + getImpersonation, + assertViewOnly, + startImpersonation, + ViewOnlyError, +} from './impersonation' beforeEach(() => { cookieStore.value = undefined @@ -70,6 +75,17 @@ describe('getImpersonation', () => { expect(cookieStore.delete).toHaveBeenCalledWith('wcpos-impersonate') }) + it('returns null even if clearing the cookie throws in read-only context', async () => { + cookieStore.value = 'cus_target' + headerStore.value = '1' + cookieStore.delete.mockImplementationOnce(() => { + throw new Error('Cookies can only be modified in a Server Action or Route Handler') + }) + getSessionCustomer.mockResolvedValue({ email: 'attacker@evil.com' }) + + await expect(getImpersonation()).resolves.toBeNull() + }) + it('returns null when there is no cookie', async () => { headerStore.value = '1' getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) @@ -77,6 +93,29 @@ describe('getImpersonation', () => { }) }) +describe('startImpersonation', () => { + it('sets the target cookie for an admin session', async () => { + getSessionCustomer.mockResolvedValue({ email: 'paul@kilbot.com' }) + + await startImpersonation('cus_target') + + expect(cookieStore.set).toHaveBeenCalledWith( + 'wcpos-impersonate', + 'cus_target', + expect.objectContaining({ httpOnly: true, path: '/' }) + ) + }) + + it('rejects non-admin sessions before setting the cookie', async () => { + getSessionCustomer.mockResolvedValue({ email: 'attacker@evil.com' }) + + await expect(startImpersonation('cus_target')).rejects.toThrow( + 'Not authorized to impersonate' + ) + expect(cookieStore.set).not.toHaveBeenCalled() + }) +}) + describe('assertViewOnly', () => { it('throws ViewOnlyError while impersonating', async () => { cookieStore.value = 'cus_target' diff --git a/src/lib/impersonation.ts b/src/lib/impersonation.ts index a387d9f4..7139edcb 100644 --- a/src/lib/impersonation.ts +++ b/src/lib/impersonation.ts @@ -70,6 +70,9 @@ export async function assertViewOnly(): Promise { /** Begin inspecting a target customer (called from the admin action, which has * already verified the caller is an admin). */ export async function startImpersonation(targetId: string): Promise { + const session = await getSessionCustomer() + if (!isAdmin(session?.email)) throw new Error('Not authorized to impersonate') + const cookieStore = await cookies() cookieStore.set(IMPERSONATION_COOKIE, targetId, IMPERSONATION_COOKIE_OPTIONS) } diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 60997489..bae966ba 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -161,6 +161,60 @@ describe('middleware', () => { response?.headers.get(`x-middleware-request-${ACCOUNT_REQUEST_HEADER}`) ).toBe('1') }) + + it('sets the account-request header on store cart mutation API routes', () => { + const request = new NextRequest('https://wcpos.com/api/store/cart/complete', { + headers: { [ACCOUNT_REQUEST_HEADER]: 'spoofed' }, + }) + + const response = middleware(request) + + expect( + response?.headers.get(`x-middleware-request-${ACCOUNT_REQUEST_HEADER}`) + ).toBe('1') + }) + + it('does not set the account-request header for account-prefixed page segments', () => { + const request = new NextRequest('https://wcpos.com/accounting', { + headers: { + cookie: 'medusa-token=test-token', + [ACCOUNT_REQUEST_HEADER]: 'spoofed', + }, + }) + + middleware(request) + + expect(intlRequests).toHaveLength(1) + expect(intlRequests[0].headers.get(ACCOUNT_REQUEST_HEADER)).toBeNull() + }) + + it('strips the account-request header on legacy WC API Manager rewrites', () => { + const request = new NextRequest( + 'https://wcpos.com/?wc-api=am-software-api&request=activation', + { headers: { [ACCOUNT_REQUEST_HEADER]: '1' } } + ) + + const response = middleware(request) + + expect( + response?.headers.get(`x-middleware-request-${ACCOUNT_REQUEST_HEADER}`) + ).toBeNull() + }) + + it('strips the account-request header on updates API routes', () => { + const request = new NextRequest('https://updates.wcpos.com/api/check', { + headers: { + host: 'updates.wcpos.com', + [ACCOUNT_REQUEST_HEADER]: '1', + }, + }) + + const response = middleware(request) + + expect( + response?.headers.get(`x-middleware-request-${ACCOUNT_REQUEST_HEADER}`) + ).toBeNull() + }) }) describe('analytics consent gating', () => { diff --git a/src/middleware.ts b/src/middleware.ts index deb37cdb..072a2264 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -91,13 +91,15 @@ export function middleware(request: NextRequest) { if (request.nextUrl.searchParams.get('wc-api') === 'am-software-api') { const rewriteUrl = request.nextUrl.clone() rewriteUrl.pathname = '/api/legacy/wc-am' - return NextResponse.rewrite(rewriteUrl) + return NextResponse.rewrite(rewriteUrl, { + request: { headers: sanitizedHeaders }, + }) } // Handle updates.wcpos.com — restrict to API routes only if (hostname === UPDATES_HOSTNAME) { if (pathname.startsWith('/api/')) { - return NextResponse.next() + return NextResponse.next({ request: { headers: sanitizedHeaders } }) } if (pathname === '/') { @@ -120,7 +122,11 @@ export function middleware(request: NextRequest) { // API routes don't need locale processing. Account APIs get the // account-request header so impersonation is scoped to /account. if (pathname.startsWith('/api/')) { - if (pathname.startsWith('/api/account/')) { + if ( + pathname.startsWith('/api/account/') || + pathname === '/api/store/cart' || + pathname.startsWith('/api/store/cart/') + ) { const headers = new Headers(sanitizedHeaders) headers.set(ACCOUNT_REQUEST_HEADER, '1') return NextResponse.next({ request: { headers } }) @@ -133,12 +139,15 @@ export function middleware(request: NextRequest) { const localeRegex = new RegExp(`^/(${localePattern})(?=/|$)`) const pathnameWithoutLocale = pathname.replace(localeRegex, '') || '/' const pathnameWithQuery = `${pathnameWithoutLocale}${request.nextUrl.search}` + const isAccountPath = + pathnameWithoutLocale === '/account' || + pathnameWithoutLocale.startsWith('/account/') // Protected routes: /account/* requires a medusa-token cookie. // /pro/checkout is deliberately NOT gated: signed-out buyers create their // account inline in the checkout's first step (the cart APIs it calls // still enforce auth server-side). - const requiresAuth = pathnameWithoutLocale.startsWith('/account') + const requiresAuth = isAccountPath if (requiresAuth) { const token = request.cookies.get(COOKIE_NAME)?.value @@ -165,7 +174,7 @@ export function middleware(request: NextRequest) { // headers onto the response it forwards (`NextResponse.rewrite`/`.next` with // `{ request: { headers } }`), so a header set on the request it receives // propagates to the RSC layer via `headers()`. - if (pathnameWithoutLocale.startsWith('/account')) { + if (isAccountPath) { const headers = new Headers(sanitizedHeaders) headers.set(ACCOUNT_REQUEST_HEADER, '1') const requestWithHeader = new NextRequest(request, { headers }) From a4a2f7d1020d270c7a04a9ee8da95f779813d1a0 Mon Sep 17 00:00:00 2001 From: "wcpos-agents[bot]" <2860316+wcpos-agents[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:05:53 +0000 Subject: [PATCH 14/16] fix: address impersonation audit review feedback --- src/app/[locale]/account/admin/actions.ts | 9 +++++++-- src/app/api/account/impersonate/exit/route.ts | 8 +++----- src/lib/discord/medusa-admin.test.ts | 7 +++++++ src/lib/discord/medusa-admin.ts | 15 ++++++++++----- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/app/[locale]/account/admin/actions.ts b/src/app/[locale]/account/admin/actions.ts index 7cbdb2d6..fb317578 100644 --- a/src/app/[locale]/account/admin/actions.ts +++ b/src/app/[locale]/account/admin/actions.ts @@ -1,5 +1,6 @@ 'use server' +import { createHash } from 'node:crypto' import { redirect } from '@/i18n/navigation' import { getSessionCustomer } from '@/lib/medusa-auth' import { isAdmin } from '@/lib/admin' @@ -19,6 +20,10 @@ function redirectToAccount(locale: string): never { throw new Error('Redirect failed') } +function auditHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12) +} + export type StartImpersonationResult = | { error: 'forbidden' } | { error: 'not_found' } @@ -48,11 +53,11 @@ export async function startImpersonationAction(input: { const email = input.email.trim().toLowerCase() const target = await findAdminCustomerByEmail(email) if (!target) { - authLogger.info`Impersonation lookup miss: admin=${adminEmail} target=${email}` + authLogger.info`Impersonation lookup miss: admin=${adminEmail} target_hash=${auditHash(email)}` return { error: 'not_found' } } - authLogger.info`Impersonation START: admin=${adminEmail} target=${target.email} (${target.id})` + authLogger.info`Impersonation START: admin=${adminEmail} target_id=${target.id}` await startImpersonation(target.id) redirectToAccount(input.locale) } diff --git a/src/app/api/account/impersonate/exit/route.ts b/src/app/api/account/impersonate/exit/route.ts index da0b8c04..a89ef041 100644 --- a/src/app/api/account/impersonate/exit/route.ts +++ b/src/app/api/account/impersonate/exit/route.ts @@ -3,6 +3,7 @@ import { stopImpersonation } from '@/lib/impersonation' import { getSessionCustomer } from '@/lib/medusa-auth' import { authLogger } from '@/lib/logger' import { defaultLocale, locales } from '@/i18n/config' +import { getPathname } from '@/i18n/navigation' function accountUrl(request: Request): URL { const requestUrl = new URL(request.url) @@ -18,16 +19,13 @@ function accountUrl(request: Request): URL { const locale = locales.find( (candidate) => candidate === explicitLocale || candidate === refererLocale ) - return new URL( - locale && locale !== defaultLocale ? `/${locale}/account` : '/account', - request.url - ) + return new URL(getPathname({ href: '/account', locale: locale ?? defaultLocale }), request.url) } async function exit(request: Request) { const session = await getSessionCustomer() await stopImpersonation() - authLogger.info`Impersonation STOP: admin=${session?.email ?? 'unknown'}` + authLogger.info`Impersonation STOP: admin_id=${session?.id ?? 'unknown'}` return NextResponse.redirect(accountUrl(request), { status: 303 }) } diff --git a/src/lib/discord/medusa-admin.test.ts b/src/lib/discord/medusa-admin.test.ts index 28219814..4709605e 100644 --- a/src/lib/discord/medusa-admin.test.ts +++ b/src/lib/discord/medusa-admin.test.ts @@ -107,4 +107,11 @@ describe('getAdminCustomerOrderById', () => { expect(await getAdminCustomerOrderById('cus_1', 'ord_1')).toBeNull() }) + + it('returns null and logs when the admin order lookup fails', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'down' } as Response) + + expect(await getAdminCustomerOrderById('cus_1', 'ord_1')).toBeNull() + expect(mockInfraError).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/lib/discord/medusa-admin.ts b/src/lib/discord/medusa-admin.ts index 43176da8..7f142dcf 100644 --- a/src/lib/discord/medusa-admin.ts +++ b/src/lib/discord/medusa-admin.ts @@ -103,11 +103,16 @@ export async function getAdminCustomerOrderById( id: orderId, }) - const page = await medusaAdminFetch( - `/admin/orders?${query.toString()}` - ) - const [order] = page.orders ?? [] - return order?.id === orderId ? order : null + try { + const page = await medusaAdminFetch( + `/admin/orders?${query.toString()}` + ) + const [order] = page.orders ?? [] + return order?.id === orderId ? order : null + } catch (error) { + infraLogger.error`Failed to fetch admin order ${orderId} for customer ${customerId}: ${error}` + return null + } } interface AdminCustomerResponse { From 1c33300175240a05c6742a201d365a0aa08e0628 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 12:41:49 +0200 Subject: [PATCH 15/16] fix(admin): set owner allowlist to paul@kilbot.com.au --- src/lib/admin.test.ts | 6 +++--- src/lib/admin.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/admin.test.ts b/src/lib/admin.test.ts index ae0bd6b1..8efad8f0 100644 --- a/src/lib/admin.test.ts +++ b/src/lib/admin.test.ts @@ -3,12 +3,12 @@ import { isAdmin, ADMIN_EMAILS } from './admin' describe('isAdmin', () => { it('includes the owner in the allowlist', () => { - expect(ADMIN_EMAILS).toContain('paul@kilbot.com') + expect(ADMIN_EMAILS).toContain('paul@kilbot.com.au') }) it('matches an allowlisted email case-insensitively, trimming whitespace', () => { - expect(isAdmin('Paul@Kilbot.com')).toBe(true) - expect(isAdmin(' paul@kilbot.com ')).toBe(true) + expect(isAdmin('Paul@Kilbot.com.au')).toBe(true) + expect(isAdmin(' paul@kilbot.com.au ')).toBe(true) }) it('rejects non-allowlisted or missing emails (fail closed)', () => { diff --git a/src/lib/admin.ts b/src/lib/admin.ts index a2837e4d..6fd842a6 100644 --- a/src/lib/admin.ts +++ b/src/lib/admin.ts @@ -5,7 +5,7 @@ * flip mechanism is git, and the value fails loudly if wrong. Add owner emails * here. Empty ⇒ nobody is admin (fail closed). */ -export const ADMIN_EMAILS: readonly string[] = ['paul@kilbot.com'] +export const ADMIN_EMAILS: readonly string[] = ['paul@kilbot.com.au'] const NORMALIZED = new Set(ADMIN_EMAILS.map((e) => e.trim().toLowerCase())) From ccf116ee21ee08e73adb45db9652e128f395294b Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 12:50:14 +0200 Subject: [PATCH 16/16] fix(admin): Medusa v2 admin API keys require HTTP Basic auth, not Bearer --- src/lib/discord/medusa-admin.test.ts | 14 ++++++++++++++ src/lib/discord/medusa-admin.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/lib/discord/medusa-admin.test.ts b/src/lib/discord/medusa-admin.test.ts index 4709605e..3c7abeab 100644 --- a/src/lib/discord/medusa-admin.test.ts +++ b/src/lib/discord/medusa-admin.test.ts @@ -32,6 +32,20 @@ function ok(body: unknown) { return { ok: true, json: async () => body } as Response } +describe('admin auth header', () => { + it('authenticates with HTTP Basic (secret key as username), not Bearer', async () => { + fetchMock.mockResolvedValueOnce(ok({ customers: [] })) + await findAdminCustomerByEmail('a@b.com') + + const init = fetchMock.mock.calls[0][1] as RequestInit + const auth = (init.headers as Record).Authorization + expect(auth.startsWith('Basic ')).toBe(true) + // Medusa v2 rejects Bearer for API keys; the key is the Basic username. + const decoded = Buffer.from(auth.slice('Basic '.length), 'base64').toString('utf-8') + expect(decoded).toBe('admin-token:') + }) +}) + describe('findAdminCustomerByEmail', () => { it('queries /admin/customers by email and returns the first match', async () => { fetchMock.mockResolvedValueOnce( diff --git a/src/lib/discord/medusa-admin.ts b/src/lib/discord/medusa-admin.ts index 7f142dcf..872316b2 100644 --- a/src/lib/discord/medusa-admin.ts +++ b/src/lib/discord/medusa-admin.ts @@ -22,6 +22,13 @@ function requireAdminToken(): string { } async function medusaAdminFetch(path: string, init: RequestInit = {}): Promise { + // Medusa v2 admin API keys authenticate via HTTP Basic auth (the secret key + // is the username, password empty) — the framework rejects `Bearer` for API + // keys (`Bearer` is only for short-lived user JWTs, which can't serve as a + // static env token). See @medusajs/framework authenticate-middleware + // getApiKeyInfo: it requires `tokenType === 'basic'`. + const basicCredential = Buffer.from(`${requireAdminToken()}:`).toString('base64') + // Discord role-sync reconciles LIVE business data — it runs from webhooks // and cron with no meaningful request host, so it is pinned to live rather // than host-resolved. @@ -29,7 +36,7 @@ async function medusaAdminFetch(path: string, init: RequestInit = {}): Promis ...init, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${requireAdminToken()}`, + Authorization: `Basic ${basicCredential}`, ...(init.headers as Record | undefined), }, })