Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ This is the only path override in round one. Other Critter Stack reference archi

### Architectural non-negotiables

- Four separate services (Catalog, Inventory, Orders, Identity); Identity is a real EF-Core-backed customer registry (Open-Host Service + Published Language) but carries no authN/authZ — the frontend still sends a hardcoded `X-Customer-Id` (ADR 009)
- Four separate services (Catalog, Inventory, Orders, Identity); Identity is a real EF-Core-backed customer registry (Open-Host Service + Published Language) **and** the system's auth issuer — it uses ASP.NET Core Identity + mints a self-validated JWT the other services verify offline against a config-distributed public key (ADR 023). The frontend now authenticates and sends `Authorization: Bearer`; the `sub` claim is the trust boundary. `X-Customer-Id` survives only as a dev-only fallback on Orders (DEBT-tracked for removal). AuthZ (roles/policies) is still deferred (ADR 009/023)
- Cross-service communication via Wolverine over RabbitMQ; no synchronous service-to-service HTTP
- Shared PostgreSQL with schema-per-service
- Process Manager via Handlers pattern for the Order aggregate (ADR 007); convention `Wolverine.Saga` is an additive counterpart used elsewhere (Inventory's `Replenishment`, slices 2.5–2.7), not a replacement
Expand Down
13 changes: 11 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,20 @@
<PackageVersion Include="WolverineFx.RuntimeCompilation" Version="6.16.0" />
</ItemGroup>
<!-- EF Core provider — the Identity spike runs Wolverine over EF Core / Npgsql instead of Marten
(the persistence-agnostic teaching beat; a data store per ADR 009, NOT an auth provider).
.NET 10 / EF Core 10 line. -->
(the persistence-agnostic teaching beat; a data store per ADR 009). .NET 10 / EF Core 10 line. -->
<ItemGroup>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<!-- Real authentication for Identity (ADR 023). ASP.NET Core Identity is the user store + credential
handling (UserManager/SignInManager) on the same EF-Core/`identity` schema; JwtBearer provides BOTH
the resource servers' offline token validation AND — via its bundled Microsoft.IdentityModel.* stack
(JsonWebTokenHandler, SecurityTokenDescriptor) — Identity's token MINTING, so issuer and validator
share one IdentityModel version line (no standalone JsonWebTokens pin to skew). .NET 10 line, pinned
with the aspnetcore runtime (10.0.9). -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
</ItemGroup>
<!-- CritterWatch monitoring console + per-service client (out-of-band trial). 1.0.0-beta.1 targets
WolverineFx(.Marten) 6.16.0, which is why the Wolverine block above is pinned to 6.16.0 (see that
comment). Bump the two together, never apart. -->
Expand Down
63 changes: 37 additions & 26 deletions client/src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,58 @@ import { z } from "zod";

import {
ApiError,
CUSTOMER_ID_HEADER,
NotFoundError,
deleteCommand,
fetchParsed,
postCommand,
type RequestContext,
} from "@/api/client";

const schema = z.object({ id: z.string() });

const ctx = { customerId: "customer-7" };
// An authenticated context carries the JWT (for the Authorization header) + the id (for cache keys).
const ctx: RequestContext = { token: "jwt-abc", customerId: "customer-7" };
// A logged-out context (e.g. Catalog browsing) carries no token — no auth header is sent.
const anonCtx: RequestContext = { token: null, customerId: "" };

function authHeader(init: RequestInit): string | undefined {
return (init.headers as Record<string, string>).Authorization;
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("fetchParsed", () => {
it("sets the X-Customer-Id header from the request context and parses the body", async () => {
it("sets the Authorization: Bearer header from the token and parses the body", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: "crit-001" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);

const result = await fetchParsed("http://orders/carts/mine", schema, {
customerId: "customer-7",
});
const result = await fetchParsed("http://orders/carts/mine", schema, ctx);

expect(result).toEqual({ id: "crit-001" });
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect((init.headers as Record<string, string>)[CUSTOMER_ID_HEADER]).toBe("customer-7");
expect(authHeader(fetchMock.mock.calls[0][1] as RequestInit)).toBe("Bearer jwt-abc");
});

it("sends no Authorization header when the context has no token (public reads)", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: "crit-001" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);

await fetchParsed("http://catalog/products", schema, anonCtx);

expect(authHeader(fetchMock.mock.calls[0][1] as RequestInit)).toBeUndefined();
});

it("throws NotFoundError on 404 so callers can treat it as a domain-empty state", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 })));

