Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .agents/skills/apps-nuvemshop/references/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ No API covers customer sessions. What works, validated against a live store:
| `actions/account/logout` | store `GET /account/logout/` | |
| `loaders/user` | store `GET /account/` → `LS.customer = <id>`; Admin API `GET /customers/<id>` | `null` when there's no `store_*` cookie or `/account/` redirects to login. |

The store domain sits behind a Cloudflare WAF that 403-challenges requests with no `User-Agent` (the default from a Worker); `storeFetch` forwards the buyer's UA, falling back to `Mozilla/5.0 (compatible; deco-storefront)`.

## Cookies

The session is the store's `store_session_payload_<storeId>` (rewritten on
Expand All @@ -24,3 +26,45 @@ login) + `store_login_session`. `store.ts`:
`adminToken` is a custom-app token, server-only, often created with full
access. Never return it or Admin API payloads wholesale to the browser —
`user` returns only id/name/email/phone of the session's own customer.

## Customer data: profile, addresses, orders

| Export | Upstream | Notes |
|---|---|---|
| `loaders/account/profile` | Admin `GET /customers/<id>` | Picks id/name/email/phone/identification/`billing_*`. |
| `actions/account/updateProfile` | Admin `PUT /customers/<id>` | Allow-list: name, phone, identification (CPF checked), `billing_*`. Never email/password/note. |
| `loaders/account/addresses` | Admin `GET /customers/<id>?fields=addresses,default_address` | `default` flag derived. |
| `actions/account/addAddress` | Admin `PUT /customers/<id>` `{addresses:[…]}` | The PUT **appends**. Country forced to BR. |
| `actions/account/updateAddress` | store form `POST /account/address/<id>/` (trailing slash required) | Admin PUT would create a duplicate. Success = 302 to `/account/addresses…`; rejected = 302 back to the form. |
| `loaders/account/orders` | Admin `GET /orders?customer_ids=<id>` | `page` clamped 1..1000, `perPage` 1..50. Empty = 404 "Last page is 0" → `[]`. |
| `loaders/account/order` | Admin `GET /orders/<id>?aggregates=fulfillment_orders` | |

Code: `utils/accountData.ts` (operations), `utils/account.ts` (validation, `AccountError`,
pt-BR error mapping), `utils/orders.ts` (mapping + status labels). `sessionCustomerId()`
(`store.ts`, exported from the barrel) is the only source of the customer id.
All loaders are `cache = "no-store"` and go through `nuvemshopAdmin` (raw instrumented
transport, **not** `createFetchCache`): per-user data must never hit the shared GET cache.
Errors are `AccountError` (`.status`, pt-BR message); a site's server-fn layer should
surface only those and keep everything else generic.

### Security rules (each is tested in `__tests__/accountData.test.ts`)

- The customer id comes from the session only (`LS.customer` on `/account/`), never props. No session → 401, nothing sent to the Admin API.
- The scrape fails closed: every `LS.customer = N;` in the page must agree (user text rendered in the page can't override it).
- Ids from callers (`orderId`, `addressId`) go through `parseId` (digits only, safe integer > 0) before touching a URL.
- Ownership compares `String(a) === String(b)`; not-yours is the same 404 as not-found (same message, one upstream call).
- `orders` is post-filtered by `customer.id` regardless of the upstream filter; address updates only accept ids from the session customer's own list.
- Writes are allow-listed field by field; extra keys (`id`, `customer_id`, `email`) are dropped.
- **Same-origin / CSRF is the site's job** (server-fn layer): reject cross-origin browser requests (`Sec-Fetch-Site` not `same-origin`/`none`, or `Origin` host ≠ request host), set `Cache-Control: private, no-store`, and map errors. The package has no request-origin policy.

### Unsupported (no upstream support)

- Address delete / set default: neither the Admin API nor the store form exposes them.
- Logged-in password change: no endpoint.
- Password recovery: the store form needs a reCAPTCHA bound to the store domain.

### Client bundles

`admin.ts` carries no secrets itself (the token is read from server config at call time), but
the barrel (`index.ts`) re-exports server-only code (`store.ts`, loaders). Import loaders/actions
from server code (invoke/server fns) — never from client components.
18 changes: 18 additions & 0 deletions packages/apps-nuvemshop/src/__tests__/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,21 @@ describe("user", () => {
});
});
});

