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
122 changes: 122 additions & 0 deletions .agents/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Post-Deploy Wiring Instructions

This document describes the Terraform and backend wiring steps required after the
frontend changes in this PR are merged. These cannot be run inside Devin's VM and
must be executed in an environment with AWS credentials for account `445056752928`
in `us-east-1`.

---

## Summary of Infrastructure Changes

| Change | File | Impact |
|--------|------|--------|
| `genderPref` field stored on connections | `infra/src/handler.mjs` | No schema migration needed (DynamoDB is schemaless) |
| `GET /bundles` API route added | `infra/src/handler.mjs` | New read-only route; no new tables or IAM |
| No new DynamoDB tables | — | Existing `connections` + `posts` tables used |
| No new Lambda functions | — | All routes in the single `handler.mjs` monolith |

---

## Step 1: Deploy the Lambda (handler.mjs changes)

The handler update adds:
1. `genderPref` parsing + storage on `POST /connections`
2. `GET /bundles?userId=&connectionId=` route (reads from connections + posts tables)

```bash
cd infra

# Install handler dependencies (s3vectors SDK)
cd src && npm ci && cd ..

# Plan — should show only the Lambda function updating (source_code_hash change)
terraform plan -var-file=production.tfvars -out=plan.out

# Expected changes:
# ~ aws_lambda_function.api (source_code_hash, filename)
# ~ data.archive_file.api (output_base64sha256)
# NO new resources, NO IAM changes, NO table changes.

# Apply
terraform apply plan.out
```

### Verification

```bash
# Test genderPref is stored
curl -X POST https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com/connections \
-H "content-type: application/json" \
-d '{"senderId":"test_user","guest":{"name":"TestGuy","genderPref":"he","vibes":["tech"],"seeds":["pin_1"]}}'

# Test bundle endpoint
curl "https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com/bundles?userId=test_user&connectionId=<id_from_above>"
Comment on lines +49 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Secrets exposure: AWS account ID and live API endpoint hardcoded in documentation

The deploy instructions file (.agents/DEPLOY.md:6,49,54) hardcodes the AWS account ID 445056752928, region us-east-1, and the full live API Gateway endpoint URL https://tvyu8gqmki.execute-api.us-east-1.amazonaws.com. While the account ID and endpoint are also mentioned in CLAUDE.md (which predates this PR), this PR adds new files containing curl command examples with these values that make reconnaissance trivial for attackers.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the API endpoint and account ID are already in CLAUDE.md (committed before this PR) and this is a private repo. The information is not a secret (account IDs aren't sensitive, and the API endpoint is publicly routable by design). Keeping the curl examples as-is for developer convenience.

```

---

## Step 2: Verify Frontend Reads

After the Lambda is deployed, the frontend will:
1. Send `genderPref` in `POST /connections` when a challenge is completed
2. Read connections via `GET /connections?userId=` (already works, now returns `genderPref`)
3. Optionally call `GET /bundles` for server-side bundle generation (currently bundles
are computed client-side from seeds stored in the connection — the API route is a
future enhancement for when the catalog grows beyond what ships in `pins.ts`)

No `NEXT_PUBLIC_*` env var changes needed — the API base URL is unchanged.

---

## Step 3: Future Enhancements (not blocking this PR)

These are optional follow-ups that extend the bundle/delivery system:

### 3a. Real delivery date integration

Currently `estimatedDeliveryDays()` uses a simple price-tier heuristic (3/5/7 days).
To integrate real shipping data:

1. Add an `estimatedDeliveryDays` field to posts in DynamoDB (populate via PA-API
enrichment in `infra/ingest/paapi-enrich.mjs`)
2. Update `GET /bundles` to read `item.estimatedDeliveryDays` instead of computing it

### 3b. Maxi-powered bundle curation

The current bundle is a direct lookup of seed pins. To have Maxi (Bedrock) curate a
smarter bundle that accounts for gender preference, budget, and occasion:

1. Add a `POST /bundles/generate` route that invokes Bedrock Converse with the
connection's taste profile
2. Cache generated bundles in a new `bundles` DynamoDB table (PK: connectionId)
3. Update the frontend `SoloGiftCard` to call this endpoint

### 3c. One-click checkout

The "One-click checkout bundle" button is scaffolded in the frontend but not wired.
To complete:

1. Integrate with Amazon Associates / PA-API cart creation
2. Or implement Stripe Checkout for direct purchase flow
3. Add a `POST /checkout` route that creates an order record

### 3d. Gender-preference-aware vector recommendations

Currently gender preference only reorders the local PINS deck. To use it in the
vector recommender:

1. Add `genderPref` as a metadata filter in the S3 Vectors query
(`infra/src/handler.mjs` → `GET /recommendations` route)
2. Tag each vector with gender-affinity metadata during ingest
(`infra/ingest/ingest-pins.mjs` → add `genderAffinity` to vector metadata)

---

## No-Op Confirmation Checklist

