From cacda7c57b8469875436d989357733337fc60d8a Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:26:26 -0400 Subject: [PATCH 1/4] Note read-only purchase-orders consumption (INFRA-1) (#45) Mark the purchase-orders DynamoDB table as read-only and owned by procurement-ingest, and add a Shared Resources note recording the cross-repo schema-coordination rule. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ac4699..71fb767 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,17 @@ EventBridge (daily 02:00 UTC) | Conversation Log | DynamoDB `seahaven-conversations` (90-day TTL) | | Unanswered Questions | DynamoDB `seahaven-unanswered-questions` (180-day TTL) | | Payment Data | DynamoDB `PaymentsDashboard` (via payments-dashboard) | -| PO Data Source | DynamoDB `purchase-orders` (via po-ingest) | +| PO Data Source | DynamoDB `purchase-orders` (read-only; owned by procurement-ingest / po-ingest) | | Work Order Data Source | DynamoDB `WorkOrders` + `WorkOrderComments` (via workorder-ingest) | | Site Assignments | DynamoDB `verified-sites` (auto-populated via po-ingest Streams pipeline) | | VPC | `seahaven-vpc` (`vpc-0d3d4b67bd0cf8a68`) — QBO Lambdas + Socket Mode | | Static Outbound IP | `52.202.83.13` (NAT Gateway for Intuit IP allowlist) | | QBO OAuth URLs | `bot.seahaven.com/qbo/connect`, `/qbo/callback`, `/qbo/disconnect`, `/qbo/launch` | +### Shared Resources + +This bot reads the `purchase-orders` DynamoDB table **read-only** (via the `po-sync` and `wo-po-lookup` Lambdas, both granted `grantReadData`). The table is owned by the `procurement-ingest` repo (`po-ingest` stack), which is the sole authoritative writer. Any change to the `purchase-orders` schema must be coordinated with `procurement-ingest` (owner) and `payments-dashboard` (the other read-only consumer). + ## Prerequisites - Node.js 22+ From de55c0eeca58b3ec0788298d2fb3b05b6f6bef68 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:47:44 -0400 Subject: [PATCH 2/4] feat(api): add access logging and throttling to webhook HTTP API (#46) Adds an access log group (/aws/apigateway/seahaven-slack-webhook, 90-day retention) with JSON access-log format and DefaultRouteSettings throttling (rate 2 rps, burst 5) on the seahaven-slack-webhook HTTP API default stage, applied via CfnStage property overrides. Matches the pattern landed on seahaven-door-unlock-api. Refs INFRA-29 (AWS audit M-18) --- lib/constructs/slack-handler.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/constructs/slack-handler.ts b/lib/constructs/slack-handler.ts index 802faba..dfa8c2f 100644 --- a/lib/constructs/slack-handler.ts +++ b/lib/constructs/slack-handler.ts @@ -138,6 +138,33 @@ export class SlackHandlerConstruct extends Construct { }); } + // ── Access logging + throttling (audit M-18) ────────────────────────────── + const defaultStage = this.api.defaultStage!.node.defaultChild as apigatewayv2.CfnStage; + defaultStage.addPropertyOverride('DefaultRouteSettings', { + ThrottlingBurstLimit: 50, + ThrottlingRateLimit: 100, + }); + + const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogGroup', { + logGroupName: '/aws/apigateway/seahaven-slack-webhook', + retention: logs.RetentionDays.THREE_MONTHS, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + defaultStage.addPropertyOverride('AccessLogSettings', { + DestinationArn: apiAccessLogGroup.logGroupArn, + Format: JSON.stringify({ + requestId: '$context.requestId', + ip: '$context.identity.sourceIp', + requestTime: '$context.requestTime', + method: '$context.httpMethod', + routeKey: '$context.routeKey', + status: '$context.status', + protocol: '$context.protocol', + responseLength: '$context.responseLength', + integrationError: '$context.integrationErrorMessage', + }), + }); + // ── Custom domain: bot.seahaven.com ─────────────────────────────────────── const certificate = acm.Certificate.fromCertificateArn( this, 'WildcardCert', props.wildcardCertArn, From b3c75c88d22071c7786db3ad8ddaa3654a071903 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:42:40 -0400 Subject: [PATCH 3/4] Fix notion-sync header crash on non-ASCII titles (#48) S3 user-metadata values are sent as HTTP headers, which must be US-ASCII. Notion page titles routinely contain em dashes and other non-ASCII characters, causing ERR_INVALID_CHAR and a daily sync failure. Sanitize the notion-title metadata to printable ASCII. Also treat a Bedrock ConflictException from StartIngestionJob as benign (an ingestion job is already running and will pick up the freshly uploaded files) instead of failing the whole invocation. --- lambda/notion-sync/index.ts | 45 ++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/lambda/notion-sync/index.ts b/lambda/notion-sync/index.ts index 696e5eb..7837152 100644 --- a/lambda/notion-sync/index.ts +++ b/lambda/notion-sync/index.ts @@ -44,6 +44,17 @@ function getPageTitle(page: Record): string { return page.id as string; } +// S3 user-metadata values travel as HTTP headers, which must be US-ASCII with no +// control characters. Notion titles routinely contain em dashes and other +// non-ASCII characters (ERR_INVALID_CHAR otherwise). Strip to safe printable +// ASCII and cap at the 256-char metadata limit. +function sanitizeMetadataValue(value: string): string { + return value + .replace(/[^\x20-\x7E]/g, '') // drop non-printable / non-ASCII + .trim() + .slice(0, 256); +} + async function clearOldFiles(bucket: string): Promise { let continuationToken: string | undefined; const toDelete: { Key: string }[] = []; @@ -128,7 +139,7 @@ export const handler = async (): Promise => { ContentType: 'text/markdown', Metadata: { 'notion-page-id': pageId, - 'notion-title': title.slice(0, 256), // S3 metadata value limit + 'notion-title': sanitizeMetadataValue(title), }, }), ); @@ -144,13 +155,27 @@ export const handler = async (): Promise => { console.log(`Upload complete. ${synced} synced, ${failed} failed.`); console.log('Triggering Bedrock KB ingestion job...'); - const ingestionRes = await bedrockAgent.send( - new StartIngestionJobCommand({ - knowledgeBaseId: kbId, - dataSourceId: dsId, - }), - ); - console.log( - `Ingestion job started: ${ingestionRes.ingestionJob?.ingestionJobId}`, - ); + try { + const ingestionRes = await bedrockAgent.send( + new StartIngestionJobCommand({ + knowledgeBaseId: kbId, + dataSourceId: dsId, + }), + ); + console.log( + `Ingestion job started: ${ingestionRes.ingestionJob?.ingestionJobId}`, + ); + } catch (err) { + // A ConflictException means an ingestion job is already running for this data + // source (e.g. an overlapping run). The freshly uploaded files will be picked + // up by that in-flight job, so this is benign — log and exit cleanly rather + // than failing the whole invocation. + if (err instanceof Error && err.name === 'ConflictException') { + console.log( + 'Ingestion job already in progress; uploaded files will be picked up by the running job. Skipping.', + ); + return; + } + throw err; + } }; From 5175d39eb9e4cc88a691238c9727d8555c91212f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:01:08 -0400 Subject: [PATCH 4/4] fix(kb): lock AOSS network policy to private with Bedrock source service (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @cdklabs/generative-ai-cdk-constructs VectorKnowledgeBase hardcodes AllowFromPublic: true on the auto-created AOSS network policy and exposes no prop to change it. Override the underlying CfnSecurityPolicy to set the collection rule to AllowFromPublic: false with SourceServices: ['bedrock.amazonaws.com'] — the latter is required to keep Bedrock-managed retrieval working once public access is removed (a SourceVPCEs-only policy returns 401 for Bedrock retrieve). Dashboard rule kept public for console access; AWS services cannot reach Dashboards. Same end state was applied live via update-security-policy and smoke-tested (retrieve returns hits, top score ~0.40) so this deploy is a no-op convergence. INFRA-92 --- lib/constructs/knowledge-base.ts | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/lib/constructs/knowledge-base.ts b/lib/constructs/knowledge-base.ts index d412e46..0fc9f3c 100644 --- a/lib/constructs/knowledge-base.ts +++ b/lib/constructs/knowledge-base.ts @@ -1,6 +1,7 @@ import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as oss from 'aws-cdk-lib/aws-opensearchserverless'; import { bedrock } from '@cdklabs/generative-ai-cdk-constructs'; export interface KnowledgeBaseProps { @@ -37,6 +38,57 @@ export class KnowledgeBaseConstruct extends Construct { instruction: 'Use this knowledge base to answer questions about Sea Haven Industries company policies, SOPs, SA8000 social accountability compliance requirements, approved vendor lists, the employee handbook, work order status and history, and purchase order details.', }); + // Lock the auto-created AOSS network policy to private (INFRA-92). + // @cdklabs/generative-ai-cdk-constructs hardcodes AllowFromPublic: true on the + // VectorCollection's network policy and exposes no prop to change it, so we reach + // the underlying CfnSecurityPolicy via the construct tree and override its Policy. + // SourceServices: ['bedrock.amazonaws.com'] is REQUIRED — it is what keeps + // Bedrock-managed retrieval working once public access is removed. A SourceVPCEs-only + // policy returns 401 for Bedrock retrieval. Dashboard rule kept for console access + // (AWS services cannot reach Dashboards regardless). + const vectorCollection = this.knowledgeBase.vectorStore as Construct; + const networkPolicy = vectorCollection.node.findChild('NetworkPolicy'); + if (!(networkPolicy instanceof oss.CfnSecurityPolicy)) { + throw new Error( + "Expected child 'NetworkPolicy' of the AOSS VectorCollection to be a CfnSecurityPolicy. " + + 'The @cdklabs/generative-ai-cdk-constructs internals may have changed — review knowledge-base.ts (INFRA-92).', + ); + } + const collectionName = (this.knowledgeBase.vectorStore as { collectionName?: string }).collectionName; + if (!collectionName) { + throw new Error( + 'Could not resolve the AOSS collection name from vectorStore — check the @cdklabs construct API (INFRA-92).', + ); + } + // Policy is typed as a JSON string on the L1 CfnSecurityPolicy, so it must be stringified. + networkPolicy.addPropertyOverride( + 'Policy', + JSON.stringify([ + { + Rules: [ + { + ResourceType: 'collection', + Resource: [`collection/${collectionName}`], + }, + ], + AllowFromPublic: false, + SourceServices: ['bedrock.amazonaws.com'], + }, + { + Rules: [ + { + ResourceType: 'dashboard', + Resource: [`collection/${collectionName}`], + }, + ], + // INFRA-92: dashboard endpoint intentionally left public for console access. + // AWS services (incl. Bedrock) cannot reach Dashboards regardless, so this does + // not affect the data plane. Remove this rule to fully lock down console access. + AllowFromPublic: true, + }, + ]), + ); + // S3 data source — chunking configured via ChunkingStrategy.fixedSize() this.dataSource = new bedrock.S3DataSource(this, 'S3DataSource', { bucket: this.docsBucket,