From 00f783157dfc299d37892299b389591f7b0f8203 Mon Sep 17 00:00:00 2001 From: Mahakisore7 Date: Sat, 19 Sep 2026 19:24:40 +0530 Subject: [PATCH] feat: low-stock and conflict-needs-review email alerts via SES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds notifyAlerts.ts, a new Lambda consuming inventory_records' DynamoDB Stream — enabled since Phase 1 but never actually wired to anything (the existing real-time push to connected clients goes through wsPush.ts's direct in-process call from conflictResolver.ts instead). This makes the notifications feature a pure read-side addition: zero changes to the correctness-critical write pipeline. For each stream record, compares OLD vs NEW images and fires an email only on the edge that matters: - stock crossing at/under a configurable threshold (default 5), not every write to an already-low item - conflict_status newly becoming needs_review, not every write to an already-flagged item The recipient is looked up dynamically per-shop: Cognito's "owner" group holds every shop's owner together, so notifyAlerts.ts pages through ListUsersInGroup and filters by custom:shop_id client-side (no new shop->owner index needed at this scale). SES send failures are caught and logged, never thrown — same degrade-gracefully principle as bedrock.ts's non-blocking explainer, since alerts are advisory and must never affect the resolver pipeline. SES starts every AWS account in sandbox mode, where both sender and every recipient must be individually verified. config.ts's alertsFromEmail (kisore2004@gmail.com) is used for both roles for now, documented as a known constraint — real per-shop delivery needs a production-access request, same shape as the existing Bedrock account-gate workaround. Also adds one narrow, documented exception to the CDK "no wildcard IAM resources" test: dynamodb:ListStreams has no resource-level IAM scoping at all (confirmed against the synthesized template) — CDK's own DynamoEventSource construct always generates it against Resource: "*". Every other action in the alert function's policy stays scoped. Tests: 11 new API tests (notifyAlerts.test.ts, covering the crossing logic, owner lookup + pagination, REMOVE-event skip, graceful failure on no-owner-found/SES-rejection/malformed-record), 3 new CDK tests for the Notifications construct. Full suites verified green: 100/100 API, 37/37 CDK. Co-Authored-By: Claude Sonnet 5 --- apps/api/package.json | 2 + apps/api/src/handlers/notifyAlerts.ts | 121 ++++++++++++++ apps/api/test/notifyAlerts.test.ts | 186 ++++++++++++++++++++++ infra/cdk/lib/config.ts | 9 ++ infra/cdk/lib/constructs/Notifications.ts | 67 ++++++++ infra/cdk/lib/stocksync-stack.ts | 9 ++ infra/cdk/test/stocksync-stack.test.ts | 53 +++++- package-lock.json | 22 +++ 8 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/handlers/notifyAlerts.ts create mode 100644 apps/api/test/notifyAlerts.test.ts create mode 100644 infra/cdk/lib/constructs/Notifications.ts diff --git a/apps/api/package.json b/apps/api/package.json index 4842401..4aaa069 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,8 +17,10 @@ "@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-sesv2": "^3.1136.0", "@aws-sdk/client-sqs": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.700.0", + "@aws-sdk/util-dynamodb": "^3.996.9", "@stocksync/core": "*", "aws-jwt-verify": "^4.0.1" }, diff --git a/apps/api/src/handlers/notifyAlerts.ts b/apps/api/src/handlers/notifyAlerts.ts new file mode 100644 index 0000000..6290684 --- /dev/null +++ b/apps/api/src/handlers/notifyAlerts.ts @@ -0,0 +1,121 @@ +import { + CognitoIdentityProviderClient, + ListUsersInGroupCommand, + type UserType, +} from "@aws-sdk/client-cognito-identity-provider"; +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import type { DynamoDBStreamEvent } from "aws-lambda"; +import { createLogger } from "../lib/logger"; +import type { InventoryRecordItem } from "../lib/inventoryRecord"; + +const logger = createLogger("notifyAlerts"); + +const cognito = new CognitoIdentityProviderClient({}); +const ses = new SESv2Client({}); + +const USER_POOL_ID = process.env.USER_POOL_ID ?? ""; +const FROM_EMAIL = process.env.SES_FROM_EMAIL ?? ""; +const LOW_STOCK_THRESHOLD = process.env.LOW_STOCK_THRESHOLD ? Number(process.env.LOW_STOCK_THRESHOLD) : 5; + +function attr(user: UserType, name: string): string | undefined { + return user.Attributes?.find((a) => a.Name === name)?.Value; +} + +/** + * Owners aren't grouped per-shop in Cognito (the "owner" group holds every + * shop's owner) — so finding the one for this shop_id means paging through + * the whole group and filtering client-side by custom:shop_id. Acceptable + * at hackathon/demo scale; would need a shop->owner index table to stay + * cheap at real scale. + */ +async function findShopOwnerEmail(shopId: string): Promise { + let nextToken: string | undefined; + do { + const response = await cognito.send( + new ListUsersInGroupCommand({ UserPoolId: USER_POOL_ID, GroupName: "owner", NextToken: nextToken }), + ); + const match = (response.Users ?? []).find((user) => attr(user, "custom:shop_id") === shopId); + if (match) return attr(match, "email"); + nextToken = response.NextToken; + } while (nextToken); + return undefined; +} + +async function sendAlertEmail(to: string, subject: string, body: string): Promise { + try { + await ses.send( + new SendEmailCommand({ + FromEmailAddress: FROM_EMAIL, + Destination: { ToAddresses: [to] }, + Content: { Simple: { Subject: { Data: subject }, Body: { Text: { Data: body } } } }, + }), + ); + } catch (error) { + // Notifications are advisory, never correctness-critical — same + // degrade-gracefully principle as bedrock.ts's explainer. A failed + // send (e.g. SES sandbox rejecting an unverified recipient) must never + // fail the stream-triggered invocation. + logger.error("SES send failed", { error, to, subject }); + } +} + +function crossedIntoLowStock(oldItem: InventoryRecordItem | undefined, newItem: InventoryRecordItem): boolean { + return newItem.stock <= LOW_STOCK_THRESHOLD && (!oldItem || oldItem.stock > LOW_STOCK_THRESHOLD); +} + +function enteredNeedsReview(oldItem: InventoryRecordItem | undefined, newItem: InventoryRecordItem): boolean { + return newItem.conflict_status === "needs_review" && oldItem?.conflict_status !== "needs_review"; +} + +/** + * Triggered by inventory_records' DynamoDB Stream (enabled since Phase 1, + * never actually consumed until now — real-time push to connected clients + * goes through wsPush.ts's direct in-process call from conflictResolver.ts + * instead, so this stream was sitting unused). Compares OLD vs NEW images + * to fire an email only on the edge that matters — crossing *into* low + * stock, or a conflict newly appearing — never on every write to an + * already-low or already-flagged item, which would spam the owner on each + * subsequent sale. + */ +export async function handler(event: DynamoDBStreamEvent): Promise { + for (const record of event.Records) { + try { + if (record.eventName === "REMOVE" || !record.dynamodb?.NewImage) continue; + + const newItem = unmarshall(record.dynamodb.NewImage as Record) as InventoryRecordItem; + const oldItem = record.dynamodb.OldImage + ? (unmarshall(record.dynamodb.OldImage as Record) as InventoryRecordItem) + : undefined; + + const lowStock = crossedIntoLowStock(oldItem, newItem); + const newConflict = enteredNeedsReview(oldItem, newItem); + if (!lowStock && !newConflict) continue; + + const ownerEmail = await findShopOwnerEmail(newItem.shop_id); + if (!ownerEmail) { + logger.warn("no owner found for shop, skipping alert", { shop_id: newItem.shop_id }); + continue; + } + + if (lowStock) { + await sendAlertEmail( + ownerEmail, + `Low stock: ${newItem.name ?? newItem.item_id}`, + `${newItem.name ?? newItem.item_id} is down to ${newItem.stock} unit(s), at or below your threshold of ${LOW_STOCK_THRESHOLD}.`, + ); + } + if (newConflict) { + await sendAlertEmail( + ownerEmail, + `Conflict needs review: ${newItem.name ?? newItem.item_id}`, + `${newItem.name ?? newItem.item_id} has a data conflict that needs your review — two counters recorded different values for the same field.`, + ); + } + } catch (error) { + // One malformed/unexpected record must never block the rest of the + // batch or cause the stream shard to stall retrying it forever. + logger.error("failed to process stream record", { error }); + } + } +} diff --git a/apps/api/test/notifyAlerts.test.ts b/apps/api/test/notifyAlerts.test.ts new file mode 100644 index 0000000..b013937 --- /dev/null +++ b/apps/api/test/notifyAlerts.test.ts @@ -0,0 +1,186 @@ +import { CognitoIdentityProviderClient, ListUsersInGroupCommand } from "@aws-sdk/client-cognito-identity-provider"; +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import type { DynamoDBStreamEvent } from "aws-lambda"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { InventoryRecordItem } from "../src/lib/inventoryRecord"; + +const cognitoMock = mockClient(CognitoIdentityProviderClient); +const sesMock = mockClient(SESv2Client); + +// notifyAlerts.ts reads USER_POOL_ID/SES_FROM_EMAIL once at module load — +// same static-import-hoisting reason staffInvite.test.ts uses a dynamic +// import behind a beforeAll. +let handler: typeof import("../src/handlers/notifyAlerts").handler; + +beforeAll(async () => { + process.env.USER_POOL_ID = "pool-1"; + process.env.SES_FROM_EMAIL = "alerts@shop.com"; + process.env.LOW_STOCK_THRESHOLD = "5"; + ({ handler } = await import("../src/handlers/notifyAlerts")); +}); + +function baseItem(overrides: Partial = {}): InventoryRecordItem { + return { + pk: "SHOP#shop-1#ITEM#parle-g", + sk: "CURRENT", + shop_id: "shop-1", + item_id: "parle-g", + name: "Parle-G 100g", + base_stock: 40, + pn_counter: { increments: {}, decrements: {} }, + stock: 40, + vector_clock: {}, + field_last_writer: {}, + conflict_status: "none", + updated_at: "2026-09-19T00:00:00.000Z", + ...overrides, + }; +} + +function streamEvent(params: { + eventName?: "INSERT" | "MODIFY" | "REMOVE"; + oldItem?: InventoryRecordItem; + newItem?: InventoryRecordItem; +}): DynamoDBStreamEvent { + const { eventName = "MODIFY", oldItem, newItem } = params; + return { + Records: [ + { + eventName, + dynamodb: { + OldImage: oldItem ? (marshall(oldItem) as never) : undefined, + NewImage: newItem ? (marshall(newItem) as never) : undefined, + }, + }, + ], + } as unknown as DynamoDBStreamEvent; +} + +const OWNER_USER = { + Username: "owner-1", + Attributes: [ + { Name: "email", Value: "owner@shop-1.com" }, + { Name: "custom:shop_id", Value: "shop-1" }, + ], +}; + +beforeEach(() => { + cognitoMock.reset(); + sesMock.reset(); + cognitoMock.on(ListUsersInGroupCommand).resolves({ Users: [OWNER_USER] }); + sesMock.on(SendEmailCommand).resolves({}); +}); + +describe("notifyAlerts (inventory_records stream consumer)", () => { + it("sends a low-stock alert when stock crosses at/under the threshold", async () => { + const event = streamEvent({ + oldItem: baseItem({ stock: 10 }), + newItem: baseItem({ stock: 3 }), + }); + await handler(event); + + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(1); + const call = sesMock.commandCalls(SendEmailCommand)[0].args[0].input; + expect(call.Destination?.ToAddresses).toEqual(["owner@shop-1.com"]); + expect(call.Content?.Simple?.Subject?.Data).toMatch(/low stock/i); + }); + + it("does not re-alert when stock was already at/under the threshold", async () => { + const event = streamEvent({ + oldItem: baseItem({ stock: 3 }), + newItem: baseItem({ stock: 2 }), + }); + await handler(event); + + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0); + }); + + it("treats a brand-new item (no OldImage) at/under the threshold as crossing in", async () => { + const event = streamEvent({ eventName: "INSERT", newItem: baseItem({ stock: 1 }) }); + await handler(event); + + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(1); + }); + + it("sends a conflict alert when conflict_status newly becomes needs_review", async () => { + const event = streamEvent({ + oldItem: baseItem({ conflict_status: "none" }), + newItem: baseItem({ conflict_status: "needs_review" }), + }); + await handler(event); + + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(1); + const call = sesMock.commandCalls(SendEmailCommand)[0].args[0].input; + expect(call.Content?.Simple?.Subject?.Data).toMatch(/conflict/i); + }); + + it("does not re-alert when conflict_status was already needs_review", async () => { + const event = streamEvent({ + oldItem: baseItem({ conflict_status: "needs_review" }), + newItem: baseItem({ conflict_status: "needs_review" }), + }); + await handler(event); + + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0); + }); + + it("sends both alerts when a single write both drops stock low and flags a conflict", async () => { + const event = streamEvent({ + oldItem: baseItem({ stock: 10, conflict_status: "none" }), + newItem: baseItem({ stock: 1, conflict_status: "needs_review" }), + }); + await handler(event); + + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(2); + }); + + it("ignores REMOVE events entirely", async () => { + const event = streamEvent({ eventName: "REMOVE", oldItem: baseItem({ stock: 1 }) }); + await handler(event); + + expect(cognitoMock.commandCalls(ListUsersInGroupCommand)).toHaveLength(0); + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0); + }); + + it("skips gracefully when no owner is found for the shop, never throwing", async () => { + cognitoMock.on(ListUsersInGroupCommand).resolves({ Users: [] }); + const event = streamEvent({ oldItem: baseItem({ stock: 10 }), newItem: baseItem({ stock: 1 }) }); + + await expect(handler(event)).resolves.toBeUndefined(); + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(0); + }); + + it("pages through ListUsersInGroup to find the matching shop's owner", async () => { + cognitoMock + .on(ListUsersInGroupCommand) + .resolvesOnce({ + Users: [{ Username: "other", Attributes: [{ Name: "email", Value: "x@other.com" }, { Name: "custom:shop_id", Value: "shop-2" }] }], + NextToken: "page-2", + }) + .resolvesOnce({ Users: [OWNER_USER] }); + + const event = streamEvent({ oldItem: baseItem({ stock: 10 }), newItem: baseItem({ stock: 1 }) }); + await handler(event); + + expect(cognitoMock.commandCalls(ListUsersInGroupCommand)).toHaveLength(2); + expect(sesMock.commandCalls(SendEmailCommand)[0].args[0].input.Destination?.ToAddresses).toEqual(["owner@shop-1.com"]); + }); + + it("never throws when SES rejects the send (e.g. sandbox-unverified recipient)", async () => { + sesMock.on(SendEmailCommand).rejects(new Error("MessageRejected: Email address is not verified")); + const event = streamEvent({ oldItem: baseItem({ stock: 10 }), newItem: baseItem({ stock: 1 }) }); + + await expect(handler(event)).resolves.toBeUndefined(); + }); + + it("processes remaining records even if one record is malformed", async () => { + const good = streamEvent({ oldItem: baseItem({ stock: 10 }), newItem: baseItem({ stock: 1 }) }).Records[0]; + const malformed = { eventName: "MODIFY", dynamodb: { NewImage: { bogus: "not-a-valid-attribute-value" } } }; + const event = { Records: [malformed, good] } as unknown as DynamoDBStreamEvent; + + await expect(handler(event)).resolves.toBeUndefined(); + expect(sesMock.commandCalls(SendEmailCommand)).toHaveLength(1); + }); +}); diff --git a/infra/cdk/lib/config.ts b/infra/cdk/lib/config.ts index 7fed195..cfdb060 100644 --- a/infra/cdk/lib/config.ts +++ b/infra/cdk/lib/config.ts @@ -29,3 +29,12 @@ export const bedrockCrossAccountId = "368339042048"; export const bedrockCrossAccountRegion = "us-east-1"; export const bedrockInferenceProfileArn = `arn:aws:bedrock:${bedrockCrossAccountRegion}:${bedrockCrossAccountId}:inference-profile/us.${bedrockModelId}`; + +// Phase 4 (notifications): the verified SES sender identity for low-stock/ +// conflict alert emails. SES starts every new account in sandbox mode, +// where both the sender AND every recipient must be verified individually +// — using the same address for both keeps this deployable without a +// production-access request. Real per-shop delivery (a different owner's +// inbox) only succeeds once this account exits sandbox, same kind of +// account-level gate as the Bedrock approval above. +export const alertsFromEmail = "kisore2004@gmail.com"; diff --git a/infra/cdk/lib/constructs/Notifications.ts b/infra/cdk/lib/constructs/Notifications.ts new file mode 100644 index 0000000..4d6b88a --- /dev/null +++ b/infra/cdk/lib/constructs/Notifications.ts @@ -0,0 +1,67 @@ +import * as path from "node:path"; +import { Duration } from "aws-cdk-lib"; +import { Table } from "aws-cdk-lib/aws-dynamodb"; +import { PolicyStatement } from "aws-cdk-lib/aws-iam"; +import { Runtime, StartingPosition } from "aws-cdk-lib/aws-lambda"; +import { DynamoEventSource } from "aws-cdk-lib/aws-lambda-event-sources"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { EmailIdentity, Identity } from "aws-cdk-lib/aws-ses"; +import type { UserPool } from "aws-cdk-lib/aws-cognito"; +import { Construct } from "constructs"; +import { alertsFromEmail } from "../config"; + +export interface NotificationsProps { + inventoryRecordsTable: Table; + userPool: UserPool; +} + +/** + * Phase 4 — low-stock and conflict-needs-review email alerts. Consumes + * inventory_records' DynamoDB Stream (enabled since Phase 1, never + * actually wired to anything — see DataLayer.ts) rather than adding a new + * write-path hook, so this is a pure read-side addition with zero risk to + * the correctness-critical resolver pipeline. + */ +export class Notifications extends Construct { + public readonly notifyAlertsFn: NodejsFunction; + + constructor(scope: Construct, id: string, props: NotificationsProps) { + super(scope, id); + + const emailIdentity = new EmailIdentity(this, "AlertsSenderIdentity", { + identity: Identity.email(alertsFromEmail), + }); + + this.notifyAlertsFn = new NodejsFunction(this, "NotifyAlertsFunction", { + entry: path.join(__dirname, "../../../../apps/api/src/handlers/notifyAlerts.ts"), + handler: "handler", + runtime: Runtime.NODEJS_22_X, + timeout: Duration.seconds(15), + environment: { + USER_POOL_ID: props.userPool.userPoolId, + SES_FROM_EMAIL: alertsFromEmail, + }, + }); + + this.notifyAlertsFn.addToRolePolicy( + new PolicyStatement({ + actions: ["cognito-idp:ListUsersInGroup"], + resources: [props.userPool.userPoolArn], + }), + ); + this.notifyAlertsFn.addToRolePolicy( + new PolicyStatement({ + actions: ["ses:SendEmail"], + resources: [emailIdentity.emailIdentityArn], + }), + ); + + this.notifyAlertsFn.addEventSource( + new DynamoEventSource(props.inventoryRecordsTable, { + startingPosition: StartingPosition.LATEST, + batchSize: 10, + retryAttempts: 2, + }), + ); + } +} diff --git a/infra/cdk/lib/stocksync-stack.ts b/infra/cdk/lib/stocksync-stack.ts index c988369..bcec098 100644 --- a/infra/cdk/lib/stocksync-stack.ts +++ b/infra/cdk/lib/stocksync-stack.ts @@ -9,6 +9,7 @@ import { RealtimeApi } from "./constructs/RealtimeApi"; import { Observability } from "./constructs/Observability"; import { PlatformCrud } from "./constructs/PlatformCrud"; import { Analytics } from "./constructs/Analytics"; +import { Notifications } from "./constructs/Notifications"; export class StocksyncStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { @@ -81,5 +82,13 @@ export class StocksyncStack extends Stack { inventoryRecordsTable: dataLayer.inventoryRecordsTable, ordersTable: platformCrud.ordersTable, }); + + // Low-stock/conflict email alerts — consumes inventory_records' stream + // (already enabled, previously unused; real-time push to clients goes + // through wsPush.ts's direct call from conflictResolver.ts instead). + new Notifications(this, "Notifications", { + inventoryRecordsTable: dataLayer.inventoryRecordsTable, + userPool: auth.userPool, + }); } } diff --git a/infra/cdk/test/stocksync-stack.test.ts b/infra/cdk/test/stocksync-stack.test.ts index 0483f87..8812be0 100644 --- a/infra/cdk/test/stocksync-stack.test.ts +++ b/infra/cdk/test/stocksync-stack.test.ts @@ -2,7 +2,7 @@ import { App } from "aws-cdk-lib"; import { Match, Template } from "aws-cdk-lib/assertions"; import { describe, expect, it } from "vitest"; import { StocksyncStack } from "../lib/stocksync-stack"; -import { bedrockModelId, bedrockInferenceProfileArn } from "../lib/config"; +import { bedrockModelId, bedrockInferenceProfileArn, alertsFromEmail } from "../lib/config"; function synthTemplate(): Template { const app = new App(); @@ -447,6 +447,49 @@ describe("StocksyncStack — Analytics (Phase 3, owner dashboard)", () => { }); }); +describe("StocksyncStack — Notifications (Phase 4, low-stock/conflict alerts)", () => { + it("creates exactly one verified SES sender identity for the configured alerts address", () => { + const template = synthTemplate(); + template.resourceCountIs("AWS::SES::EmailIdentity", 1); + template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: alertsFromEmail }); + }); + + it("wires the alert function as a DynamoDB Stream consumer of inventory_records, not a new write-path hook", () => { + const template = synthTemplate(); + const mappings = template.findResources("AWS::Lambda::EventSourceMapping"); + const alertMapping = Object.values(mappings).find((mapping) => + JSON.stringify(mapping).includes("NotifyAlertsFunction"), + ); + expect(alertMapping).toBeDefined(); + expect( + (alertMapping as { Properties: { EventSourceArn: unknown } }).Properties.EventSourceArn, + ).toBeDefined(); + }); + + it("grants the alert function only cognito:ListUsersInGroup and ses:SendEmail, scoped, never a DynamoDB write action", () => { + const template = synthTemplate(); + const policies = template.findResources("AWS::IAM::Policy"); + const alertPolicy = Object.values(policies).find((policy) => + JSON.stringify(policy).includes("NotifyAlertsFunction"), + ); + expect(alertPolicy).toBeDefined(); + const statements = ( + alertPolicy as { Properties: { PolicyDocument: { Statement: { Action: string | string[]; Resource: unknown }[] } } } + ).Properties.PolicyDocument.Statement; + const actions = statements.flatMap((statement) => (Array.isArray(statement.Action) ? statement.Action : [statement.Action])); + + expect(actions).toEqual( + expect.arrayContaining(["cognito-idp:ListUsersInGroup", "ses:SendEmail"]), + ); + // DynamoEventSource also grants stream-read actions (GetRecords, + // DescribeStream, etc.) — those are expected; only a write action + // would be a real problem, since this Lambda must never mutate data. + expect(actions.some((action) => action.includes("Put") || action.includes("Update") || action.includes("Delete"))).toBe( + false, + ); + }); +}); + describe("StocksyncStack — no wildcard IAM resources", () => { it("never grants a DynamoDB or SQS action against a wildcard resource", () => { const template = synthTemplate(); @@ -459,6 +502,14 @@ describe("StocksyncStack — no wildcard IAM resources", () => { const actions = Array.isArray(statement.Action) ? statement.Action : [statement.Action]; const touchesDataPlane = actions.some((action) => action.startsWith("dynamodb:") || action.startsWith("sqs:")); if (!touchesDataPlane) continue; + // dynamodb:ListStreams is the one documented exception: unlike + // GetRecords/GetShardIterator/DescribeStream, IAM has no + // resource-level authorization for it at all — AWS's own + // DynamoEventSource construct (Notifications.ts's stream + // consumer) always generates it against Resource: "*". Every + // other action in this statement set must still be scoped. + const onlyListStreams = actions.every((action) => action === "dynamodb:ListStreams"); + if (onlyListStreams && statement.Resource === "*") continue; expect(statement.Resource).not.toBe("*"); } } diff --git a/package-lock.json b/package-lock.json index 6021d06..9a378d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,8 +40,10 @@ "@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-sesv2": "^3.1136.0", "@aws-sdk/client-sqs": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.700.0", + "@aws-sdk/util-dynamodb": "^3.996.9", "@stocksync/core": "*", "aws-jwt-verify": "^4.0.1" }, @@ -363,6 +365,26 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.1136.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.1136.0.tgz", + "integrity": "sha512-CbDMGe3NMq2XQ/RCsbAEEas4X/pmoTpWZpc4FU41yHJ5rtBp33VnXr/rkIq/wZoB3y6CN3m+1U59PwZEDCOXOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-node": "^3.972.83", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sqs": { "version": "3.1133.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1133.0.tgz",