describe("store-domain User-Agent (WAF challenges UA-less requests)", () => {
const uaSent = () => new Headers(fetchMock.mock.calls.at(-1)![1]!.headers).get("user-agent");
const run = (headers: Record<string, string>) => {
routes[`GET ${STORE}/account/logout/`] = () => redirect(`${STORE}/`);
return RequestContext.run(new Request(`${SITE}/x`, { headers }), () => logout({}));
};

it("forwards the buyer's UA", async () => {
await run({ "user-agent": "BuyerBrowser/1.0" });
expect(uaSent()).toBe("BuyerBrowser/1.0");
});

it("falls back to a generic UA when the request has none", async () => {
await run({});
expect(uaSent()).toBe("Mozilla/5.0 (compatible; deco-storefront)");
});
});
179 changes: 179 additions & 0 deletions packages/apps-nuvemshop/src/__tests__/accountData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Regression tests for "no user sees or changes another user's data". No network: fetch is mocked.
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
import { describe, expect, it } from "vitest";
import addAddress from "../actions/account/addAddress";
import updateAddress from "../actions/account/updateAddress";
import updateProfile from "../actions/account/updateProfile";
import { configureNuvemshop, setNuvemshopFetch } from "../client";
import addresses from "../loaders/account/addresses";
import order from "../loaders/account/order";
import orders from "../loaders/account/orders";
import profile from "../loaders/account/profile";
import { sessionCustomerId } from "../store";
import { parseId } from "../utils/account";

const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status });
const calls: { url: string; init?: RequestInit }[] = [];
const isAccountPage = (u: string) => u === "https://s.example/account/";

/** Mocks upstream; the store's /account/ page reports `session` as the logged-in customer. */
function mock(
handler: (url: string, init?: RequestInit) => Response,
session = "LS.customer = 7;",
) {
calls.length = 0;
configureNuvemshop({ storeId: "1", adminToken: "t", storeUrl: "https://s.example" });
setNuvemshopFetch((async (u: unknown, i?: RequestInit) => {
calls.push({ url: String(u), init: i });
return isAccountPage(String(u)) ? new Response(session) : handler(String(u), i);
}) as typeof fetch);
}
const as = <T>(fn: () => Promise<T>, cookie: string | null = "store_x=1") =>
RequestContext.run(new Request("https://x.example/", cookie ? { headers: { cookie } } : {}), fn);
const msg = (p: Promise<unknown>) =>
p.then(
() => "OK",
(e: { status: number; message: string }) => `${e.status} ${e.message}`,
);
const upstream = () => calls.filter((c) => !isAccountPage(c.url));
const ADDR = {
address: "R",
number: "1",
locality: "B",
city: "C",
province: "SP",
zipcode: "01001000",
};

describe("ownership", () => {
it("foreign order == nonexistent order (same status and message)", async () => {
mock((u) => (u.includes("/orders/1") ? json({ id: 1, customer: { id: 8 } }) : json({}, 404)));
const foreign = await as(() => msg(order({ orderId: 1 })));
expect(foreign).toBe("404 Pedido não encontrado.");
expect(await as(() => msg(order({ orderId: 2 })))).toBe(foreign);
expect(await as(() => msg(order({ orderId: 0 })))).toBe(foreign);
});

it("compares ids as strings (upstream string id still matches only the owner)", async () => {
mock(() => json({ id: 1, customer: { id: "7" } }));
expect(await as(() => msg(order({ orderId: 1 })))).toBe("OK");
mock(() => json({ id: 1, customer: { id: "7" } }), "LS.customer = 70;");
expect(await as(() => msg(order({ orderId: 1 })))).toBe("404 Pedido não encontrado.");
});

it("path/query injection ids never reach the Admin API", async () => {
mock(() => json({ id: 1, customer: { id: 7 } }));
for (const bad of [
"1,2",
"../customers/8",
"123?customer_ids=8",
"1e3",
" 1",
"-1",
1.5,
Number.NaN,
null,
{},
1e21,
]) {
expect(parseId(bad)).toBeNull();
expect(await as(() => msg(order({ orderId: bad as number })))).toBe(
"404 Pedido não encontrado.",
);
}
expect(upstream()).toHaveLength(0);
expect(parseId("42")).toBe(42);
});

it("orders sends the session id, clamps paging and drops foreign orders", async () => {
mock(() => json([{ id: 1, customer: { id: 7 } }, { id: 2, customer: { id: 8 } }, { id: 3 }]));
const r = await as(() => orders({ page: 99999, perPage: 9999 }));
expect(r.map((o) => o.id)).toEqual([1]);
const q = new URL(upstream()[0].url).searchParams;
expect([q.get("customer_ids"), q.get("per_page"), q.get("page")]).toEqual(["7", "50", "1000"]);
});

it("orders: empty page (404 Last page) is []", async () => {
mock(() => json({ description: "Last page is 0" }, 404));
expect(await as(() => orders({}))).toEqual([]);
});

it("profile/updateProfile target the session customer and allow-list fields", async () => {
mock(() => json({ id: 7, name: "A", email: "e" }));
await as(() => profile({}));
expect(upstream()[0].url).toContain("/customers/7");
await as(() => updateProfile({ name: "A", id: 8, customer_id: 8, email: "[email protected]" } as never));
const put = upstream()[1];
expect(put.url).toContain("/customers/7");
expect(JSON.parse(String(put.init?.body))).toEqual({ name: "A" });
});

it("addAddress writes to the session customer only", async () => {
mock(() => json({ addresses: [{ id: 10 }] }));
await as(() => addAddress({ ...ADDR, customer_id: 8 } as never));
expect(upstream()[0].url).toContain("/customers/7");
expect(JSON.parse(String(upstream()[0].init?.body))).not.toHaveProperty("customer_id");
});

it("updateAddress with a foreign address id is rejected before any write", async () => {
mock(() => json({ addresses: [{ id: 10 }] }));
expect(await as(() => msg(updateAddress({ ...ADDR, addressId: 11 })))).toBe(
"404 Endereço não encontrado.",
);
expect(await as(() => msg(updateAddress({ ...ADDR, addressId: "10/../11" })))).toBe(
"404 Endereço não encontrado.",
);
expect(calls.every((c) => c.init?.method !== "POST")).toBe(true);
});
});