Before applying, confirm:
- [ ] `terraform plan` shows ONLY the Lambda function update (no surprise resource creation)
- [ ] No new IAM permissions are required (bundles route reads from existing tables the Lambda already has access to)
- [ ] No DynamoDB table changes (genderPref is an optional attribute, no GSI needed)
- [ ] The `GET /bundles` route is NOT in `isPublicRoute()` — it requires auth (only the sender can view their own bundles)
83 changes: 81 additions & 2 deletions infra/src/handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2174,8 +2174,8 @@ export const handler = async (event) => {
}

// ── Soft profiles (viral swipe challenge) ────────────────────────────────
// POST /connections { senderId, guest:{ name, handle?, birthday?, vibes?,
// seeds?, interests?, yesCount?, totalSwipes? } }
// POST /connections { senderId, guest:{ name, handle?, birthday?, genderPref?,
// vibes?, seeds?, interests?, yesCount?, totalSwipes? } }
// Created when an invited guest finishes the swipe challenge. The sender
// (senderId, embedded in the invite link) "owns" the resulting soft profile;
// consent is implied by the guest completing a link the sender shared.
Expand All @@ -2192,6 +2192,18 @@ export const handler = async (event) => {
typeof guest.birthday === "string" && /^\d{4}-\d{2}-\d{2}$/.test(guest.birthday)
? guest.birthday
: undefined;
const VALID_GENDER_PREFS = ["he", "she", "they"];
const genderPref =
typeof guest.genderPref === "string" && VALID_GENDER_PREFS.includes(guest.genderPref)
? guest.genderPref
: undefined;
// Parse dwell timing signals (how long the guest spent on each card)
const dwellSignals = Array.isArray(guest.dwellSignals)
? guest.dwellSignals
.slice(0, 100)
.filter((s) => s && typeof s.id === "string" && typeof s.dwellMs === "number")
.map((s) => ({ id: String(s.id), dir: String(s.dir), dwellMs: Math.round(Number(s.dwellMs)) }))
: undefined;
const item = {
userId: senderId,
connectionId: `conn_${rid}`,
Expand All @@ -2200,11 +2212,13 @@ export const handler = async (event) => {
guestName: String(guest.name).trim().slice(0, 80),
guestHandle: guest.handle ? String(guest.handle).slice(0, 40) : undefined,
birthday,
genderPref,
vibes: Array.isArray(guest.vibes) ? guest.vibes.slice(0, 12).map(String) : [],
seeds: Array.isArray(guest.seeds) ? guest.seeds.slice(0, 20).map(String) : [],
interests: Array.isArray(guest.interests) ? guest.interests.slice(0, 12).map(String) : [],
yesCount: Number(guest.yesCount) || 0,
totalSwipes: Number(guest.totalSwipes) || 0,
dwellSignals,
seen: false,
createdAt: Date.now(),
};
Expand Down Expand Up @@ -2306,6 +2320,71 @@ export const handler = async (event) => {
return json(200, { ok: true, claimed });
}

// ── Gift bundles (Maxi's picks from a completed challenge) ─────────────────
// GET /bundles?connectionId=&userId= — generate a gift bundle from a
// completed swipe challenge. Uses the connection's seeds + genderPref to rank
// items and compute estimated delivery dates relative to the birthday/date.
if (method === "GET" && path === "/bundles") {
const userId = qs.userId;
const connectionId = qs.connectionId;
if (!userId || !connectionId) return json(400, { error: "userId and connectionId required" });
// Authorization: only the owner (or admin) can read their bundles
const auth = await authorizeRequest(event, method, path);
if (!(auth.via === "admin" || auth.sub === userId)) {
return json(403, { error: "forbidden" });
Comment on lines +2331 to +2334

@coderabbitai coderabbitai Bot Jun 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reuse the top-level auth result here.

Line 2324 re-runs the auth gate (request filter) unconditionally, so /bundles no longer follows the global AUTH_ENFORCE toggle. When enforcement is off, this endpoint still returns 403 for unsigned callers while sibling protected routes stay open, which creates environment drift.

Suggested fix
-      const auth = await authorizeRequest(event, method, path);
-      if (!(auth.via === "admin" || auth.sub === userId)) {
+      if (auth && !(auth.via === "admin" || auth.sub === userId)) {
         return json(403, { error: "forbidden" });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Authorization: only the owner (or admin) can read their bundles
const auth = await authorizeRequest(event, method, path);
if (!(auth.via === "admin" || auth.sub === userId)) {
return json(403, { error: "forbidden" });
// Authorization: only the owner (or admin) can read their bundles
if (auth && !(auth.via === "admin" || auth.sub === userId)) {
return json(403, { error: "forbidden" });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/src/handler.mjs` around lines 2323 - 2326, The bundles authorization
check is re-running request auth inside the handler, bypassing the global
AUTH_ENFORCE behavior and causing unsigned callers to be rejected even when
enforcement is off. Update the bundles read path in infra/src/handler.mjs to
reuse the already-computed top-level auth result instead of calling
authorizeRequest again, and keep the owner/admin check against that shared auth
object so /bundles follows the same toggle as the other protected routes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — /bundles (like /connections/claim at L2287) unconditionally enforces auth because it returns user-specific private data. The global AUTH_ENFORCE toggle is a development convenience for non-sensitive read routes; endpoints that expose personal data should never be unauthenticated regardless of the toggle state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

}
// Fetch the connection record
const connOut = await ddb.send(
new GetCommand({ TableName: CONNECTIONS, Key: { userId, connectionId } })
);
const conn = connOut.Item;
if (!conn) return json(404, { error: "connection not found" });
// Build a bundle from the seeds — query the posts table for matching items
const seeds = conn.seeds ?? [];
const genderPref = conn.genderPref; // "he" | "she" | "they" | undefined
const deadline = conn.birthday; // "YYYY-MM-DD" or undefined
let bundleItems = [];
if (seeds.length > 0) {
// Look up seed pins from the posts table
for (const seed of seeds.slice(0, 8)) {
const out = await ddb.send(new GetCommand({ TableName: POSTS, Key: { postId: seed } }));
if (out.Item) bundleItems.push(out.Item);
}
}
// Compute delivery estimates for each item
const today = new Date();
today.setHours(0, 0, 0, 0);
let deadlineDays = null;
if (deadline && /^\d{4}-\d{2}-\d{2}$/.test(deadline)) {
const [y, m, d] = deadline.split("-").map(Number);
const target = new Date(y, m - 1, d);
deadlineDays = Math.ceil((target.getTime() - today.getTime()) / 86_400_000);
}
const bundle = bundleItems.map((item) => {
const price = Number(item.price ?? item.product?.price) || 50;
const deliveryDays = price > 200 ? 7 : price > 100 ? 5 : 3;
const canDeliverByDeadline = deadlineDays === null || deliveryDays <= deadlineDays;
return {
postId: item.postId,
title: item.caption ?? item.product?.name ?? item.title ?? "",
image: item.product?.image ?? item.image ?? "",
price,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
category: item.category ?? item.product?.category,
deliveryDays,
canDeliverByDeadline,
};
});
Comment on lines +2367 to +2376

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Gift bundle items returned without title or image because the backend reads non-existent fields from stored posts

Bundle items are built by reading item.title and item.image (infra/src/handler.mjs:2358-2359) from DynamoDB post records, but ingested posts store those values as caption and product.image instead, so every bundle item is returned with undefined for both fields.

Impact: The gift bundle API endpoint returns items with no title and no image, rendering the bundle unusable for any consumer.

Post schema mismatch in the /bundles handler

Posts ingested via infra/ingest/ingest-pins.mjs:102 store the display name as caption: shortCaption(pin.title) and the image inside a nested object at product.image (infra/ingest/ingest-pins.mjs:129). There is no top-level title or image attribute on the DynamoDB item.

Other parts of the handler correctly handle this — e.g. infra/src/handler.mjs:874 uses String(p.product?.name || p.caption || p.title || p.name || "") and infra/src/handler.mjs:1282 uses a similar fallback chain. The /bundles handler should follow the same pattern.

Suggested change
return {
postId: item.postId,
title: item.title,
image: item.image,
price,
category: item.category,
deliveryDays,
canDeliverByDeadline,
};
});
return {
postId: item.postId,
title: item.caption ?? item.product?.name ?? item.title ?? "",
image: item.product?.image ?? item.image ?? "",
price,
category: item.category,
deliveryDays,
canDeliverByDeadline,
};
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9bb6f77 — field resolution now uses item.caption ?? item.product?.name ?? item.title ?? "" and item.product?.image ?? item.image ?? "", matching the fallback chain used by other handler routes. Also added item.product?.price fallback for price.

return json(200, {
connectionId,
guestName: conn.guestName,
genderPref,
deadline,
deadlineDays,
bundle,
bundleTotal: bundle.reduce((sum, i) => sum + i.price, 0),
});
}
Comment on lines +2327 to +2386

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Missing authorization check: /bundles endpoint allows any authenticated user to read another user's connection data

The GET /bundles endpoint (infra/src/handler.mjs:2319-2375) accepts userId and connectionId as query parameters and returns the connection's guest name, gender preference, birthday, and gift bundle. It only checks that the caller is authenticated (not in isPublicRoute), but does NOT verify that the authenticated user's identity (auth.sub) matches the requested userId. Any authenticated user can read any other user's connection data by guessing or enumerating connection IDs.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9bb6f77 — added authorizeRequest call and auth.sub === userId check (with admin bypass). Returns 403 if mismatched.


// ── Group gifts (pools) ──────────────────────────────────────────────────
// POST /pools { userId, name, pool:{ title, occasion, goal, blurb?, emoji?,
// grad?, image?, recipient? } } — create a pool; the creator becomes the
Expand Down
Loading
Loading