await expect(
fetchParsed("http://orders/carts/mine", schema, { customerId: "customer-7" }),
).rejects.toBeInstanceOf(NotFoundError);
await expect(fetchParsed("http://orders/carts/mine", schema, ctx)).rejects.toBeInstanceOf(
NotFoundError,
);
});

it("rejects when the body fails the Zod boundary parse", async () => {
Expand All @@ -48,34 +63,30 @@ describe("fetchParsed", () => {
vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 42 }), { status: 200 })),
);

await expect(
fetchParsed("http://orders/carts/mine", schema, { customerId: "customer-7" }),
).rejects.toThrow();
await expect(fetchParsed("http://orders/carts/mine", schema, ctx)).rejects.toThrow();
});
});

describe("postCommand", () => {
it("POSTs the body with the identity header and parses the response through the schema", async () => {
it("POSTs the body with the bearer token and parses the response through the schema", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: "cart-1" }), { status: 201 }));
vi.stubGlobal("fetch", fetchMock);

const result = await postCommand("http://orders/carts/c-7/items", { sku: "crit-001" }, ctx, schema);
const result = await postCommand("http://orders/carts/mine/items", { sku: "crit-001" }, ctx, schema);

expect(result).toEqual({ id: "cart-1" });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>)[CUSTOMER_ID_HEADER]).toBe("customer-7");
expect(authHeader(init)).toBe("Bearer jwt-abc");
expect(JSON.parse(init.body as string)).toEqual({ sku: "crit-001" });
});

it("returns void and never reads the body when no schema is given (a 204 has none)", async () => {
// A real 204 Response has an empty body; calling `.json()` on it would throw. Asserting the resolve
// proves the no-schema path skips the parse entirely.
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));

const result = await postCommand("http://orders/carts/c-7/items/crit-001/quantity", { newQuantity: 3 }, ctx);
const result = await postCommand("http://orders/carts/mine/items/crit-001/quantity", { newQuantity: 3 }, ctx);

expect(result).toBeUndefined();
});
Expand All @@ -84,28 +95,28 @@ describe("postCommand", () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 409 })));

await expect(
postCommand("http://orders/carts/c-7/items/crit-001/quantity", { newQuantity: 3 }, ctx),
postCommand("http://orders/carts/mine/items/crit-001/quantity", { newQuantity: 3 }, ctx),
).rejects.toBeInstanceOf(ApiError);
});
});

describe("deleteCommand", () => {
it("issues a DELETE with the identity header and resolves on a 204 (no body to parse)", async () => {
it("issues a DELETE with the bearer token and resolves on a 204 (no body to parse)", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);

await expect(deleteCommand("http://orders/carts/c-7/items/crit-001", ctx)).resolves.toBeUndefined();
await expect(deleteCommand("http://orders/carts/mine/items/crit-001", ctx)).resolves.toBeUndefined();

const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://orders/carts/c-7/items/crit-001");
expect(url).toBe("http://orders/carts/mine/items/crit-001");
expect(init.method).toBe("DELETE");
expect((init.headers as Record<string, string>)[CUSTOMER_ID_HEADER]).toBe("customer-7");
expect(authHeader(init)).toBe("Bearer jwt-abc");
});

it("throws ApiError on a non-2xx response", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 409 })));

await expect(deleteCommand("http://orders/carts/c-7/items/crit-001", ctx)).rejects.toBeInstanceOf(
await expect(deleteCommand("http://orders/carts/mine/items/crit-001", ctx)).rejects.toBeInstanceOf(
ApiError,
);
});
Expand Down
68 changes: 34 additions & 34 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import type { ZodType } from "zod";

import { useCurrentCustomer } from "@/identity/useCurrentCustomer";
import { useAuth } from "@/identity/useCurrentCustomer";

