Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
97ea7d8
fix(seo): add OG/Twitter cards, canonical, clean robots.txt
May 7, 2026
c35ec61
fix(llms): remove resolve_product_query — not deployed to live MCP en…
May 7, 2026
01d4d1f
fix(aeo): fix /integrate page — correct MCP tool names and count
May 7, 2026
422de99
Add 50 Best US Home Appliances landing page configs (BUY-13346)
May 7, 2026
c2aecbf
Add 59 Best US + Cheapest deal page configs (restoring BUY-13124, BUY…
May 7, 2026
7d049e3
Add 59 page.tsx files for new Best US + Cheapest deal pages
May 7, 2026
2948311
fix(aeo): add find_best_price to API docs tools table
May 7, 2026
a02e5f0
Add directory listings and content assets
May 7, 2026
6ae5df4
Add Product Hunt launch copy for BuyWhere
May 7, 2026
763ae8d
Add MCP server directory listing for BuyWhere
May 7, 2026
1ae9a90
Add API listings directory content for BuyWhere
May 7, 2026
6021b75
Add comparison page: Best AI Shopping Agents & Price APIs
May 7, 2026
7e92b09
Add MCP servers for shopping comparison page
May 7, 2026
3bc1845
fix(aeo): fix ai-plugin.json — broken logo URL, inaccurate descriptions
May 7, 2026
f179ea7
Add outdoor-living editorial content for Home & Garden pages
May 7, 2026
02a48f9
Add FAQPage schema to mcp-ecommerce page
May 7, 2026
6d3b218
fix(content): correct MCP tool names in api-listings-buywhere.md
May 7, 2026
b26f6b6
fix(content): correct MCP tool names, pricing, install command in mcp…
May 7, 2026
a0218c3
Add FAQPage schema to developers page
May 7, 2026
425c45d
Add FAQPage schema to integrate page
May 7, 2026
08a5a60
Add FAQPage schema to api-keys page
May 7, 2026
d6da825
fix(content): correct MCP tool names across 4 directory files
May 7, 2026
c5a320b
Add FAQPage schema to quickstart page
May 7, 2026
15de25c
fix(BUY-12779): update nginx upstream from 8000 to 3000
May 7, 2026
4c12846
Add catch-all route for compare content pages
May 7, 2026
ff1eddf
Fix catch-all compare route to extract H1 as title when no frontmatter
May 7, 2026
bc5d526
Add directory listing pages with SoftwareApplication schema
May 7, 2026
514145a
Add Superior Lighting US scraper - BigCommerce Catalyst, sitemap craw…
May 7, 2026
31604e7
Add BuyWhere vs Lister AI comparison content
May 7, 2026
276b0ab
fix(content): add resolve_product_query tool + fix Smithery URL in MC…
May 7, 2026
1acd667
fix(gamestop): use persistent data directory instead of /tmp
May 7, 2026
e4c435a
Add BuyWhere vs Constructor.io comparison content
May 7, 2026
ece18f4
fix(gamestop): checkpoint before ingestion to avoid losing progress o…
May 7, 2026
447f098
fix(gamestop): checkpoint every 20 products to survive heartbeat time…
May 7, 2026
754ab00
Add BuyWhere vs Octane AI comparison content
May 7, 2026
6ccb048
Add BuyWhere vs Microsoft Copilot Shopping comparison content
May 7, 2026
6a9cf07
Add BuyWhere vs Perplexity AI comparison content
May 7, 2026
5c17069
feat(seo): add OpenAPI link tag and WebApplication schema to homepage
May 7, 2026
0b1b7ec
Add directory and compare catch-all routes for content files
May 7, 2026
437ce03
Add BuyWhere vs ChatGPT Shopping comparison content
May 7, 2026
5b914ab
Add BuyWhere vs Algolia comparison content
May 7, 2026
bd0dc3e
Add BuyWhere vs Doofinder comparison content
May 7, 2026
cb99998
Add BuyWhere vs Klevu comparison content
May 7, 2026
feeed9a
Add BuyWhere vs Google Gemini comparison content
May 7, 2026
01ff6d0
BUY-13503: add BuyWhere vs Google Shopping API comparison page
May 7, 2026
8402adc
Add BuyWhere vs Amazon PA API comparison content
May 7, 2026
981f901
fix(BUY-13085): update robots.txt — block internal paths and API from…
May 7, 2026
65e394a
Merge pull request #5 from richmondteo-code/hotfix-restore
BuyWhere May 7, 2026
31d9f34
fix(BUY-7843): increase PG_POOL_MAX from 10 to 50 for staging k6 vali…
May 9, 2026
6a8faf6
feat: make email optional on /register, add /register/agent endpoint
May 9, 2026
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
81 changes: 0 additions & 81 deletions .github/workflows/nginx-deploy.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
name: Deploy nginx config

<<<<<<< HEAD
# Triggers: push to deploy/nginx/ on main, or manual dispatch
=======
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
on:
push:
branches: [main]
Expand All @@ -20,11 +16,7 @@ on:
required: false
default: 'false'
restart_only:
<<<<<<< HEAD
description: 'If true, skip config file upload/write and just restart nginx (useful to pick up new upstream)'
=======
description: 'If true, skip config file upload/write and just restart nginx'
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
required: false
default: 'false'

Expand All @@ -34,22 +26,15 @@ jobs:
environment: production
steps:
- uses: actions/checkout@v4
<<<<<<< HEAD
=======
with:
fetch-depth: 2
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)