describe("session", () => {
it("logged out -> 401 on every entry point, nothing reaches the Admin API", async () => {
mock(() => json({}), "LS.customer = false;");
for (const run of [
() => profile({}),
() => orders({}),
() => order({ orderId: 1 }),
() => addAddress(ADDR),
() => updateProfile({ name: "A" }),
() => updateAddress({ ...ADDR, addressId: 10 }),
() => addresses({}),
]) {
expect(await as(() => msg(run()))).toBe("401 Faça login para continuar.");
expect(await as(() => msg(run()), null)).toBe("401 Faça login para continuar.");
}
expect(upstream()).toHaveLength(0);
});

it("real logged-in /account/ snippet (verified on a live store) parses", async () => {
mock(
() => json({}),
"<script>\nLS.customer = 350524152;\nLS.customerHasPriceTables = false;\n</script>",
);
expect(await as(() => sessionCustomerId())).toBe(350524152);
});

it("memoizes the session lookup per request", async () => {
mock(() => json({ id: 7, name: "A", email: "e", addresses: [] }));
await as(async () => {
await profile({});
await addresses({});
});
expect(calls.filter((c) => isAccountPage(c.url))).toHaveLength(1);
});

it("conflicting LS.customer values fail closed; single value works; non-store cookies are not forwarded", async () => {
mock(() => json({}), "LS.customer = 7;\nOlá LS.customer = 8; ");
expect(await as(() => sessionCustomerId())).toBeNull();
mock(() => json({}), "LS.customer = 7;\nLS.customerHasPriceTables = false;");
expect(await as(() => sessionCustomerId(), "a=1; store_x=1")).toBe(7);
expect((calls[0].init!.headers as Record<string, string>).cookie).toBe("store_x=1");
});

it("redirected /account/ (expired session) -> null", async () => {
calls.length = 0;
configureNuvemshop({ storeId: "1", adminToken: "t", storeUrl: "https://s.example" });
setNuvemshopFetch((async () => new Response("", { status: 302 })) as typeof fetch);
expect(await as(() => sessionCustomerId())).toBeNull();
});
});
12 changes: 12 additions & 0 deletions packages/apps-nuvemshop/src/actions/account/addAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type AddressInput, addAddress, requireCustomerId } from "../../utils/accountData";
import type { Address } from "../../utils/orders";

export type Props = AddressInput;

/**
* @title Nuvemshop - Add address
* @description Adds an address (Brazil) to the logged-in buyer.
*/
export default async function addAddressAction(props: Props): Promise<Address> {
return addAddress(await requireCustomerId(), props);
}
16 changes: 16 additions & 0 deletions packages/apps-nuvemshop/src/actions/account/updateAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { type AddressInput, requireCustomerId, updateAddress } from "../../utils/accountData";

export interface Props extends AddressInput {
/** @title Address id */
addressId: number | string;
}

