diff --git a/.env.example b/.env.example index 3e041e8..d330398 100644 --- a/.env.example +++ b/.env.example @@ -13,15 +13,26 @@ # infra/cdk — you don't create any of them by hand in the console, # except the one explicit step called out below. # -# - Lambda 8 functions (write-intake, conflict-resolver, +# - Lambda 10 functions (write-intake, conflict-resolver, # sync/audit query, ws connect/disconnect, # conflict-resolve, products/categories/ -# suppliers CRUD, checkout) +# suppliers CRUD, checkout, plus Cognito's +# postConfirmation trigger and the staff-invite +# admin endpoint) # - DynamoDB 8 tables, on-demand billing (inventory_records, # write_dedup, audit_log, ws_connections, # products, categories, suppliers, orders) # - SQS 1 FIFO write queue + 1 FIFO dead-letter queue -# - API Gateway 1 HTTP API (REST) + 1 WebSocket API +# - API Gateway 1 HTTP API (REST) + 1 WebSocket API — every REST +# route sits behind a Cognito JWT authorizer; the +# WebSocket $connect route verifies the same token +# manually (see apps/api/src/handlers/wsConnect.ts) +# - Cognito 1 User Pool (self-signup, email/password), 3 +# groups (owner/manager/counter_staff) — see +# infra/cdk/lib/constructs/Auth.ts for the full +# signup/invite flow. No console step needed; +# self-signup and staff invites both work +# out of the box. # - CloudWatch custom metrics + a dashboard (Observability construct) # - Bedrock InvokeModel on anthropic.claude-haiku-4-5-20251001-v1:0 # (via an inference profile, not a bare model ID — @@ -40,9 +51,10 @@ # - IAM roles/policies for the above, all scoped # least-privilege by the CDK constructs themselves # -# Not needed for the Tier-1 core engine or the Tier-2 CRUD/checkout -# layer (Cognito, SNS/SES, Transcribe, EventBridge, Rekognition) are -# roadmap items — see README's "Where this goes next". +# Not needed for the Tier-1 core engine or the Tier-2 CRUD/checkout layer +# (SNS/SES, Transcribe, EventBridge, Rekognition) are roadmap items — see +# README's "Where this goes next". Cognito (above) is no longer on that +# list — real auth shipped. # # Frontend hosting (AWS Amplify Hosting) is a separate deploy, not part # of `cdk deploy` — see "Deploying" below. @@ -99,10 +111,16 @@ SUPPLIERS_TABLE_NAME= # Only needed for scripts/simulate-conflict.ts, against a real deployed # API (defaults to the local dev server's http://localhost:4000 if unset). +# Known gap: against the real deployed stack (Cognito JWT authorizer in +# front of every route), this script has no login flow of its own yet — +# it works unmodified against the local dev server (no Cognito there), +# but a real deployed run needs a manually-obtained ID token passed some +# other way until this script gains its own sign-in step. API_BASE_URL= # Decorative today, not actually enforced server-side (see # 07-EDGE-CASES.md edge case E-1, a stated known limitation) — set to # anything non-empty; it's sent as the x-api-key header but no Lambda -# or API Gateway usage plan currently validates it. +# or API Gateway usage plan currently validates it. Real per-shop +# authorization is now the Cognito JWT, not this header. API_KEY=local-e2e-key SHOP_ID=demo-shop diff --git a/apps/api/package.json b/apps/api/package.json index 4b80dbc..4842401 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,11 +14,13 @@ "@aws-lambda-powertools/metrics": "^2.11.0", "@aws-sdk/client-apigatewaymanagementapi": "^3.700.0", "@aws-sdk/client-bedrock-runtime": "^3.700.0", + "@aws-sdk/client-cognito-identity-provider": "^3.700.0", "@aws-sdk/client-dynamodb": "^3.700.0", "@aws-sdk/client-secrets-manager": "^3.700.0", "@aws-sdk/client-sqs": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.700.0", - "@stocksync/core": "*" + "@stocksync/core": "*", + "aws-jwt-verify": "^4.0.1" }, "devDependencies": { "@types/aws-lambda": "^8.10.146", diff --git a/apps/api/src/handlers/auditQuery.ts b/apps/api/src/handlers/auditQuery.ts index 8576d70..13b8bdf 100644 --- a/apps/api/src/handlers/auditQuery.ts +++ b/apps/api/src/handlers/auditQuery.ts @@ -2,6 +2,7 @@ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda import { QueryCommand } from "@aws-sdk/lib-dynamodb"; import type { AuditHistoryEntry } from "@stocksync/core"; import { ddb } from "../lib/dynamo"; +import { getAuthContext } from "../lib/authContext"; import { inventoryRecordKey } from "../lib/inventoryRecord"; function invalidPayload(message: string): APIGatewayProxyResultV2 { @@ -23,7 +24,7 @@ function timestampFromSortKey(sk: string): string { */ export async function handler(event: APIGatewayProxyEventV2): Promise { const itemId = event.pathParameters?.item_id; - const shopId = event.queryStringParameters?.shop_id; + const shopId = getAuthContext(event)?.shopId ?? event.queryStringParameters?.shop_id; if (!itemId) return invalidPayload("item_id is required"); if (!shopId) return invalidPayload("shop_id is required"); diff --git a/apps/api/src/handlers/checkout.ts b/apps/api/src/handlers/checkout.ts index 79ae552..f25d3b8 100644 --- a/apps/api/src/handlers/checkout.ts +++ b/apps/api/src/handlers/checkout.ts @@ -3,6 +3,7 @@ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda import { PutCommand } from "@aws-sdk/lib-dynamodb"; import type { TransactionInput } from "@stocksync/core"; import { ddb } from "../lib/dynamo"; +import { getAuthContext } from "../lib/authContext"; import { handler as writeIntakeHandler } from "./writeIntake"; interface LineItemInput { @@ -68,7 +69,10 @@ export async function handler(event: APIGatewayProxyEventV2): Promise { + if (event.triggerSource !== "PostConfirmation_ConfirmSignUp") return event; + + await cognito.send( + new AdminAddUserToGroupCommand({ + UserPoolId: event.userPoolId, + Username: event.userName, + GroupName: "owner", + }), + ); + + return event; +} diff --git a/apps/api/src/handlers/staffInvite.ts b/apps/api/src/handlers/staffInvite.ts new file mode 100644 index 0000000..1830b09 --- /dev/null +++ b/apps/api/src/handlers/staffInvite.ts @@ -0,0 +1,70 @@ +import { + AdminAddUserToGroupCommand, + AdminCreateUserCommand, + CognitoIdentityProviderClient, +} from "@aws-sdk/client-cognito-identity-provider"; +import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; +import { canInviteStaff, getAuthContext } from "../lib/authContext"; + +const cognito = new CognitoIdentityProviderClient({}); +const USER_POOL_ID = process.env.USER_POOL_ID ?? ""; + +const INVITABLE_ROLES = ["manager", "counter_staff"] as const; +type InvitableRole = (typeof INVITABLE_ROLES)[number]; + +function invalidPayload(message: string): APIGatewayProxyResultV2 { + return { statusCode: 400, body: JSON.stringify({ error: "invalid_payload", message }) }; +} + +function forbidden(message: string): APIGatewayProxyResultV2 { + return { statusCode: 403, body: JSON.stringify({ error: "forbidden", message }) }; +} + +/** + * POST /staff (owner-only, Auth.ts's JWT authorizer sits in front of every + * route including this one). Creates a new Cognito user already assigned + * to the *inviting* owner's shop_id and the requested role — never a + * client-supplied shop_id, which is the one thing that would make this + * exploitable across shops. AdminCreateUser bypasses ConfirmSignUp, so + * postConfirmation.ts's trigger never fires for these users — the group + * is assigned here, synchronously, instead. + */ +export async function handler(event: APIGatewayProxyEventV2): Promise { + const auth = getAuthContext(event); + if (!auth) return forbidden("authentication required"); + if (!canInviteStaff(auth.role)) return forbidden("only the shop owner can invite staff"); + + let body: Record; + try { + body = JSON.parse(event.body ?? "{}") as Record; + } catch { + return invalidPayload("request body must be valid JSON"); + } + + const email = typeof body.email === "string" && body.email.length > 0 ? body.email : undefined; + if (!email) return invalidPayload("email is required"); + + const role = body.role; + if (typeof role !== "string" || !INVITABLE_ROLES.includes(role as InvitableRole)) { + return invalidPayload(`role must be one of: ${INVITABLE_ROLES.join(", ")}`); + } + + await cognito.send( + new AdminCreateUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + UserAttributes: [ + { Name: "email", Value: email }, + { Name: "email_verified", Value: "true" }, + { Name: "custom:shop_id", Value: auth.shopId }, + ], + DesiredDeliveryMediums: ["EMAIL"], + }), + ); + + await cognito.send( + new AdminAddUserToGroupCommand({ UserPoolId: USER_POOL_ID, Username: email, GroupName: role }), + ); + + return { statusCode: 201, body: JSON.stringify({ email, role, shop_id: auth.shopId }) }; +} diff --git a/apps/api/src/handlers/syncQuery.ts b/apps/api/src/handlers/syncQuery.ts index f682966..edbc303 100644 --- a/apps/api/src/handlers/syncQuery.ts +++ b/apps/api/src/handlers/syncQuery.ts @@ -2,6 +2,7 @@ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda import { QueryCommand } from "@aws-sdk/lib-dynamodb"; import type { SyncItem } from "@stocksync/core"; import { ddb } from "../lib/dynamo"; +import { getAuthContext } from "../lib/authContext"; import type { InventoryRecordItem } from "../lib/inventoryRecord"; function invalidPayload(message: string): APIGatewayProxyResultV2 { @@ -49,7 +50,7 @@ function toSyncItem(item: InventoryRecordItem): SyncItem { * personalization, not because the response differs by counter today. */ export async function handler(event: APIGatewayProxyEventV2): Promise { - const shopId = event.queryStringParameters?.shop_id; + const shopId = getAuthContext(event)?.shopId ?? event.queryStringParameters?.shop_id; const counterId = event.queryStringParameters?.counter_id; if (!shopId) return invalidPayload("shop_id is required"); diff --git a/apps/api/src/handlers/writeIntake.ts b/apps/api/src/handlers/writeIntake.ts index fc596d0..4b2982b 100644 --- a/apps/api/src/handlers/writeIntake.ts +++ b/apps/api/src/handlers/writeIntake.ts @@ -8,6 +8,7 @@ import { ddb } from "../lib/dynamo"; import { sqs } from "../lib/sqs"; import { createLogger } from "../lib/logger"; import { createMetrics } from "../lib/metrics"; +import { getAuthContext } from "../lib/authContext"; const logger = createLogger("write-intake"); // Module-level so a single request's metrics (one or more transactions @@ -234,7 +235,13 @@ export async function handler(event: APIGatewayProxyEventV2): Promise { const connectionId = event.requestContext.connectionId; - const shopId = event.queryStringParameters?.shop_id; const counterId = event.queryStringParameters?.counter_id; + let shopId = event.queryStringParameters?.shop_id; + + if (verifier) { + const token = event.queryStringParameters?.token; + if (!token) { + logger.warn("rejected $connect missing token", { connectionId }); + return { statusCode: 401, body: "token query parameter is required" }; + } + try { + const payload = await verifier.verify(token); + const claimedShopId = payload["custom:shop_id"]; + if (typeof claimedShopId !== "string" || !claimedShopId) { + logger.warn("rejected $connect token missing custom:shop_id", { connectionId }); + return { statusCode: 401, body: "token is missing custom:shop_id" }; + } + // The verified token's shop_id is authoritative, same pattern as + // authContext.ts for every REST route — never trust the query + // param's shop_id once a token is required. + shopId = claimedShopId; + } catch (error) { + logger.warn("rejected $connect invalid token", { connectionId, error }); + return { statusCode: 401, body: "invalid token" }; + } + } + if (!shopId || !counterId) { logger.warn("rejected $connect missing shop_id/counter_id", { connectionId }); return { statusCode: 400, body: "shop_id and counter_id query parameters are required" }; diff --git a/apps/api/src/lib/authContext.ts b/apps/api/src/lib/authContext.ts new file mode 100644 index 0000000..df56dd2 --- /dev/null +++ b/apps/api/src/lib/authContext.ts @@ -0,0 +1,68 @@ +import type { APIGatewayProxyEventV2 } from "aws-lambda"; + +export type Role = "owner" | "manager" | "counter_staff"; +const ROLE_PRIORITY: Role[] = ["owner", "manager", "counter_staff"]; + +export interface AuthContext { + shopId: string; + role: Role; + sub: string; + email?: string; +} + +function parseGroups(raw: string | undefined): Role[] { + if (!raw) return []; + // API Gateway's HTTP API JWT authorizer flattens a JSON-array claim like + // `cognito:groups` into a comma-separated string (no brackets in the + // documented case, but stripping them anyway costs nothing and guards + // against a differently-shaped claim reaching here unverified). + return raw + .replace(/^\[|\]$/g, "") + .split(",") + .map((group) => group.trim()) + .filter((group): group is Role => (ROLE_PRIORITY as string[]).includes(group)); +} + +/** + * Reads the identity API Gateway's JWT authorizer has already verified + * (infra/cdk/lib/constructs/Auth.ts) — this function never itself verifies + * a token, that's the authorizer's job before the Lambda is even invoked. + * + * Returns undefined when no authorizer context is present, which is + * exactly the case for local dev (src/local/server.ts has no Cognito + * integration) and every pre-auth test — callers fall back to whatever + * shop_id the request body/query itself supplies, unchanged from + * pre-auth behavior. In production every route sits behind the + * authorizer, so this is never undefined there. + */ +export function getAuthContext(event: APIGatewayProxyEventV2): AuthContext | undefined { + const claims = ( + event.requestContext as unknown as { authorizer?: { jwt?: { claims?: Record } } } | undefined + )?.authorizer?.jwt?.claims; + if (!claims) return undefined; + + const shopId = claims["custom:shop_id"]; + const sub = claims.sub; + if (!shopId || !sub) return undefined; + + const groups = parseGroups(claims["cognito:groups"]); + // Highest-privilege group wins if a user somehow ends up in more than + // one — shouldn't happen given how postConfirmation.ts/staffInvite.ts + // assign groups, but resolving deterministically here is cheap insurance + // against silently granting the wrong role. + const role = ROLE_PRIORITY.find((candidate) => groups.includes(candidate)) ?? "counter_staff"; + + return { shopId, role, sub, email: claims.email }; +} + +const CATALOG_WRITE_ROLES: Role[] = ["owner", "manager"]; + +/** Products/categories/suppliers create/update/delete — counter staff browse but never edit the catalog. */ +export function canWriteCatalog(role: Role): boolean { + return CATALOG_WRITE_ROLES.includes(role); +} + +/** Only the shop owner can invite new staff accounts. */ +export function canInviteStaff(role: Role): boolean { + return role === "owner"; +} diff --git a/apps/api/src/lib/crudTable.ts b/apps/api/src/lib/crudTable.ts index f0e356c..418d8bd 100644 --- a/apps/api/src/lib/crudTable.ts +++ b/apps/api/src/lib/crudTable.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb"; import { DeleteCommand, PutCommand, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { canWriteCatalog, getAuthContext } from "./authContext"; import { ddb } from "./dynamo"; /** @@ -15,11 +16,15 @@ import { ddb } from "./dynamo"; * per verb) — GET/POST/PUT/DELETE on the same Lambda, same pattern * every resource in this file follows. * - * Auth note: scoped by whatever `shop_id` the caller supplies, exactly - * like every Tier-1 endpoint — there is no Cognito/JWT layer yet (that's - * a separate, still-roadmap item; see docs/general/14-build-order-while-waiting.md). - * Not a regression: nothing in Tier 1 enforces per-shop authorization - * either (edge case E-1, a stated, known limitation). + * Auth: scoped by the verified shop_id from the request's JWT claims + * (authContext.ts) when the API Gateway JWT authorizer is in front of the + * request — the authoritative value, never the client-supplied shop_id, so + * one tenant can never read or write another's catalog. Falls back to the + * client-supplied shop_id only when no authorizer context is present, + * which is exactly local dev (src/local/server.ts has no Cognito + * integration) — same as every other handler in this codebase. Only + * owner/manager may create/update/delete; counter_staff can list/browse + * but never edit the catalog (see authContext.ts's canWriteCatalog). */ export interface CrudResourceConfig { tableName: () => string; @@ -43,6 +48,15 @@ function notFound(message: string): APIGatewayProxyResultV2 { return { statusCode: 404, body: JSON.stringify({ error: "not_found", message }) }; } +function forbidden(message: string): APIGatewayProxyResultV2 { + return { statusCode: 403, body: JSON.stringify({ error: "forbidden", message }) }; +} + +/** The verified shop_id from the JWT, or the client-supplied one when there's no authorizer context (local dev). */ +function resolveShopId(event: APIGatewayProxyEventV2, clientSupplied: string | undefined): string | undefined { + return getAuthContext(event)?.shopId ?? clientSupplied; +} + function requireNonEmptyString(value: unknown): string | undefined { if (value === undefined) return undefined; return typeof value === "string" && value.length > 0 ? value : undefined; @@ -67,7 +81,7 @@ function fromItem(config: CrudResourceConfig, item: Record): Re } async function list(config: CrudResourceConfig, event: APIGatewayProxyEventV2): Promise { - const shopId = requireNonEmptyString(event.queryStringParameters?.shop_id); + const shopId = resolveShopId(event, requireNonEmptyString(event.queryStringParameters?.shop_id)); if (!shopId) return invalidPayload("shop_id is required"); const filterValue = config.listFilterField @@ -94,6 +108,9 @@ async function list(config: CrudResourceConfig, event: APIGatewayProxyEventV2): } async function create(config: CrudResourceConfig, event: APIGatewayProxyEventV2): Promise { + const auth = getAuthContext(event); + if (auth && !canWriteCatalog(auth.role)) return forbidden("only owner/manager can create catalog entries"); + let body: Record; try { body = JSON.parse(event.body ?? "{}") as Record; @@ -101,7 +118,7 @@ async function create(config: CrudResourceConfig, event: APIGatewayProxyEventV2) return invalidPayload("request body must be valid JSON"); } - const shopId = requireNonEmptyString(body.shop_id); + const shopId = resolveShopId(event, requireNonEmptyString(body.shop_id)); if (!shopId) return invalidPayload("shop_id is required"); const row: Record = {}; @@ -127,6 +144,9 @@ async function update( event: APIGatewayProxyEventV2, id: string, ): Promise { + const auth = getAuthContext(event); + if (auth && !canWriteCatalog(auth.role)) return forbidden("only owner/manager can update catalog entries"); + let body: Record; try { body = JSON.parse(event.body ?? "{}") as Record; @@ -134,7 +154,7 @@ async function update( return invalidPayload("request body must be valid JSON"); } - const shopId = requireNonEmptyString(body.shop_id); + const shopId = resolveShopId(event, requireNonEmptyString(body.shop_id)); if (!shopId) return invalidPayload("shop_id is required"); const allowedFields = [...config.requiredFields, ...config.optionalFields]; @@ -175,7 +195,10 @@ async function remove( event: APIGatewayProxyEventV2, id: string, ): Promise { - const shopId = requireNonEmptyString(event.queryStringParameters?.shop_id); + const auth = getAuthContext(event); + if (auth && !canWriteCatalog(auth.role)) return forbidden("only owner/manager can delete catalog entries"); + + const shopId = resolveShopId(event, requireNonEmptyString(event.queryStringParameters?.shop_id)); if (!shopId) return invalidPayload("shop_id is required"); try { diff --git a/apps/api/test/authContext.test.ts b/apps/api/test/authContext.test.ts new file mode 100644 index 0000000..e67781e --- /dev/null +++ b/apps/api/test/authContext.test.ts @@ -0,0 +1,66 @@ +import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import { describe, expect, it } from "vitest"; +import { canInviteStaff, canWriteCatalog, getAuthContext } from "../src/lib/authContext"; + +function eventWithClaims(claims: Record | undefined): APIGatewayProxyEventV2 { + return { + requestContext: claims ? { authorizer: { jwt: { claims } } } : {}, + } as unknown as APIGatewayProxyEventV2; +} + +describe("getAuthContext", () => { + it("returns undefined when there's no authorizer context (local dev / pre-auth tests)", () => { + expect(getAuthContext(eventWithClaims(undefined))).toBeUndefined(); + }); + + it("returns undefined when custom:shop_id is missing from the claims", () => { + expect(getAuthContext(eventWithClaims({ sub: "u1" }))).toBeUndefined(); + }); + + it("returns undefined when sub is missing from the claims", () => { + expect(getAuthContext(eventWithClaims({ "custom:shop_id": "shop-1" }))).toBeUndefined(); + }); + + it("extracts shopId/sub/email/role from a well-formed claims set", () => { + const ctx = getAuthContext( + eventWithClaims({ sub: "u1", "custom:shop_id": "shop-1", email: "owner@shop.com", "cognito:groups": "owner" }), + ); + expect(ctx).toEqual({ shopId: "shop-1", role: "owner", sub: "u1", email: "owner@shop.com" }); + }); + + it("defaults to counter_staff when cognito:groups is absent (never silently grants a higher role)", () => { + const ctx = getAuthContext(eventWithClaims({ sub: "u1", "custom:shop_id": "shop-1" })); + expect(ctx?.role).toBe("counter_staff"); + }); + + it("picks the highest-privilege group when a user is somehow in more than one", () => { + const ctx = getAuthContext( + eventWithClaims({ sub: "u1", "custom:shop_id": "shop-1", "cognito:groups": "counter_staff,owner" }), + ); + expect(ctx?.role).toBe("owner"); + }); + + it("tolerates a bracketed groups claim (defensive — API Gateway's documented format has no brackets, but parsing shouldn't break if one shows up)", () => { + const ctx = getAuthContext(eventWithClaims({ sub: "u1", "custom:shop_id": "shop-1", "cognito:groups": "[manager]" })); + expect(ctx?.role).toBe("manager"); + }); +}); + +describe("canWriteCatalog", () => { + it("allows owner and manager", () => { + expect(canWriteCatalog("owner")).toBe(true); + expect(canWriteCatalog("manager")).toBe(true); + }); + + it("denies counter_staff", () => { + expect(canWriteCatalog("counter_staff")).toBe(false); + }); +}); + +describe("canInviteStaff", () => { + it("allows only owner", () => { + expect(canInviteStaff("owner")).toBe(true); + expect(canInviteStaff("manager")).toBe(false); + expect(canInviteStaff("counter_staff")).toBe(false); + }); +}); diff --git a/apps/api/test/crud.test.ts b/apps/api/test/crud.test.ts index 1feb684..7e687b4 100644 --- a/apps/api/test/crud.test.ts +++ b/apps/api/test/crud.test.ts @@ -15,10 +15,18 @@ let ddb: typeof import("../src/lib/dynamo").ddb; function invoke( method: string, - options: { query?: Record; pathParams?: Record; body?: unknown } = {}, + options: { + query?: Record; + pathParams?: Record; + body?: unknown; + claims?: Record; + } = {}, ): Promise<{ statusCode: number; body: string }> { const event = { - requestContext: { http: { method } }, + requestContext: { + http: { method }, + ...(options.claims ? { authorizer: { jwt: { claims: options.claims } } } : {}), + }, queryStringParameters: options.query, pathParameters: options.pathParams, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, @@ -127,3 +135,56 @@ describe("productsCrud — the generic CRUD handler via one representative resou ); }); }); + +describe("productsCrud — authenticated (JWT authorizer present)", () => { + const OWNER_CLAIMS = { sub: "u-owner", "custom:shop_id": "auth-shop", "cognito:groups": "owner" }; + const STAFF_CLAIMS = { sub: "u-staff", "custom:shop_id": "auth-shop", "cognito:groups": "counter_staff" }; + + it("the verified JWT shop_id wins over a client-supplied one — closes the cross-tenant gap crudTable.ts used to have", async () => { + const created = await invoke("POST", { + claims: OWNER_CLAIMS, + body: { shop_id: "attacker-supplied-shop", name: "Tenant-safe item" }, + }); + expect(created.statusCode).toBe(201); + + // Listed under the JWT's real shop, not the attacker-supplied one. + const listedReal = await invoke("GET", { claims: OWNER_CLAIMS, query: { shop_id: "auth-shop" } }); + expect(parseBody<{ items: { name: string }[] }>(listedReal).items).toContainEqual( + expect.objectContaining({ name: "Tenant-safe item" }), + ); + + const listedAttacker = await invoke("GET", { query: { shop_id: "attacker-supplied-shop" } }); + expect(parseBody<{ items: unknown[] }>(listedAttacker).items).toHaveLength(0); + }); + + it("counter_staff can list products but gets 403 creating one", async () => { + const listed = await invoke("GET", { claims: STAFF_CLAIMS, query: {} }); + expect(listed.statusCode).toBe(200); + + const created = await invoke("POST", { claims: STAFF_CLAIMS, body: { name: "Should be blocked" } }); + expect(created.statusCode).toBe(403); + }); + + it("counter_staff gets 403 updating or deleting a product owner/manager created", async () => { + const created = await invoke("POST", { claims: OWNER_CLAIMS, body: { name: "Owner's item" } }); + const { product_id } = parseBody<{ product_id: string }>(created); + + const updated = await invoke("PUT", { claims: STAFF_CLAIMS, pathParams: { product_id }, body: { base_price: 5 } }); + expect(updated.statusCode).toBe(403); + + const deleted = await invoke("DELETE", { claims: STAFF_CLAIMS, pathParams: { product_id } }); + expect(deleted.statusCode).toBe(403); + }); + + it("owner can create, update, and delete", async () => { + const created = await invoke("POST", { claims: OWNER_CLAIMS, body: { name: "Owner CRUD" } }); + expect(created.statusCode).toBe(201); + const { product_id } = parseBody<{ product_id: string }>(created); + + const updated = await invoke("PUT", { claims: OWNER_CLAIMS, pathParams: { product_id }, body: { base_price: 9 } }); + expect(updated.statusCode).toBe(200); + + const deleted = await invoke("DELETE", { claims: OWNER_CLAIMS, pathParams: { product_id } }); + expect(deleted.statusCode).toBe(204); + }); +}); diff --git a/apps/api/test/postConfirmation.test.ts b/apps/api/test/postConfirmation.test.ts new file mode 100644 index 0000000..bb3ca96 --- /dev/null +++ b/apps/api/test/postConfirmation.test.ts @@ -0,0 +1,40 @@ +import { + AdminAddUserToGroupCommand, + CognitoIdentityProviderClient, +} from "@aws-sdk/client-cognito-identity-provider"; +import { mockClient } from "aws-sdk-client-mock"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { PostConfirmationTriggerEvent } from "aws-lambda"; +import { handler } from "../src/handlers/postConfirmation"; + +const cognitoMock = mockClient(CognitoIdentityProviderClient); + +function triggerEvent(triggerSource: string): PostConfirmationTriggerEvent { + return { + triggerSource, + userPoolId: "pool-1", + userName: "owner@shop.com", + request: { userAttributes: {} }, + response: {}, + } as unknown as PostConfirmationTriggerEvent; +} + +beforeEach(() => { + cognitoMock.reset(); + cognitoMock.on(AdminAddUserToGroupCommand).resolves({}); +}); + +describe("postConfirmation trigger", () => { + it("adds a self-confirmed user to the owner group — the only path into that group", async () => { + await handler(triggerEvent("PostConfirmation_ConfirmSignUp")); + + const calls = cognitoMock.commandCalls(AdminAddUserToGroupCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ UserPoolId: "pool-1", Username: "owner@shop.com", GroupName: "owner" }); + }); + + it("does nothing for a trigger source other than ConfirmSignUp (e.g. AdminCreateUser's confirm)", async () => { + await handler(triggerEvent("PostConfirmation_ConfirmForgotPassword")); + expect(cognitoMock.commandCalls(AdminAddUserToGroupCommand)).toHaveLength(0); + }); +}); diff --git a/apps/api/test/staffInvite.test.ts b/apps/api/test/staffInvite.test.ts new file mode 100644 index 0000000..1e432e4 --- /dev/null +++ b/apps/api/test/staffInvite.test.ts @@ -0,0 +1,74 @@ +import { + AdminAddUserToGroupCommand, + AdminCreateUserCommand, + CognitoIdentityProviderClient, +} from "@aws-sdk/client-cognito-identity-provider"; +import { mockClient } from "aws-sdk-client-mock"; +import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +const cognitoMock = mockClient(CognitoIdentityProviderClient); + +// staffInvite.ts reads USER_POOL_ID once at module load — static imports +// are always hoisted above any other top-level code regardless of source +// position, so setting the env var must happen before a *dynamic* import, +// same pattern as bedrock.crossAccount.test.ts. +let handler: typeof import("../src/handlers/staffInvite").handler; + +beforeAll(async () => { + process.env.USER_POOL_ID = "pool-1"; + ({ handler } = await import("../src/handlers/staffInvite")); +}); + +function invoke(claims: Record | undefined, body: unknown): Promise<{ statusCode: number; body: string }> { + const event = { + requestContext: claims ? { authorizer: { jwt: { claims } } } : {}, + body: JSON.stringify(body), + } as unknown as APIGatewayProxyEventV2; + return handler(event) as Promise<{ statusCode: number; body: string }>; +} + +const OWNER_CLAIMS = { sub: "u-owner", "custom:shop_id": "shop-1", "cognito:groups": "owner" }; +const MANAGER_CLAIMS = { sub: "u-mgr", "custom:shop_id": "shop-1", "cognito:groups": "manager" }; + +beforeEach(() => { + cognitoMock.reset(); + cognitoMock.on(AdminCreateUserCommand).resolves({}); + cognitoMock.on(AdminAddUserToGroupCommand).resolves({}); +}); + +describe("POST /staff", () => { + it("403s an unauthenticated request", async () => { + const result = await invoke(undefined, { email: "x@y.com", role: "manager" }); + expect(result.statusCode).toBe(403); + }); + + it("403s a manager — only the owner can invite", async () => { + const result = await invoke(MANAGER_CLAIMS, { email: "x@y.com", role: "counter_staff" }); + expect(result.statusCode).toBe(403); + expect(cognitoMock.commandCalls(AdminCreateUserCommand)).toHaveLength(0); + }); + + it("400s an invalid role", async () => { + const result = await invoke(OWNER_CLAIMS, { email: "x@y.com", role: "owner" }); + expect(result.statusCode).toBe(400); + }); + + it("400s a missing email", async () => { + const result = await invoke(OWNER_CLAIMS, { role: "manager" }); + expect(result.statusCode).toBe(400); + }); + + it("creates the user under the inviter's own shop_id — never a client-supplied one — and assigns the requested group", async () => { + const result = await invoke(OWNER_CLAIMS, { email: "staff@shop.com", role: "manager", shop_id: "attacker-shop" }); + expect(result.statusCode).toBe(201); + expect(JSON.parse(result.body)).toEqual({ email: "staff@shop.com", role: "manager", shop_id: "shop-1" }); + + const createCall = cognitoMock.commandCalls(AdminCreateUserCommand)[0]; + const shopAttr = (createCall.args[0].input.UserAttributes ?? []).find((attr) => attr.Name === "custom:shop_id"); + expect(shopAttr?.Value).toBe("shop-1"); + + const groupCall = cognitoMock.commandCalls(AdminAddUserToGroupCommand)[0]; + expect(groupCall.args[0].input).toEqual({ UserPoolId: "pool-1", Username: "staff@shop.com", GroupName: "manager" }); + }); +}); diff --git a/apps/api/test/wsConnect.test.ts b/apps/api/test/wsConnect.test.ts new file mode 100644 index 0000000..bc5b7ea --- /dev/null +++ b/apps/api/test/wsConnect.test.ts @@ -0,0 +1,112 @@ +import { CreateTableCommand } from "@aws-sdk/client-dynamodb"; +import { GetCommand } from "@aws-sdk/lib-dynamodb"; +import dynalite from "dynalite"; +import type { APIGatewayProxyWebsocketEventV2 } from "aws-lambda"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const PORT = 8131; +const WS_TABLE = "ws_connections_test"; +process.env.DYNAMODB_ENDPOINT = `http://localhost:${PORT}`; +process.env.WS_CONNECTIONS_TABLE_NAME = WS_TABLE; + +let dynaliteServer: ReturnType; +let ddb: typeof import("../src/lib/dynamo").ddb; + +const verifyMock = vi.fn(); +vi.mock("aws-jwt-verify", () => ({ + CognitoJwtVerifier: { create: vi.fn().mockImplementation(() => ({ verify: verifyMock })) }, +})); + +function connectEvent(query: Record, connectionId = "conn-1"): APIGatewayProxyWebsocketEventV2 { + return { + requestContext: { connectionId, routeKey: "$connect" }, + queryStringParameters: query, + } as unknown as APIGatewayProxyWebsocketEventV2; +} + +async function connect( + handler: (event: APIGatewayProxyWebsocketEventV2) => Promise, + query: Record, + connectionId: string, +): Promise<{ statusCode: number; body: string }> { + return (await handler(connectEvent(query, connectionId))) as { statusCode: number; body: string }; +} + +beforeAll(async () => { + dynaliteServer = dynalite({ createTableMs: 0 }); + await new Promise((resolve, reject) => { + dynaliteServer.listen(PORT, (err?: Error) => (err ? reject(err) : resolve())); + }); + ({ ddb } = await import("../src/lib/dynamo")); + await ddb.send( + new CreateTableCommand({ + TableName: WS_TABLE, + AttributeDefinitions: [{ AttributeName: "connection_id", AttributeType: "S" }], + KeySchema: [{ AttributeName: "connection_id", KeyType: "HASH" }], + BillingMode: "PAY_PER_REQUEST", + }), + ); +}); + +afterAll(async () => { + await new Promise((resolve) => dynaliteServer.close(() => resolve())); +}); + +afterEach(() => { + delete process.env.USER_POOL_ID; + delete process.env.USER_POOL_CLIENT_ID; +}); + +describe("wsConnect — no USER_POOL_ID configured (local dev)", () => { + it("accepts the connection using the query string's shop_id directly, no token required", async () => { + const { handler } = await import("../src/handlers/wsConnect"); + const result = await connect(handler, { shop_id: "demo-shop", counter_id: "counter_a" }, "conn-local"); + expect(result.statusCode).toBe(200); + + const stored = await ddb.send(new GetCommand({ TableName: WS_TABLE, Key: { connection_id: "conn-local" } })); + expect(stored.Item?.shop_id).toBe("demo-shop"); + }); +}); + +describe("wsConnect — USER_POOL_ID configured (production)", () => { + beforeEach(() => { + verifyMock.mockReset(); + process.env.USER_POOL_ID = "pool-1"; + process.env.USER_POOL_CLIENT_ID = "client-1"; + vi.resetModules(); + }); + + it("rejects a connection with no token", async () => { + const { handler } = await import("../src/handlers/wsConnect"); + const result = await connect(handler, { shop_id: "demo-shop", counter_id: "counter_a" }, "conn-no-token"); + expect(result.statusCode).toBe(401); + }); + + it("rejects an invalid token", async () => { + verifyMock.mockRejectedValue(new Error("bad signature")); + const { handler } = await import("../src/handlers/wsConnect"); + const result = await connect(handler, { token: "garbage", counter_id: "counter_a" }, "conn-bad-token"); + expect(result.statusCode).toBe(401); + }); + + it("rejects a validly-signed token missing custom:shop_id", async () => { + verifyMock.mockResolvedValue({ sub: "u1" }); + const { handler } = await import("../src/handlers/wsConnect"); + const result = await connect(handler, { token: "good-token", counter_id: "counter_a" }, "conn-no-shop"); + expect(result.statusCode).toBe(401); + }); + + it("accepts a valid token and uses its shop_id, not the query string's", async () => { + verifyMock.mockResolvedValue({ sub: "u1", "custom:shop_id": "real-shop" }); + const { handler } = await import("../src/handlers/wsConnect"); + const result = await connect( + handler, + { token: "good-token", shop_id: "attacker-shop", counter_id: "counter_a" }, + "conn-good", + ); + expect(result.statusCode).toBe(200); + + const stored = await ddb.send(new GetCommand({ TableName: WS_TABLE, Key: { connection_id: "conn-good" } })); + expect(stored.Item?.shop_id).toBe("real-shop"); + }); +}); diff --git a/apps/web/.env.example b/apps/web/.env.example index cdd7af7..2493127 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -6,6 +6,16 @@ # AWS-related (credentials, table names, which services are required). VITE_API_BASE_URL=https://changeme.execute-api.us-east-1.amazonaws.com VITE_WEBSOCKET_URL=wss://changeme.execute-api.us-east-1.amazonaws.com/prod -# Decorative today, not actually enforced server-side (07-EDGE-CASES.md -# edge case E-1, a stated known limitation) — any non-empty value works. +# Decorative — not actually enforced server-side (07-EDGE-CASES.md edge +# case E-1, a stated known limitation); any non-empty value works. Real +# authorization is the Cognito JWT below, not this header. VITE_API_KEY=changeme + +# ============================================================ +# Cognito — real authentication (src/lib/cognito.ts, src/state/AuthContext.tsx). +# Both come from `cdk deploy`'s printed CfnOutputs (Auth.UserPoolId, +# Auth.UserPoolClientId). +# ============================================================ +VITE_COGNITO_USER_POOL_ID=changeme +VITE_COGNITO_CLIENT_ID=changeme +VITE_COGNITO_REGION=us-east-1 diff --git a/apps/web/package.json b/apps/web/package.json index f078953..1a6b200 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "e2e": "playwright test" }, "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.700.0", "@stocksync/core": "*", "@zxing/browser": "^0.2.1", "idb": "^8.0.3", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a5d82c0..3e2aa53 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,81 +1,116 @@ import { useState } from "react"; +import { AuthProvider, useAuth, type AuthUser } from "./state/AuthContext"; import { ShopProvider } from "./state/ShopContext"; import { CounterPage } from "./pages/CounterPage"; import { HeroPage } from "./pages/HeroPage"; import { ProductsPage } from "./pages/ProductsPage"; import { CheckoutPage } from "./pages/CheckoutPage"; - -interface CounterEntry { - shopId: string; - counterId: string; -} - -/** A direct/shareable link (README's ?shop_id=&counter_id= pattern) already knows which counter it wants — skip the landing page and go straight there. */ -function readEntryFromUrl(): CounterEntry | null { - const params = new URLSearchParams(window.location.search); - const shopId = params.get("shop_id"); - const counterId = params.get("counter_id"); - return shopId && counterId ? { shopId, counterId } : null; -} +import { StaffPage } from "./pages/StaffPage"; +import { CounterPicker } from "./components/CounterPicker"; function useQueryParam(name: string, fallback: string): string { return new URLSearchParams(window.location.search).get(name) ?? fallback; } +function readCounterIdFromUrl(): string | null { + return new URLSearchParams(window.location.search).get("counter_id"); +} + const NAV_LINKS = [ - { page: "counter", label: "Counter" }, - { page: "products", label: "Products" }, - { page: "checkout", label: "Checkout" }, + { page: "counter", label: "Counter", roles: ["owner", "manager", "counter_staff"] }, + { page: "products", label: "Products", roles: ["owner", "manager", "counter_staff"] }, + { page: "checkout", label: "Checkout", roles: ["owner", "manager", "counter_staff"] }, + { page: "staff", label: "Staff", roles: ["owner"] }, ] as const; +interface AuthedAppProps { + shopId: string; + role: "owner" | "manager" | "counter_staff"; +} + /** - * Query-param page switch (?page=products|checkout, default counter) — - * deliberately not a routing library: a handful of flat pages, no nested - * routes, no history-stack needs, and CounterPage/the demo already - * relies on plain query params (?shop_id=&counter_id=) for identity, so - * this stays consistent with that rather than introducing a second - * navigation mechanism (19b). - * - * A bare visit (no shop_id/counter_id) shows the landing page first — - * see HeroPage/CounterPicker — regardless of ?page=, since none of the - * pages below make sense without a known shop/counter identity. + * Everything past "we know which shop and which role" — still needs to + * know which physical counter this browser session is (CounterPicker), + * then the same ?page= nav phase-19b already established. Query-param + * page switch stays deliberately not a routing library — see the + * original comment this replaced in git history for why. */ -export function App() { - const [entry, setEntry] = useState(readEntryFromUrl); +function AuthedApp({ shopId, role }: AuthedAppProps) { + const [counterId, setCounterId] = useState(readCounterIdFromUrl); const page = useQueryParam("page", "counter"); - function handleEnter(shopId: string, counterId: string): void { + function handleEnterCounter(id: string): void { const url = new URL(window.location.href); - url.searchParams.set("shop_id", shopId); - url.searchParams.set("counter_id", counterId); + url.searchParams.set("counter_id", id); window.history.pushState({}, "", url); - setEntry({ shopId, counterId }); + setCounterId(id); } - if (!entry) { + if (!counterId) { return ( - - - +
+ +
); } + const visibleLinks = NAV_LINKS.filter((link) => (link.roles as readonly string[]).includes(role)); + return ( - {page === "products" && } - {page === "checkout" && } - {page !== "products" && page !== "checkout" && } + {page === "products" && } + {page === "checkout" && } + {page === "staff" && role === "owner" && } + {page !== "products" && page !== "checkout" && page !== "staff" && } ); } + +function TopBar({ user, onSignOut }: { user: AuthUser; onSignOut: () => void }) { + return ( +
+ {user.email} + +
+ ); +} + +function AppShell() { + const { status, user, signOut } = useAuth(); + + if (status === "loading") { + return
Loading…
; + } + + if (status === "signed_out" || !user?.shopId) { + return ; + } + + return ( +
+ + +
+ ); +} + +export function App() { + return ( + + + + ); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index da2b4f4..eef35db 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -8,6 +8,7 @@ import type { TransactionResultDTO, WsPushMessage, } from "@stocksync/core"; +import { getTokens } from "../lib/tokenStore"; // Vite exposes only VITE_-prefixed env vars to client code. Falling back to // "" keeps local dev usable before a real API Gateway URL is configured — @@ -18,7 +19,12 @@ const WEBSOCKET_URL: string = import.meta.env.VITE_WEBSOCKET_URL ?? ""; const API_KEY: string = import.meta.env.VITE_API_KEY ?? ""; function headers(): HeadersInit { - return { "content-type": "application/json", "x-api-key": API_KEY }; + const idToken = getTokens()?.idToken; + return { + "content-type": "application/json", + "x-api-key": API_KEY, + ...(idToken ? { authorization: `Bearer ${idToken}` } : {}), + }; } export interface TransactionsResponse { @@ -79,7 +85,12 @@ export async function getAudit(itemId: string, shopId: string): Promise { + const response = await fetch(`${API_BASE_URL}/staff`, { + method: "POST", + headers: headers(), + body: JSON.stringify({ email, role }), + }); + if (!response.ok) { + throw new Error(`POST /staff failed: ${response.status}`); + } + return (await response.json()) as StaffInviteResponse; +} + export function parseWsPushMessage(raw: string): WsPushMessage | undefined { try { const parsed: unknown = JSON.parse(raw); diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx new file mode 100644 index 0000000..80848d0 --- /dev/null +++ b/apps/web/src/components/AuthPanel.tsx @@ -0,0 +1,235 @@ +import { useState, type FormEvent } from "react"; +import { useAuth } from "../state/AuthContext"; +import { ArrowRightIcon } from "./icons"; + +type Mode = "login" | "signup" | "confirm" | "new_password"; + +const INPUT_CLASS = "w-full rounded-md border border-slate-300 px-3 py-2 text-sm"; +const SUBMIT_CLASS = + "flex w-full items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:opacity-50"; + +/** + * The app's real front door for identity — replaces the old shop_id/ + * counter_id picker at HeroPage's #try-it anchor now that shop identity + * comes from a real account (Cognito), not a free-text ID anyone could + * type. Which physical counter this session is (CounterPicker, shown + * post-login in App.tsx) is now a separate, lighter-weight step. + */ +export function AuthPanel() { + const { signUp, confirmSignUp, resendConfirmationCode, signIn, completeNewPassword } = useAuth(); + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [shopName, setShopName] = useState(""); + const [code, setCode] = useState(""); + const [session, setSession] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [info, setInfo] = useState(""); + + async function handleLogin(event: FormEvent): Promise { + event.preventDefault(); + setError(""); + setBusy(true); + try { + const result = await signIn(email, password); + if (result.newPasswordRequired && result.session) { + setSession(result.session); + setMode("new_password"); + } + } catch { + setError("Couldn't sign in — check your email and password."); + } finally { + setBusy(false); + } + } + + async function handleSignup(event: FormEvent): Promise { + event.preventDefault(); + setError(""); + setBusy(true); + try { + await signUp({ email, password, shopName }); + setMode("confirm"); + } catch { + setError("Couldn't create that account — the email may already be in use, or the password is too weak (8+ characters, upper + lower + a digit)."); + } finally { + setBusy(false); + } + } + + async function handleConfirm(event: FormEvent): Promise { + event.preventDefault(); + setError(""); + setBusy(true); + try { + await confirmSignUp(email, code); + setMode("login"); + setInfo("Email confirmed — sign in below."); + setCode(""); + } catch { + setError("That code didn't work — check your inbox and try again."); + } finally { + setBusy(false); + } + } + + async function handleNewPassword(event: FormEvent): Promise { + event.preventDefault(); + setError(""); + setBusy(true); + try { + await completeNewPassword(email, newPassword, session); + } catch { + setError("Couldn't set that password — try a stronger one (8+ characters, upper + lower + a digit)."); + } finally { + setBusy(false); + } + } + + return ( +
+ {mode === "login" && ( + <> +

Sign in

+

Enter your shop's account to get to your counter.

+ {info &&

{info}

} +
+ setEmail(event.target.value)} + placeholder="you@shop.com" + aria-label="Email" + className={INPUT_CLASS} + /> + setPassword(event.target.value)} + placeholder="Password" + aria-label="Password" + className={INPUT_CLASS} + /> + {error &&

{error}

} + +
+ + + )} + + {mode === "signup" && ( + <> +

Set up your shop

+

You'll be the owner — invite your staff once you're in.

+
+ setShopName(event.target.value)} + placeholder="Shop name" + aria-label="Shop name" + className={INPUT_CLASS} + /> + setEmail(event.target.value)} + placeholder="you@shop.com" + aria-label="Email" + className={INPUT_CLASS} + /> + setPassword(event.target.value)} + placeholder="Password (8+ characters)" + aria-label="Password" + className={INPUT_CLASS} + /> + {error &&

{error}

} + +
+ + + )} + + {mode === "confirm" && ( + <> +

Check your email

+

Enter the confirmation code we sent to {email}.

+
+ setCode(event.target.value)} + placeholder="Confirmation code" + aria-label="Confirmation code" + className={INPUT_CLASS} + /> + {error &&

{error}

} + +
+ + + )} + + {mode === "new_password" && ( + <> +

Choose a password

+

First sign-in on an invited account — set your own password to continue.

+
+ setNewPassword(event.target.value)} + placeholder="New password (8+ characters)" + aria-label="New password" + className={INPUT_CLASS} + /> + {error &&

{error}

} + +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/CounterPicker.tsx b/apps/web/src/components/CounterPicker.tsx index 742583f..b411be5 100644 --- a/apps/web/src/components/CounterPicker.tsx +++ b/apps/web/src/components/CounterPicker.tsx @@ -2,33 +2,32 @@ import { useState } from "react"; import { ArrowRightIcon } from "./icons"; export interface CounterPickerProps { - onEnter: (shopId: string, counterId: string) => void; + onEnter: (counterId: string) => void; } -const DEFAULT_SHOP_ID = "demo-shop"; - const PRESET_COUNTERS = [ { id: "counter_a", label: "Counter A" }, { id: "counter_b", label: "Counter B" }, ]; /** - * The demo's real entry point: two preset counters matching the README's - * shareable-link scenario (?shop_id=demo-shop&counter_id=counter_a / _b), - * plus a custom-ID escape hatch for anyone who wants to point this at a - * different shop/counter pair. + * Post-login step (App.tsx, once a user is signed in) — which physical + * counter this browser session is running as. Shop identity is no longer + * picked here (it comes from the signed-in account's custom:shop_id + * claim); this only ever asks for counter_id, matching the two-window + * conflict demo's README scenario (open two counters of the *same* + * signed-in shop, take one offline, sell the same item from both). */ export function CounterPicker({ onEnter }: CounterPickerProps) { const [showCustom, setShowCustom] = useState(false); - const [shopId, setShopId] = useState(DEFAULT_SHOP_ID); const [counterId, setCounterId] = useState(""); return ( -
-

Try it yourself

+
+

Which counter is this?

- Open this in two browser windows as two different counters, take one offline, sell the same item from both — - then reconnect and watch it converge. + Open this in a second window as the other counter to try the offline-conflict demo — take one offline, sell + the same item from both, then reconnect and watch it converge.

@@ -36,11 +35,10 @@ export function CounterPicker({ onEnter }: CounterPickerProps) { ))}
@@ -51,23 +49,16 @@ export function CounterPicker({ onEnter }: CounterPickerProps) { onClick={() => setShowCustom(true)} className="mt-4 text-sm text-slate-500 underline decoration-dotted hover:text-slate-700" > - Use a custom shop / counter ID instead + Use a custom counter ID instead ) : (
{ event.preventDefault(); - if (counterId.trim()) onEnter(shopId.trim() || DEFAULT_SHOP_ID, counterId.trim()); + if (counterId.trim()) onEnter(counterId.trim()); }} > - setShopId(event.target.value)} - placeholder="shop id" - aria-label="Shop ID" - className="flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm" - /> setCounterId(event.target.value)} diff --git a/apps/web/src/lib/cognito.ts b/apps/web/src/lib/cognito.ts new file mode 100644 index 0000000..afaf5e4 --- /dev/null +++ b/apps/web/src/lib/cognito.ts @@ -0,0 +1,186 @@ +import { + CognitoIdentityProviderClient, + ConfirmSignUpCommand, + InitiateAuthCommand, + ResendConfirmationCodeCommand, + RespondToAuthChallengeCommand, + SignUpCommand, +} from "@aws-sdk/client-cognito-identity-provider"; + +// Same pattern as apps/web/src/api/client.ts: read-only Vite env, empty +// string fallback keeps local dev from crashing at build time before a +// real User Pool exists — calls simply fail with a clear network/config +// error instead. +const REGION: string = import.meta.env.VITE_COGNITO_REGION ?? "us-east-1"; +const CLIENT_ID: string = import.meta.env.VITE_COGNITO_CLIENT_ID ?? ""; + +const client = new CognitoIdentityProviderClient({ region: REGION }); + +export interface AuthTokens { + idToken: string; + accessToken: string; + refreshToken: string; + /** Epoch ms — derived from the ID token's own `exp` claim, not guessed from `ExpiresIn`, so a clock-skewed client still refreshes at the right moment relative to what the token itself claims. */ + expiresAt: number; +} + +export interface IdTokenClaims { + sub: string; + email?: string; + shopId?: string; + role?: "owner" | "manager" | "counter_staff"; +} + +/** + * Decodes (never verifies — that's the server's job via the API Gateway + * JWT authorizer) the ID token's payload, purely to drive what the UI + * shows. Never trust this for an authorization decision; every protected + * request is re-checked server-side against the token itself. + */ +export function decodeIdToken(idToken: string): IdTokenClaims { + const [, payloadB64] = idToken.split("."); + const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/"))) as Record; + const groups = typeof payload["cognito:groups"] === "string" ? (payload["cognito:groups"] as string) : ""; + const role = (["owner", "manager", "counter_staff"] as const).find((candidate) => groups.includes(candidate)); + return { + sub: payload.sub as string, + email: payload.email as string | undefined, + shopId: payload["custom:shop_id"] as string | undefined, + role, + }; +} + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "shop" + ); +} + +/** A short, low-collision suffix — good enough at this scale; a true collision just means two shops share a display name, not a security issue (each still gets its own random shop_id). */ +function randomSuffix(): string { + return Math.random().toString(36).slice(2, 8); +} + +export interface SignUpInput { + email: string; + password: string; + shopName: string; +} + +/** + * Self-signup always creates a brand-new shop — the caller becomes its + * "owner" once postConfirmation.ts's trigger fires on ConfirmSignUp (see + * infra/cdk/lib/constructs/Auth.ts's doc comment for the full flow). + * shop_id is generated client-side since SignUp is unauthenticated; a + * human-readable slug plus a random suffix keeps it unique without + * needing a server round-trip first. + */ +export async function signUp(input: SignUpInput): Promise<{ shopId: string }> { + const shopId = `${slugify(input.shopName)}-${randomSuffix()}`; + await client.send( + new SignUpCommand({ + ClientId: CLIENT_ID, + Username: input.email, + Password: input.password, + UserAttributes: [ + { Name: "email", Value: input.email }, + { Name: "custom:shop_id", Value: shopId }, + ], + }), + ); + return { shopId }; +} + +export async function confirmSignUp(email: string, code: string): Promise { + await client.send(new ConfirmSignUpCommand({ ClientId: CLIENT_ID, Username: email, ConfirmationCode: code })); +} + +export async function resendConfirmationCode(email: string): Promise { + await client.send(new ResendConfirmationCodeCommand({ ClientId: CLIENT_ID, Username: email })); +} + +export type SignInResult = + | { kind: "success"; tokens: AuthTokens } + | { kind: "new_password_required"; session: string }; + +function tokensFromAuthResult(result: { + IdToken?: string; + AccessToken?: string; + RefreshToken?: string; +}): AuthTokens { + if (!result.IdToken || !result.AccessToken || !result.RefreshToken) { + throw new Error("Cognito did not return a complete set of tokens"); + } + const { exp } = decodeExp(result.IdToken); + return { idToken: result.IdToken, accessToken: result.AccessToken, refreshToken: result.RefreshToken, expiresAt: exp * 1000 }; +} + +function decodeExp(idToken: string): { exp: number } { + const [, payloadB64] = idToken.split("."); + const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/"))) as { exp: number }; + return { exp: payload.exp }; +} + +/** + * USER_PASSWORD_AUTH — sends the password directly over TLS via + * InitiateAuth. Simpler than implementing SRP client-side without Amplify + * (see Auth.ts's doc comment for the same tradeoff on the pool's + * auth-flow config). A staff account's first sign-in (after AdminCreateUser) + * comes back as a NEW_PASSWORD_REQUIRED challenge instead of tokens — + * completeNewPassword finishes that flow. + */ +export async function signIn(email: string, password: string): Promise { + const result = await client.send( + new InitiateAuthCommand({ + AuthFlow: "USER_PASSWORD_AUTH", + ClientId: CLIENT_ID, + AuthParameters: { USERNAME: email, PASSWORD: password }, + }), + ); + + if (result.ChallengeName === "NEW_PASSWORD_REQUIRED") { + if (!result.Session) throw new Error("Cognito did not return a challenge session"); + return { kind: "new_password_required", session: result.Session }; + } + + if (!result.AuthenticationResult) throw new Error("sign-in did not return tokens or a challenge"); + return { kind: "success", tokens: tokensFromAuthResult(result.AuthenticationResult) }; +} + +export async function completeNewPassword(email: string, newPassword: string, session: string): Promise { + const result = await client.send( + new RespondToAuthChallengeCommand({ + ClientId: CLIENT_ID, + ChallengeName: "NEW_PASSWORD_REQUIRED", + Session: session, + ChallengeResponses: { USERNAME: email, NEW_PASSWORD: newPassword }, + }), + ); + if (!result.AuthenticationResult) throw new Error("challenge response did not return tokens"); + return tokensFromAuthResult(result.AuthenticationResult); +} + +export async function refreshSession(refreshToken: string): Promise { + const result = await client.send( + new InitiateAuthCommand({ + AuthFlow: "REFRESH_TOKEN_AUTH", + ClientId: CLIENT_ID, + AuthParameters: { REFRESH_TOKEN: refreshToken }, + }), + ); + if (!result.AuthenticationResult?.IdToken || !result.AuthenticationResult.AccessToken) { + throw new Error("refresh did not return tokens"); + } + const { exp } = decodeExp(result.AuthenticationResult.IdToken); + // Cognito's REFRESH_TOKEN_AUTH doesn't reissue a refresh token — reuse the one the caller already has. + return { + idToken: result.AuthenticationResult.IdToken, + accessToken: result.AuthenticationResult.AccessToken, + refreshToken, + expiresAt: exp * 1000, + }; +} diff --git a/apps/web/src/lib/tokenStore.ts b/apps/web/src/lib/tokenStore.ts new file mode 100644 index 0000000..1910f56 --- /dev/null +++ b/apps/web/src/lib/tokenStore.ts @@ -0,0 +1,39 @@ +import type { AuthTokens } from "./cognito"; + +const STORAGE_KEY = "stocksync.auth.tokens"; + +/** + * Plain module-level store, not React state — apps/web/src/api/client.ts + * needs to read the current ID token synchronously from plain functions + * (postTransactions, getSync, etc. aren't hooks), so the source of truth + * for "what token do I send right now" can't live only inside + * AuthContext's component state. AuthContext still owns every write to + * this store (sign-in, refresh, sign-out) and mirrors it into its own + * React state for rendering. + */ +let current: AuthTokens | null = load(); + +function load(): AuthTokens | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as AuthTokens) : null; + } catch { + return null; + } +} + +export function getTokens(): AuthTokens | null { + return current; +} + +export function setTokens(tokens: AuthTokens | null): void { + current = tokens; + try { + if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens)); + else localStorage.removeItem(STORAGE_KEY); + } catch { + // Best-effort persistence only (e.g. a private browsing window that + // blocks storage) — the in-memory value above still makes the + // current tab's session work correctly, it just won't survive a reload. + } +} diff --git a/apps/web/src/pages/HeroPage.tsx b/apps/web/src/pages/HeroPage.tsx index b4de95c..3423398 100644 --- a/apps/web/src/pages/HeroPage.tsx +++ b/apps/web/src/pages/HeroPage.tsx @@ -1,9 +1,5 @@ import { BoxIcon, WifiOffIcon, MergeIcon, SparklesIcon, BoltIcon, ArrowRightIcon } from "../components/icons"; -import { CounterPicker } from "../components/CounterPicker"; - -export interface HeroPageProps { - onEnter: (shopId: string, counterId: string) => void; -} +import { AuthPanel } from "../components/AuthPanel"; const STEPS = [ { @@ -47,13 +43,11 @@ const FEATURES = [ ]; /** - * The app's real front door. Direct/shareable links that already carry - * ?shop_id=&counter_id= (README's demo-link pattern) skip this entirely — - * see App.tsx's routing gate — so this only shows up for a first-time or - * bare visit, and the two-window conflict demo's shareable links keep - * working exactly as documented. + * The app's real front door. Shown whenever there's no signed-in user + * (App.tsx's gate) — a returning, already-authenticated visit skips + * straight past this to CounterSelectPage instead. */ -export function HeroPage({ onEnter }: HeroPageProps) { +export function HeroPage() { return (