- name: Set deploy variables
id: vars
run: |
<<<<<<< HEAD
CONFIG_NAME="${{ github.event.inputs.config_name || 'api.buywhere.ai' }}"
=======
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
CONFIG_NAME="${{ github.event.inputs.config_name || 'api.buywhere.ai' }}"
else
# On push: detect which config file changed in deploy/nginx/
CHANGED=$(git diff --name-only HEAD~1 HEAD -- deploy/nginx/*.conf 2>/dev/null | head -1)
if [[ -n "$CHANGED" ]]; then
CONFIG_NAME=$(basename "$CHANGED" .conf)
Expand All @@ -59,7 +44,6 @@ jobs:
echo "No config change detected, defaulting to: ${CONFIG_NAME}"
fi
fi
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
DRY_RUN="${{ github.event.inputs.dry_run || 'false' }}"
RESTART_ONLY="${{ github.event.inputs.restart_only || 'false' }}"
echo "config_name=${CONFIG_NAME}" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -92,13 +76,9 @@ jobs:
SSH_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
run: |
REMOTE_TMP="/tmp/nginx-${CONFIG_NAME}-${DEPLOY_SHA}.conf"
<<<<<<< HEAD
scp -i ~/.ssh/id_ed25519 \
"deploy/nginx/${CONFIG_NAME}.conf" \
"${SSH_USER}@${SSH_HOST}:${REMOTE_TMP}"
=======
scp -i ~/.ssh/id_ed25519 "deploy/nginx/${CONFIG_NAME}.conf" "${SSH_USER}@${SSH_HOST}:${REMOTE_TMP}"
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
echo "Uploaded to ${SSH_HOST}:${REMOTE_TMP}"

- name: Deploy and reload nginx
Expand All @@ -110,51 +90,27 @@ jobs:
SSH_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }}
SSH_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
run: |
<<<<<<< HEAD
# Pass variables as explicit env vars on the remote command line.
# Using bash -s with heredoc avoids the positional-argument/unbound-variable
# issue that occurs when bash -c '...' _ "$VAR" is used over SSH.
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${SSH_HOST}" \
env CONFIG_NAME="${CONFIG_NAME}" DEPLOY_SHA="${DEPLOY_SHA}" DRY_RUN="${DRY_RUN}" RESTART_ONLY="${RESTART_ONLY}" \
bash -s <<'REMOTE'
set -euo pipefail

# Sites-enabled on this server uses no .conf suffix (nginx convention: sites-enabled/hostname)
=======
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${SSH_HOST}" env CONFIG_NAME="${CONFIG_NAME}" DEPLOY_SHA="${DEPLOY_SHA}" DRY_RUN="${DRY_RUN}" RESTART_ONLY="${RESTART_ONLY}" bash -s <<'REMOTE'
set -euo pipefail

>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
DEST="/etc/nginx/sites-enabled/${CONFIG_NAME}"
SRC="/tmp/nginx-${CONFIG_NAME}-${DEPLOY_SHA}.conf"

echo "nginx-deploy: config=${CONFIG_NAME} sha=${DEPLOY_SHA} dry_run=${DRY_RUN} restart_only=${RESTART_ONLY}"

<<<<<<< HEAD
# Validate nginx config — try sudo first (non-interactive), fall back to plain.
# Treat PID-file permission errors as non-fatal (they don't affect config syntax).
validate_nginx() {
local out
out=$(sudo -n nginx -t -c /etc/nginx/nginx.conf 2>&1) \
|| out=$(nginx -t -c /etc/nginx/nginx.conf 2>&1) \
|| true
=======
validate_nginx() {
local out
out=$(sudo -n nginx -t -c /etc/nginx/nginx.conf 2>&1) || out=$(nginx -t -c /etc/nginx/nginx.conf 2>&1) || true
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
echo "$out"
if echo "$out" | grep -q "syntax is ok"; then
return 0
fi
<<<<<<< HEAD
# If the only failures are pid-file permission errors, treat as OK
local real_errors
real_errors=$(echo "$out" | grep -v "nginx.pid" | grep -E "\[emerg\]|\[crit\]|test failed" || true)
=======
local real_errors
real_errors=$(echo "$out" | grep -v "nginx.pid" | grep -E "\\[emerg\\]|\\[crit\\]|test failed" || true)
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
if [[ -n "$real_errors" ]]; then
echo "FATAL: nginx config has errors (not just pid-file):"
echo "$real_errors"
Expand All @@ -175,26 +131,16 @@ jobs:
echo "Validating existing nginx config before deploy..."
validate_nginx

<<<<<<< HEAD
# Write the config — try plain cp first, then sudo cp, then sudo tee.
# All three must fail for the deploy to abort. If the DEST file exists but
# cannot be written, we exit rather than silently reloading stale config.
=======
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
if cp "${SRC}" "${DEST}" 2>/dev/null; then
echo "Config written to ${DEST} (plain cp)"
elif sudo -n cp "${SRC}" "${DEST}" 2>/dev/null; then
echo "Config written to ${DEST} (sudo cp)"
elif sudo -n tee "${DEST}" < "${SRC}" > /dev/null 2>/dev/null; then
echo "Config written to ${DEST} (sudo tee)"
else
<<<<<<< HEAD
echo "ERROR: cannot write ${DEST} — cp, sudo cp, and sudo tee all failed."
echo " Grant file ownership: sudo chown \$(whoami) ${DEST}"
echo " Or add sudoers rule: \$(whoami) ALL=(root) NOPASSWD: /bin/cp /tmp/nginx-*.conf /etc/nginx/sites-enabled/*"
=======
echo "ERROR: cannot write ${DEST} — all write methods failed."
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
exit 1
fi

Expand All @@ -207,51 +153,24 @@ jobs:
exit 0
fi

<<<<<<< HEAD
echo "Restarting nginx via systemctl..."
systemctl restart nginx 2>/dev/null \
|| sudo -n systemctl restart nginx 2>/dev/null \
|| sudo systemctl restart nginx
echo "nginx restarted — config drift resolved (sha ${DEPLOY_SHA})"

# Cleanup tmp configs on server
=======
echo "Restarting nginx..."
if systemctl restart nginx 2>/dev/null || sudo -n systemctl restart nginx 2>/dev/null; then
echo "nginx restarted via systemctl (sha ${DEPLOY_SHA})"
else
echo "systemctl restart failed — trying kill old + start new..."
# kill existing nginx
sudo -n fuser -k 80/tcp 443/tcp 2>/dev/null || true
sleep 1
# try nginx reload
if sudo -n nginx -s reload 2>/dev/null; then
echo "nginx reloaded via -s reload after kill"
elif sudo nginx 2>/dev/null; then
echo "nginx started fresh via sudo nginx"
else
echo "WARN: could not start nginx — config written, manual reload needed"
echo "Try: sudo nginx -s reload"
fi
fi

>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
rm -f /tmp/main-nginx.conf /tmp/buywhere-ai-modified.conf 2>/dev/null || true
REMOTE

- name: Smoke test /mcp endpoint
if: steps.vars.outputs.dry_run != 'true'
run: |
sleep 2
<<<<<<< HEAD
HTTP=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST https://api.buywhere.ai/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}},"id":1}')
=======
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST https://api.buywhere.ai/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}},"id":1}')
>>>>>>> a8194ee77 (fix(BUY-12731): use Cloud Run hostname + X-Forwarded-Host to fix 404 routing)
echo "POST https://api.buywhere.ai/mcp (initialize) → HTTP ${HTTP}"
if [[ "$HTTP" != "200" ]]; then
echo "ERROR: /mcp returned ${HTTP} after deploy — check nginx on production"
Expand Down
142 changes: 106 additions & 36 deletions api/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { createHash, randomBytes } from 'crypto';
import { db, FREE_TIER, redis } from '../config';
import { db, FREE_TIER, DEVELOPER_TIER, redis } from '../config';
import { trackRegistration, trackEmailVerified } from '../analytics/posthog';
import { sendVerificationEmail } from '../email';
import { sendError } from '../middleware/errors';
Expand All @@ -11,6 +11,8 @@ const router = Router();

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const UNVERIFIED_TIER = { rpm: 5, daily: 50 };

function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
Expand All @@ -20,7 +22,8 @@ function generateVerificationToken(): string {
}

// POST /v1/auth/register
// Headless agent self-registration — requires email for verification
// Self-registration — email optional. With email: unverified tier + verification flow.
// Without email: free tier, instant activation.
router.post('/register', async (req: Request, res: Response) => {
const { agent_name, email, contact, use_case } = req.body;

Expand All @@ -30,8 +33,11 @@ router.post('/register', async (req: Request, res: Response) => {
}

const emailAddr = (email || contact || '') as string;
if (!emailAddr || !EMAIL_RE.test(emailAddr)) {
sendError(res, ErrorCode.INVALID_PARAMETER, 'A valid email address is required.');
const hasEmail = emailAddr && EMAIL_RE.test(emailAddr);

// If email was provided but invalid, reject
if (emailAddr && !hasEmail) {
sendError(res, ErrorCode.INVALID_PARAMETER, 'Email address is invalid. Omit it entirely for instant key, or provide a valid one.');
return;
}

Expand All @@ -44,50 +50,114 @@ router.post('/register', async (req: Request, res: Response) => {
const utmMedium = (req.query.utm_medium || req.body.utm_medium) as string | undefined;
const signupChannel = resolveSignupChannel(req.headers['referer'], utmSource, utmMedium);

const verificationToken = generateVerificationToken();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
if (hasEmail) {
// Email flow: unverified tier, pending email verification
const verificationToken = generateVerificationToken();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();

await db.query(
`INSERT INTO api_keys
(id, key_hash, name, email, contact, use_case, tier, is_active,
signup_channel, attribution_source, developer_id,
email_verification_token, email_verification_expires_at)
VALUES (gen_random_uuid(),$1,$2,$3,$4,$5,'unverified',true,$6,$7,'self-registered',$8,$9)`,
[
keyHash,
agent_name.trim().slice(0, 200),
emailAddr.slice(0, 500),
emailAddr.slice(0, 500),
use_case ? String(use_case).slice(0, 1000) : null,
signupChannel,
utmSource || null,
verificationToken,
expiresAt,
]
);

trackRegistration(hashKey(rawKey), agent_name, signupChannel, utmSource || null);

sendVerificationEmail(emailAddr, verificationToken)
.then((sent) => {
if (sent) {
db.query(
`UPDATE api_keys SET email_verification_sent_at = NOW() WHERE key_hash = $1`,
[keyHash]
).catch(() => {});
}
})
.catch(() => {});

res.status(201).json({
api_key: rawKey,
tier: 'unverified',
email_verified: false,
rate_limit: {
rpm: UNVERIFIED_TIER.rpm,
daily: UNVERIFIED_TIER.daily,
},
message: 'Verify your email to unlock higher rate limits.',
docs: 'https://api.buywhere.ai/docs',
});
} else {
// No email: instant free-tier activation
await db.query(
`INSERT INTO api_keys
(id, key_hash, name, use_case, tier, is_active,
signup_channel, attribution_source, developer_id)
VALUES (gen_random_uuid(),$1,$2,$3,'free',true,$4,$5,'self-registered')`,
[
keyHash,
agent_name.trim().slice(0, 200),
use_case ? String(use_case).slice(0, 1000) : null,
signupChannel,
utmSource || null,
]
);

trackRegistration(hashKey(rawKey), agent_name, signupChannel, utmSource || null);

res.status(201).json({
api_key: rawKey,
tier: 'free',
email_verified: false,
rate_limit: {
rpm: FREE_TIER.rpm,
daily: FREE_TIER.daily,
},
docs: 'https://api.buywhere.ai/docs',
});
}
});

// POST /v1/auth/register/agent
// Agent self-registration — returns key instantly without email verification
router.post('/register/agent', async (req: Request, res: Response) => {
const { agent_name, use_case } = req.body;

const rawKey = `bw_${uuidv4().replace(/-/g, '')}`;
const keyHash = hashKey(rawKey);

await db.query(
`INSERT INTO api_keys
(id, key_hash, name, email, contact, use_case, tier, is_active,
signup_channel, attribution_source, developer_id,
email_verification_token, email_verification_expires_at)
VALUES (gen_random_uuid(),$1,$2,$3,$4,$5,'unverified',true,$6,$7,'self-registered',$8,$9)`,
(id, key_hash, name, use_case, tier, is_active,
developer_id, email_verified, rpm_limit, daily_limit)
VALUES (gen_random_uuid(),$1,$2,$3,'developer',true,'agent-registered',true,$4,$5)`,
[
keyHash,
agent_name.trim().slice(0, 200),
emailAddr.slice(0, 500),
emailAddr.slice(0, 500), // also set contact for backward compat
agent_name ? String(agent_name).trim().slice(0, 200) : 'Agent',
use_case ? String(use_case).slice(0, 1000) : null,
signupChannel,
utmSource || null,
verificationToken,
expiresAt,
DEVELOPER_TIER.rpm,
DEVELOPER_TIER.daily,
]
);

// Fire PostHog registration event (async, non-blocking)
trackRegistration(hashKey(rawKey), agent_name, signupChannel, utmSource || null);

// Send verification email (async, non-blocking)
sendVerificationEmail(emailAddr, verificationToken)
.then((sent) => {
if (sent) {
db.query(
`UPDATE api_keys SET email_verification_sent_at = NOW() WHERE key_hash = $1`,
[keyHash]
).catch(() => {});
}
})
.catch(() => {});

res.status(201).json({
api_key: rawKey,
tier: 'unverified',
email_verified: false,
tier: 'developer',
email_verified: true,
rate_limit: {
rpm: FREE_TIER.rpm,
daily: FREE_TIER.daily,
rpm: DEVELOPER_TIER.rpm,
daily: DEVELOPER_TIER.daily,
},
docs: 'https://api.buywhere.ai/docs',
});
Expand Down
Loading