// The shared HTTP client for the three Wolverine.Http services. Two CritterMart conventions live here:
// The shared HTTP client for the Wolverine.Http services. Two CritterMart conventions live here:
//
// - **Convention 4 — the X-Customer-Id header.** Every request carries the current customer's identity
// ambiently in this header, sourced from the useCurrentCustomer seam (ADR 009). The header name is
// the single Polecat-promotion swap point; call sites never restate identity in URLs or query params.
// - **Convention 4 — the Authorization: Bearer header (ADR 023).** Every request that needs identity
// carries the authenticated customer's JWT in this header, sourced from the auth seam (useAuth). This
// replaced the round-one X-Customer-Id header: the `sub` claim of the verified token is now the trust
// boundary (slice 5.10). Public reads (Catalog browsing) carry no token and send no auth header. The
// header is the single cutover point; call sites never restate identity in URLs or query params.
//
// - **Convention 2 — Zod at the wire boundary.** Every response body is `parse()`d through a Zod schema
// before the app trusts it. Three independently-deployed services (ADR 006, no BFF) are three
// contract surfaces that can drift; the boundary parse is the only place a drift surfaces — loud and
// located, never a silent `undefined` deep in a component.
export const CUSTOMER_ID_HEADER = "X-Customer-Id";
// before the app trusts it. Independently-deployed services (ADR 006, no BFF) are separate contract
// surfaces that can drift; the boundary parse is the only place a drift surfaces — loud and located.