/**
* @title Nuvemshop - Update address
* @description Updates one of the logged-in buyer's addresses through the store form. A foreign address id is 404.
*/
export default async function updateAddressAction(props: Props): Promise<{ ok: true }> {
const { addressId, ...input } = props ?? ({} as Props);
await updateAddress(await requireCustomerId(), addressId, input as AddressInput);
return { ok: true };
}
16 changes: 16 additions & 0 deletions packages/apps-nuvemshop/src/actions/account/updateProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
type Profile,
type ProfileInput,
requireCustomerId,
updateProfile,
} from "../../utils/accountData";

export type Props = ProfileInput;

/**
* @title Nuvemshop - Update profile
* @description Updates the logged-in buyer's profile. Only allow-listed fields (name, phone, CPF, billing_*) are sent; email/password are not editable.
*/
export default async function updateProfileAction(props: Props): Promise<Profile> {
return updateProfile(await requireCustomerId(), props);
}
9 changes: 9 additions & 0 deletions packages/apps-nuvemshop/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export { default as addAddress } from "./actions/account/addAddress";
export { default as login } from "./actions/account/login";
export { default as logout } from "./actions/account/logout";
export { default as register } from "./actions/account/register";
export { default as updateAddress } from "./actions/account/updateAddress";
export { default as updateProfile } from "./actions/account/updateProfile";
export { default as createCheckout } from "./actions/createCheckout";
export {
clearNuvemshopCache,
Expand All @@ -13,6 +16,10 @@ export {
PRODUCT_FIELDS,
setNuvemshopFetch,
} from "./client";
export { default as addresses } from "./loaders/account/addresses";
export { default as order } from "./loaders/account/order";
export { default as orders } from "./loaders/account/orders";
export { default as profile } from "./loaders/account/profile";
export { default as cart } from "./loaders/cart";
export { default as categories } from "./loaders/categories";
export { default as productDetailsPage } from "./loaders/productDetailsPage";
Expand All @@ -24,6 +31,8 @@ export { default as suggestions } from "./loaders/suggestions";
export { default as user } from "./loaders/user";
export { configure } from "./mod";
export { NUVEMSHOP_REGISTRY_ENTRY } from "./registry";
export { sessionCustomerId } from "./store";
export { AccountError } from "./utils/account";
export { createNuvemshopFetch } from "./utils/instrumentedFetch";
export { nuvemshopOperationRouter } from "./utils/operationRouter";
export { nuvemshopSitemap } from "./utils/sitemap";
Expand Down
12 changes: 12 additions & 0 deletions packages/apps-nuvemshop/src/loaders/account/addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { listAddresses, requireCustomerId } from "../../utils/accountData";
import type { Address } from "../../utils/orders";

/**
* @title Nuvemshop - Customer addresses
* @description The logged-in buyer's saved addresses (id taken from the store session).
*/
export default async function addresses(_props: unknown): Promise<Address[]> {
return listAddresses(await requireCustomerId());
}

export const cache = "no-store";
17 changes: 17 additions & 0 deletions packages/apps-nuvemshop/src/loaders/account/order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getOrder, requireCustomerId } from "../../utils/accountData";
import type { OrderDetail } from "../../utils/orders";

export interface Props {
/** @title Order id */
orderId: number | string;
}

/**
* @title Nuvemshop - Customer order
* @description One order of the logged-in buyer. Not-yours is indistinguishable from not-found (404).
*/
export default async function order(props: Props): Promise<OrderDetail> {
return getOrder(await requireCustomerId(), props?.orderId);
}

export const cache = "no-store";
19 changes: 19 additions & 0 deletions packages/apps-nuvemshop/src/loaders/account/orders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { listOrders, requireCustomerId } from "../../utils/accountData";
import type { Order } from "../../utils/orders";

export interface Props {
/** @title Page */
page?: number;
/** @title Orders per page (max 50) */
perPage?: number;
}

/**
* @title Nuvemshop - Customer orders
* @description The logged-in buyer's orders, newest first. Only orders owned by the session customer are returned.
*/
export default async function orders(props: Props): Promise<Order[]> {
return listOrders(await requireCustomerId(), props ?? {});
}

export const cache = "no-store";
11 changes: 11 additions & 0 deletions packages/apps-nuvemshop/src/loaders/account/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getProfile, type Profile, requireCustomerId } from "../../utils/accountData";

/**
* @title Nuvemshop - Customer profile
* @description The logged-in buyer's profile (id taken from the store session; 401 when logged out).
*/
export default async function profile(_props: unknown): Promise<Profile> {
return getProfile(await requireCustomerId());
}

export const cache = "no-store";
Loading
Loading