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
32 changes: 25 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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.
Expand Down Expand Up @@ -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
4 changes: 3 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/handlers/auditQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,7 +24,7 @@ function timestampFromSortKey(sk: string): string {
*/
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
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");
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/handlers/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -68,7 +69,10 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<APIGateway
return invalidPayload("request body must be valid JSON");
}

const shopId = requireNonEmptyString(body.shop_id);
// The verified JWT's shop_id wins over whatever the client sent (same
// pattern as writeIntake.ts/crudTable.ts) — falls back to body.shop_id
// only for local dev, which has no Cognito integration.
const shopId = getAuthContext(event)?.shopId ?? requireNonEmptyString(body.shop_id);
const counterId = requireNonEmptyString(body.counter_id);
if (!shopId) return invalidPayload("shop_id is required");
if (!counterId) return invalidPayload("counter_id is required");
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/handlers/conflictResolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import { GetCommand, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
import { ddb } from "../lib/dynamo";
import { getAuthContext } from "../lib/authContext";
import { createLogger } from "../lib/logger";
import { inventoryRecordKey, type InventoryRecordItem } from "../lib/inventoryRecord";
import { pushToShop } from "./wsPush";
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<APIGateway
return invalidPayload("request body must be valid JSON");
}

const shopId = requireNonEmptyString(body.shop_id);
const shopId = getAuthContext(event)?.shopId ?? requireNonEmptyString(body.shop_id);
const field = requireNonEmptyString(body.field);
const resolvedBy = requireNonEmptyString(body.resolved_by);
if (!shopId) return invalidPayload("shop_id is required");
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/handlers/postConfirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AdminAddUserToGroupCommand, CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import type { PostConfirmationTriggerEvent } from "aws-lambda";

const cognito = new CognitoIdentityProviderClient({});

/**
* Cognito PostConfirmation trigger (Auth.ts) — fires once, right after a
* self-signed-up user verifies their email via ConfirmSignUp. This is the
* *only* path into the "owner" group: self-signup always represents someone
* setting up a brand-new shop (its shop_id was set by the client at SignUp
* time, in custom:shop_id), so they own it. Staff invited afterwards
* (staffInvite.ts, via AdminCreateUser) never pass through this trigger —
* AdminCreateUser bypasses ConfirmSignUp entirely — so this handler only
* ever needs to handle the owner case.
*/
export async function handler(event: PostConfirmationTriggerEvent): Promise<PostConfirmationTriggerEvent> {
if (event.triggerSource !== "PostConfirmation_ConfirmSignUp") return event;

await cognito.send(
new AdminAddUserToGroupCommand({
UserPoolId: event.userPoolId,
Username: event.userName,
GroupName: "owner",
}),
);

return event;
}
70 changes: 70 additions & 0 deletions apps/api/src/handlers/staffInvite.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyResultV2> {
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<string, unknown>;
try {
body = JSON.parse(event.body ?? "{}") as Record<string, unknown>;
} 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 }) };
}
3 changes: 2 additions & 1 deletion apps/api/src/handlers/syncQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<APIGatewayProxyResultV2> {
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");
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/handlers/writeIntake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -234,7 +235,13 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<APIGateway
let shopId: string;
let counterId: string;
try {
shopId = requireNonEmptyString(body.shop_id, "shop_id");
// The verified JWT's shop_id wins over whatever the client sent, same
// pattern as crudTable.ts — falls back to body.shop_id when there's no
// authorizer context, which covers both local dev and checkout.ts's
// in-process call into this same handler (its synthesized event never
// carries an authorizer context; checkout.ts already resolved its own
// caller's shop_id via getAuthContext before building that event).
shopId = getAuthContext(event)?.shopId ?? requireNonEmptyString(body.shop_id, "shop_id");
counterId = requireNonEmptyString(body.counter_id, "counter_id");
} catch (error) {
return invalidPayload((error as ValidationError).message);
Expand Down
44 changes: 43 additions & 1 deletion apps/api/src/handlers/wsConnect.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,58 @@
import type { APIGatewayProxyResultV2, APIGatewayProxyWebsocketEventV2 } from "aws-lambda";
import { PutCommand } from "@aws-sdk/lib-dynamodb";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { ddb } from "../lib/dynamo";
import { createLogger } from "../lib/logger";

const logger = createLogger("ws-connect");

// WebSocket routes on an API Gateway v2 WebSocket API don't get the same
// native JWT authorizer HTTP API routes do (Auth.ts's HttpJwtAuthorizer is
// HTTP-API-only) — the ID token is verified here instead, from a `token`
// query param (WebSocket handshakes can't carry a custom header from a
// browser's native WebSocket client). Verifier is built once per cold
// start, same singleton-client pattern as apps/api/src/lib/dynamo.ts.
//
// Unset USER_POOL_ID (local dev — src/local/server.ts has no Cognito
// integration) skips verification entirely, same fallback pattern
// authContext.ts uses for every REST handler.
const USER_POOL_ID = process.env.USER_POOL_ID;
const USER_POOL_CLIENT_ID = process.env.USER_POOL_CLIENT_ID;
const verifier =
USER_POOL_ID && USER_POOL_CLIENT_ID
? CognitoJwtVerifier.create({ userPoolId: USER_POOL_ID, tokenUse: "id", clientId: USER_POOL_CLIENT_ID })
: undefined;

/** $connect: writes { connection_id, shop_id, counter_id, connected_at } per 04-API-SPEC.md §2's wss://...?shop_id=X&counter_id=Y. */
export async function handler(event: APIGatewayProxyWebsocketEventV2): Promise<APIGatewayProxyResultV2> {
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" };
Expand Down
68 changes: 68 additions & 0 deletions apps/api/src/lib/authContext.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } } } | 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";
}
Loading
Loading