// A non-2xx response. `NotFoundError` is split out because `404` is frequently a *domain state*, not a
// failure — e.g. `GET /carts/mine` 404s as "this customer has no open cart" (render an empty cart), and
// a query can map that to data rather than an error boundary.
// failure — e.g. `GET /carts/mine` 404s as "this customer has no open cart" (render an empty cart).
export class ApiError extends Error {
readonly status: number;

Expand All @@ -35,11 +34,20 @@ export class NotFoundError extends ApiError {
}

export interface RequestContext {
/** The current customer's id — set on the X-Customer-Id header. Sourced from the identity seam. */
/** The authenticated customer's JWT for the Authorization header, or null when logged out. */
token: string | null;
/** The current customer's id (the token's `sub`), for cache keys + optimistic updates. "" when logged out. */
customerId: string;
}

// Fetch a JSON resource, set the identity header, and parse the body through `schema`. Pure (no React),
// The auth header for a request: a Bearer token when the customer is authenticated, nothing otherwise
// (so Catalog's public reads work logged-out). The `sub` claim inside the token is what the resource
// server trusts — the id never rides a header of its own anymore.
function authHeaders(ctx: RequestContext): Record<string, string> {
return ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {};
}

// Fetch a JSON resource, attach the bearer token, and parse the body through `schema`. Pure (no React),
// so query/mutation factories can call it and tests can drive it with a literal context + mocked fetch.
// A `404` throws `NotFoundError` so callers can branch on the domain-empty case; any other non-2xx
// throws `ApiError`.
Expand All @@ -51,7 +59,7 @@ export async function fetchParsed<T>(
const response = await fetch(url, {
headers: {
Accept: "application/json",
[CUSTOMER_ID_HEADER]: ctx.customerId,
...authHeaders(ctx),
},
});

Expand All @@ -65,17 +73,10 @@ export async function fetchParsed<T>(
return schema.parse(await response.json());
}

// POST a command body, set the identity header, and (when the command answers with one) parse the
// response body through `schema`. The command-side counterpart of `fetchParsed`: same X-Customer-Id seam
// (Convention 4) and same boundary parse (Convention 2 — a command's *response* is a wire surface that can
// drift too), plus a JSON body and `Content-Type`. Pure (no React), so mutation factories can call it and
// tests can drive it with a literal context + mocked fetch. Any non-2xx throws `ApiError` (a command has
// no domain-empty 404 case).
//
// `schema` is **optional**, and the overloads make the return type follow it: a command that returns a body
// (W1 `AddToCart` → 201 `{ cartId }`) passes a schema and gets the parsed `T`; a command that returns
// `204 No Content` (slice 3.3 change-qty) omits it and gets `void`. Omitting it also skips the `.json()`
// call — reading a body off a 204 throws "Unexpected end of JSON input", so the no-schema path must not.
// POST a command body, attach the bearer token, and (when the command answers with one) parse the
// response body through `schema`. `schema` is optional, and the overloads make the return type follow it:
// a command that returns a body passes a schema and gets the parsed `T`; a `204 No Content` command omits
// it and gets `void` (omitting it also skips the `.json()` call — reading a body off a 204 throws).
export function postCommand<T>(
url: string,
body: unknown,
Expand All @@ -94,7 +95,7 @@ export async function postCommand<T>(
headers: {
Accept: "application/json",
"Content-Type": "application/json",
[CUSTOMER_ID_HEADER]: ctx.customerId,
...authHeaders(ctx),
},
body: JSON.stringify(body),
});
Expand All @@ -108,15 +109,14 @@ export async function postCommand<T>(
return schema.parse(await response.json());
}

// DELETE a route-keyed resource, setting the identity header. The SPA's first DELETE (slice 3.2 remove —
// both identifiers ride the route, there is no body either way). Like `postCommand` with no schema, the
// DELETE a route-keyed resource, attaching the bearer token. Like `postCommand` with no schema, the
// contract is `204 No Content`, so there is nothing to parse; a non-2xx throws `ApiError`. Pure (no React).
export async function deleteCommand(url: string, ctx: RequestContext): Promise<void> {
const response = await fetch(url, {
method: "DELETE",
headers: {
Accept: "application/json",
[CUSTOMER_ID_HEADER]: ctx.customerId,
...authHeaders(ctx),
},
});

Expand All @@ -125,10 +125,10 @@ export async function deleteCommand(url: string, ctx: RequestContext): Promise<v
}
}

// The React binding: builds the per-request context from the identity seam. Components and query hooks
// call this to get the `RequestContext` they hand to `fetchParsed`, so identity always flows from the
// one seam and never from a hardcoded value.
// The React binding: builds the per-request context from the auth seam. Components and query hooks call
// this to get the `RequestContext` they hand to `fetchParsed`, so identity always flows from the one seam
// (the token for the header, the id for cache keys) and never from a hardcoded value.
export function useApiContext(): RequestContext {
const customerId = useCurrentCustomer();
return { customerId };
const { token, customerId } = useAuth();
return { token, customerId: customerId ?? "" };
}
13 changes: 8 additions & 5 deletions client/src/cart/cartMutations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { CartView } from "@/cart/cartSchema";
import { CurrentCustomerProvider } from "@/identity/useCurrentCustomer";

const CUSTOMER = "customer-demo";
const TOKEN = "jwt-demo";

// A cart with one line — the snapshot for the rollback / merge tests.
const oneLineCart: CartView = {
Expand Down Expand Up @@ -73,7 +74,9 @@ describe("addLineToCart", () => {
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<CurrentCustomerProvider customerId={CUSTOMER}>{children}</CurrentCustomerProvider>
<CurrentCustomerProvider customerId={CUSTOMER} token={TOKEN}>
{children}
</CurrentCustomerProvider>
</QueryClientProvider>
);
}
Expand Down Expand Up @@ -119,7 +122,7 @@ describe("useAddToCart", () => {
expect(queryClient.getQueryData<CartView>(cartKey)?.lines).toHaveLength(1);
});

it("POSTs to the header-keyed URL (/carts/mine) with the X-Customer-Id header and a `productSnapshot` body", async () => {
it("POSTs to the header-keyed URL (/carts/mine) with the Authorization bearer token and a `productSnapshot` body", async () => {
const queryClient = freshClient();
const fetchMock = vi
.fn()
Expand All @@ -137,7 +140,7 @@ describe("useAddToCart", () => {
expect(url).toContain("/carts/mine/items"); // header-keyed (change 032), NOT /carts/{customerId}
expect(url).not.toContain(CUSTOMER); // identity rides the header, never the path
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>)["X-Customer-Id"]).toBe(CUSTOMER);
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${TOKEN}`);
const body = JSON.parse(init.body as string);
expect(body.productSnapshot).toEqual({ name: "Nebula Newt", price: 18.0 }); // the exact field name the backend binds
});
Expand Down Expand Up @@ -250,7 +253,7 @@ describe("useRemoveCartItem", () => {
expect(url).toContain("/carts/mine/items/crit-001"); // header-keyed (change 032); only {sku} on the path
expect(url).not.toContain(CUSTOMER);
expect(init.method).toBe("DELETE");
expect((init.headers as Record<string, string>)["X-Customer-Id"]).toBe(CUSTOMER);
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${TOKEN}`);
});
});

Expand Down Expand Up @@ -311,7 +314,7 @@ describe("useChangeCartItemQuantity", () => {
expect(url).toContain("/carts/mine/items/crit-001/quantity"); // header-keyed (change 032); only {sku} on the path
expect(url).not.toContain(CUSTOMER);
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>)["X-Customer-Id"]).toBe(CUSTOMER);
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${TOKEN}`);
expect(JSON.parse(init.body as string)).toEqual({ newQuantity: 5 });
});
});
2 changes: 1 addition & 1 deletion client/src/cart/cartQueries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { fetchMyCart, selectCartLineCount, cartKeys } from "@/cart/cartQueries";
import type { CartView } from "@/cart/cartSchema";

const ctx = { customerId: "customer-demo" };
const ctx = { token: "jwt-demo", customerId: "customer-demo" };

const openCart = {
id: "cart-7f3a",
Expand Down
Loading