diff --git a/.gitignore b/.gitignore index f9b1a91..fe6f115 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,45 @@ -# IDE specific files: +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs .idea -*.iml -# Specific build directories: -**/target -**/node_modules +# Finder (MacOS) folder config .DS_Store -**pycache** -**build** -**.zip** -**.venv* -*DealAgent** -**TPA.code-workspace** -**package-lock.json** -venv** -scan** \ No newline at end of file + +# nix stuff +flake.lock +flake.nix +# data +data/* +# AI +GEMINI.md +.agent/ +# keys +*.pem diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 5f53f47..0000000 --- a/LICENSE.md +++ /dev/null @@ -1 +0,0 @@ -By using, downloading, or installing this code, you agree to the terms and conditions contained in the [Visa Developer Center Terms of Use](https://developer.visa.com/terms) (including the related [Visa Trusted Agent Protocol Product Terms](https://developer.visa.com/capabilities/trusted-agent-protocol/product-terms)). If you do not agree to such terms, do not download, use or install this code. \ No newline at end of file diff --git a/README.md b/README.md index 7430be2..c18dd52 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,56 @@ -# Trusted Agent Protocol +# Trusted Agent Protocol (TAP) Reference Implementation -*Establishing a universal standard of trust between AI agents and merchants for the next phase of agentic commerce.* +A secure, edge-enforced system for authenticating Autonomous AI Agents using the **Trusted Agent Protocol**. +## Overview -## The Challenge +This project implements a reference architecture where a **CDN Proxy** sits between AI Agents and Merchant Applications, enforcing identity and security at the edge. -AI agents are becoming part of everyday commerce, capable of executing complex tasks like booking travel or managing subscriptions. As agent capabilities evolve, merchants need visibility into their identities and actions more than ever. +It supports multiple standard authentication mechanisms to ensure compatibility, compliance, and scalability: +1. **Mutual TLS / Client Certificates** (RFC 9440) for scalable, connection-level identity verification by the CA. +2. **HTTP Message Signatures** (RFC 9421) for fine-grained, request-level proof-of-possession and compatibility with application-layer signing. -**For an agent to make a purchase, merchants must answer:** +For detailed architecture and protocol diagrams, see [src/design.md](src/design.md). -- Is this a legitimate, trusted, and recognized AI agent? -- Is it acting on behalf of a specific, authenticated user? -- Does the agent carry valid instructions from the user to make this purchase? +## Components -**Without a standard, merchants face an impossible choice:** -- Block potentially valuable agent-driven commerce -- Accept significant operational and security risks from unverified agents +* **Agent**: The client representing the AI user. Manages keys, requests certificates, and signs requests. +* **CDN Proxy**: The edge gatekeeper. Verifies mTLS certificates and HTTP Signatures before forwarding traffic. +* **Registry**: The Identity Provider. Stores Agent DID documents and public keys. +* **Authority**: A private Certificate Authority (CA) that issues short-lived authentication certificates to registered Agents. -## The Solution +## Getting Started -Visa's **Trusted Agent Protocol** provides a standardized, cryptographic method for an AI agent to prove its identity and associated authorization directly to merchants. By presenting a secure digital signature with every interaction, a merchant can verify that an agent is legitimate and has the user's permission to act. +### Prerequisites +* [Bun](https://bun.sh) (v1.0+) -For merchants, the Trusted Agent Protocol describes a standardized set of mechanisms enabling merchants to: +### Installation +```bash +bun install +``` -- **Cryptographically Verify Agent Intent:** Instantly distinguish a legitimate, credentialed agent from an anonymous bot. The agent presents a secure signature that includes timestamps, a unique session identifier, key identifier, and algorithm identifier, allowing you to verify that the signature is current and prevent relays or replays. +### Running Tests +Run the full test suite: +```bash +bun test +``` -- **Confirm Transaction-Specific Authorization:** Ensure the agent is authorized for the specific action it is taking (browsing or payment) as the signature is bound to your domain and the specific operation being performed. +> [!WARNING] +> **Known Issue with Bun Test Runner** +> You may encounter failing tests when running the full suite (`bun test`) due to how Bun handles client certificates or parallel execution context. +> +> If this happens, run the proxy tests in isolation to verify correctness: +> ```bash +> bun test src/proxy/_test.ts +> ``` -- **Receive Trusted User & Payment Identifiers:** Securely receive key information needed for checkout via query parameters. This can include, as consented by the consumer, verifiable consumer identifiers, Payment Account References (PARs) for cards on file, or other identifiers like loyalty numbers, emails and phone numbers, allowing you to streamline or pre-fill the customer experience. +## Project Status -- **Reduce Fraud:** By trusting the agent's identity and intentions, merchants can create a more seamless path to purchase for customers using agents. This cryptographic proof of identity and intent provides a powerful new tool to reduce fraud and minimize chargebacks from unauthorized transactions. +**Alpha / Experimental** -## Key Benefits +This is a proof-of-concept reference implementation. It works end-to-end but is not yet hardened for production use. -- **Differentiate from Malicious Actors:** The Trusted Agent Protocol provides a definitive way for you to distinguish legitimate, authorized AI agents from other automated traffic. This allows you to confidently welcome agent-driven commerce while protecting your site from harmful bots. - -- **Context-Bound Security:** Every request from a trusted agent is cryptographically locked to a merchant's specific website and the exact page with which the agent is interacting . This ensures that an agent's authorization cannot be misused elsewhere. - -- **Protection Against Replay Attacks:** The protocol is designed to prevent bad actors from capturing and reusing old requests. Each signature includes unique, time-sensitive elements that ensure every request is fresh and valid only for a single use. - -- **Securely Receive Customer & Payment Identifiers:** The protocol defines a standardized way for a verified agent to pass essential customer information directly to merchants. This allows merchants to streamline the checkout process by receiving trusted data to pre-fill forms or identify the customer. - -## Example Agent Verification for Payments -![](./assets/trusted-agent-protocol-flow.png) - -## Quick Start - -This repository contains a complete sample implementation demonstrating the Trusted Agent Protocol across multiple components: - -### ๐Ÿš€ **Running the Sample** - -1. **Install Dependencies** (from root directory): - ```bash - pip install -r requirements.txt - ``` - -2. **Start All Services**: - ```bash - # Terminal 1: Agent Registry (port 8001) - cd agent-registry && python main.py - - # Terminal 2: Merchant Backend (port 8000) - cd merchant-backend && python -m uvicorn app.main:app --reload - - # Terminal 3: CDN Proxy (port 3002) - cd cdn-proxy && npm install && npm start - - # Terminal 4: Merchant Frontend (port 3001) - cd merchant-frontend && npm install && npm run dev - - # Terminal 5: TAP Agent (port 8501) - cd tap-agent && streamlit run agent_app.py - ``` - -3. **Try the Demo**: - - Open the TAP Agent at http://localhost:8501 - - Configure merchant URL: http://localhost:3001 - - Generate signatures and interact with the sample merchant - -### ๐Ÿ“š **Component Documentation** - -Each component has detailed setup instructions: - -- **[TAP Agent](./tap-agent/README.md)** - Streamlit app demonstrating agent signature generation -- **[Merchant Frontend](./merchant-frontend/README.md)** - React e-commerce sample with TAP integration -- **[Merchant Backend](./merchant-backend/README.md)** - FastAPI backend with signature verification -- **[CDN Proxy](./cdn-proxy/README.md)** - Node.js proxy implementing RFC 9421 signature verification -- **[Agent Registry](./agent-registry/README.md)** - Public key registry service for agent verification - -### ๐Ÿ—๏ธ **Architecture Overview** - -The sample demonstrates a complete TAP ecosystem: -1. **TAP Agent** generates RFC 9421 compliant signatures -2. **Merchant Frontend** provides the e-commerce interface -3. **CDN Proxy** intercepts and verifies agent signatures -4. **Merchant Backend** processes verified requests -5. **Agent Registry** manages agent public keys and metadata \ No newline at end of file +### Caveats +* **Self-Signed CA**: The `Authority` service generates a fresh self-signed Root CA on every restart (unless persistence is added). +* **In-Memory Storage**: The `Registry` defaults to in-memory storage. Restarting the service wipes registered agents. +* **Protocol Support**: Implements a subset of RFC 9421 (Signatures) and RFC 9440 (Client-Cert Headers) sufficient for demonstration. diff --git a/TPA.code-workspace b/TPA.code-workspace deleted file mode 100644 index cf87941..0000000 --- a/TPA.code-workspace +++ /dev/null @@ -1,19 +0,0 @@ -{ - "folders": [ - { - "path": "tap-agent" - }, - { - "path": "merchant-frontend" - }, - { - "path": "merchant-backend" - }, - { - "path": "cdn-proxy" - }, - { - "path": "agent-registry" - } - ] -} \ No newline at end of file diff --git a/agent-registry/.env.example b/agent-registry/.env.example deleted file mode 100644 index 6058df5..0000000 --- a/agent-registry/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Registry Environment Variables -# Copy this file to .env and update values for your environment - -# Database Configuration -DATABASE_URL=sqlite:///./agent_registry.db - -# Server Configuration -HOST=0.0.0.0 -PORT=8001 - -# Debug Configuration -DEBUG=true diff --git a/agent-registry/.gitignore b/agent-registry/.gitignore deleted file mode 100644 index 9d47a32..0000000 --- a/agent-registry/.gitignore +++ /dev/null @@ -1,19 +0,0 @@ -# Environment files -.env -.env.local - -# Virtual environments -venv/ -env/ -.venv/ - -# Python -__pycache__/ -*.pyc -*.pyo -*.db -*.sqlite - -# Build outputs -build/ -dist/ diff --git a/agent-registry/README.md b/agent-registry/README.md deleted file mode 100644 index dbcd578..0000000 --- a/agent-registry/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Agent Registry Service - -Sample service for managing TAP (Trusted Agent Protocol) agents and their public keys, demonstrating RFC 9421 signature verification. - -## Installation - -```bash -# Install dependencies (from root directory) -pip install -r requirements.txt - -# Initialize sample database -cd agent-registry -python populate_sample_data.py - -# Start service -python main.py -``` - -Access the service at http://localhost:8080 (docs at /docs) - -## Key Features - -- **Multi-Algorithm Support** - Ed25519 and RSA-PSS-SHA256 public keys -- **Agent Management** - CRUD operations for TAP agents -- **Key Validation** - Automatic key format validation -- **Registry UI** - Simple web interface for agent management - -## Sample API Endpoints - -### Agent Management -- `GET /agents` - List all registered agents -- `POST /agents/register` - Register new agent with public key -- `GET /agents/{agent_id}` - Get agent details by agent ID -- `PUT /agents/{agent_id}` - Update agent information -- `DELETE /agents/{agent_id}` - Deactivate agent - -### Key Management -- `POST /agents/{agent_id}/keys` - Add new key to existing agent -- `GET /agents/{agent_id}/keys/{key_id}` - Get specific key for agent -- `GET /keys/{key_id}` - **Get key by key ID only (used by CDN proxy)** - -### Domain Lookup -- `GET /agents/domain/{domain}` - Find agent by domain - -## Key Management - -The Agent Registry supports multiple keys per agent, enabling key rotation and multi-algorithm support. - -### Adding Keys to Agents - -#### 1. Register Agent with Initial Key -```bash -curl -X POST http://localhost:8080/agents/register \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Sample Payment Directory", - "domain": "https://directory.example.com", - "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN...", - "algorithm": "rsa-pss-sha256", - "key_id": "primary-rsa", - "description": "Primary RSA key" - }' -``` - -#### 2. Add Additional Keys -```bash -curl -X POST http://localhost:8080/agents/1/keys \ - -H "Content-Type: application/json" \ - -d '{ - "key_id": "backup-ed25519", - "algorithm": "ed25519", - "public_key": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo=", - "description": "Backup Ed25519 key", - "is_active": "true" - }' -``` - -#### 3. CDN Proxy Key Lookup -The CDN proxy uses the `/keys/{key_id}` endpoint to retrieve keys for signature verification: -```bash -curl http://localhost:8080/keys/primary-rsa -``` - -Returns: -```json -{ - "key_id": "primary-rsa", - "is_active": "true", - "public_key": "-----BEGIN PUBLIC KEY-----\n...", - "algorithm": "rsa-pss-sha256", - "description": "Primary RSA key", - "agent_id": 1, - "agent_name": "Sample Payment Directory", - "agent_domain": "https://directory.example.com" -} -``` - -### Supported Key Formats - -#### Ed25519 Keys -- **Format**: Base64 encoded raw public key (44 characters) -- **Example**: `"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo="` -- **Algorithm**: `"ed25519"` - -#### RSA-PSS-SHA256 Keys -- **Format**: PEM encoded RSA public key -- **Example**: `"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0B..."` -- **Algorithm**: `"rsa-pss-sha256"` - -## Architecture - -This sample demonstrates: -- FastAPI service patterns -- SQLAlchemy models with SQLite -- Multi-key agent management -- Public key validation and management -- RFC 9421 compliance for signature verification -- CDN proxy integration patterns - -## Registry UI - -Access the web interface at http://localhost:8080/ui for: -- Viewing registered agents -- Adding new agents through forms -- Testing agent registration flow - - - -### Debug Mode -```bash -# Enable comprehensive logging -DEBUG=true LOG_LEVEL=DEBUG python main.py -``` diff --git a/agent-registry/agent_registry.db b/agent-registry/agent_registry.db deleted file mode 100644 index fb28d9c..0000000 Binary files a/agent-registry/agent_registry.db and /dev/null differ diff --git a/agent-registry/database.py b/agent-registry/database.py deleted file mode 100644 index 23f012f..0000000 --- a/agent-registry/database.py +++ /dev/null @@ -1,39 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import os -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, Session -from typing import Generator - -# SQLite database configuration -SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./agent_registry.db") - -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -Base = declarative_base() - -def get_db() -> Generator[Session, None, None]: - """ - Database dependency that yields a database session - """ - db = SessionLocal() - try: - yield db - finally: - db.close() - -def init_db(): - """ - Initialize database tables - """ - from models import Agent # Import here to avoid circular imports - Base.metadata.create_all(bind=engine) - print("๐Ÿ“Š Database initialized successfully") diff --git a/agent-registry/main.py b/agent-registry/main.py deleted file mode 100644 index b3095c3..0000000 --- a/agent-registry/main.py +++ /dev/null @@ -1,372 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import os -from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Depends - -# Load environment variables -load_dotenv() -from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy.orm import Session -from typing import Optional -import uvicorn - -from database import get_db, init_db -from models import Agent, AgentKey -from schemas import (AgentCreate, AgentUpdate, AgentResponse, AgentPublicInfo, - AgentKeyCreate, AgentKeyUpdate, AgentKeyResponse, Message) - -app = FastAPI( - title="Agent Registry Service", - description="Registration and lookup service for payment directory agents", - version="1.0.0" -) - -# Enable CORS for all origins (configure appropriately for production) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -@app.on_event("startup") -async def startup_event(): - """Initialize database on startup""" - init_db() - print("๐Ÿ Agent Registry Service started successfully") - -@app.get("/", response_model=Message) -async def root(): - """Health check endpoint""" - return {"message": "Agent Registry Service is running"} - -@app.post("/agents/register", response_model=AgentResponse) -async def register_agent(agent: AgentCreate, db: Session = Depends(get_db)): - """ - Register a new agent or update existing agent for the domain - """ - try: - # Check if agent with domain already exists - existing_agent = db.query(Agent).filter(Agent.domain == agent.domain).first() - - if existing_agent: - # Update existing agent - for field, value in agent.dict(exclude={'keys'}).items(): - if hasattr(existing_agent, field): - setattr(existing_agent, field, value) - - # Handle keys separately - if agent.keys: - for key_data in agent.keys: - # Check if key_id already exists for this agent - existing_key = db.query(AgentKey).filter( - AgentKey.agent_id == existing_agent.id, - AgentKey.key_id == key_data.key_id - ).first() - - if existing_key: - # Update existing key - for field, value in key_data.dict().items(): - setattr(existing_key, field, value) - else: - # Create new key - new_key = AgentKey(agent_id=existing_agent.id, **key_data.dict()) - db.add(new_key) - - db.commit() - db.refresh(existing_agent) - - print(f"โœ… Updated agent registration for domain: {agent.domain}") - return { - "success": True, - "message": f"Agent registration updated for domain: {agent.domain}", - "agent": existing_agent - } - else: - # Create new agent - agent_dict = agent.dict(exclude={'keys'}) - new_agent = Agent(**agent_dict) - db.add(new_agent) - db.flush() # Get the agent ID - - # Add keys - if agent.keys: - for key_data in agent.keys: - new_key = AgentKey(agent_id=new_agent.id, **key_data.dict()) - db.add(new_key) - - db.commit() - db.refresh(new_agent) - - print(f"โœ… New agent registered for domain: {agent.domain}, ID: {new_agent.id}") - return { - "success": True, - "message": f"Agent successfully registered for domain: {agent.domain}, ID: {new_agent.id}", - "agent": new_agent - } - - except Exception as e: - db.rollback() - print(f"โŒ Error registering agent: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to register agent: {str(e)}") - -@app.get("/agents/{agent_id}", response_model=AgentPublicInfo) -async def get_agent_by_id(agent_id: int, db: Session = Depends(get_db)): - """ - Get agent information by agent ID - """ - try: - agent = db.query(Agent).filter(Agent.id == agent_id).first() - - if not agent: - print(f"โŒ Agent not found for ID: {agent_id}") - raise HTTPException(status_code=404, detail=f"Agent not found for ID: {agent_id}") - - if agent.is_active != "true": - print(f"โŒ Agent inactive for ID: {agent_id}") - raise HTTPException(status_code=404, detail=f"Agent is inactive for ID: {agent_id}") - - print(f"โœ… Retrieved agent info for ID: {agent_id}") - return AgentPublicInfo.model_validate(agent) - - except HTTPException: - raise - except Exception as e: - print(f"โŒ Error retrieving agent: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to retrieve agent: {str(e)}") - -@app.get("/agents/{agent_id}/keys/{key_id}") -async def get_agent_key(agent_id: int, key_id: str, db: Session = Depends(get_db)): - """ - Get specific key for an agent by agent ID and key ID - """ - try: - agent = db.query(Agent).filter(Agent.id == agent_id).first() - - if not agent: - raise HTTPException(status_code=404, detail=f"Agent not found for ID: {agent_id}") - - if agent.is_active != "true": - raise HTTPException(status_code=404, detail=f"Agent is inactive for ID: {agent_id}") - - key = db.query(AgentKey).filter( - AgentKey.agent_id == agent_id, - AgentKey.key_id == key_id - ).first() - - if not key: - raise HTTPException(status_code=404, detail=f"Key '{key_id}' not found for agent {agent_id}") - - if key.is_active != "true": - raise HTTPException(status_code=404, detail=f"Key '{key_id}' is inactive for agent {agent_id}") - - print(f"โœ… Retrieved key '{key_id}' for agent ID: {agent_id}") - return { - "agent_id": agent_id, - "agent_name": agent.name, - "agent_domain": agent.domain, - "key_id": key_id, - "is_active": key.is_active, - "public_key": key.public_key, - "algorithm": key.algorithm, - "description": key.description - } - - except HTTPException: - raise - except Exception as e: - print(f"โŒ Error retrieving agent key: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to retrieve agent key: {str(e)}") - -@app.post("/agents/{agent_id}/keys", response_model=AgentKeyResponse) -async def add_agent_key(agent_id: int, key: AgentKeyCreate, db: Session = Depends(get_db)): - """ - Add a new key to an existing agent - """ - try: - agent = db.query(Agent).filter(Agent.id == agent_id).first() - - if not agent: - raise HTTPException(status_code=404, detail=f"Agent not found for ID: {agent_id}") - - # Check if key_id already exists for this agent - existing_key = db.query(AgentKey).filter( - AgentKey.agent_id == agent_id, - AgentKey.key_id == key.key_id - ).first() - - if existing_key: - raise HTTPException(status_code=400, detail=f"Key '{key.key_id}' already exists for agent {agent_id}") - - # Create new key - new_key = AgentKey(agent_id=agent_id, **key.dict()) - db.add(new_key) - db.commit() - db.refresh(new_key) - - print(f"โœ… Added key '{key.key_id}' to agent ID: {agent_id}") - return { - "success": True, - "message": f"Key '{key.key_id}' added to agent {agent_id}", - "key": new_key - } - - except HTTPException: - raise - except Exception as e: - db.rollback() - print(f"โŒ Error adding agent key: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to add agent key: {str(e)}") - -@app.get("/keys/{key_id}") -async def get_key_by_id(key_id: str, db: Session = Depends(get_db)): - """ - Get key information by key ID only (without requiring agent ID) - """ - try: - # Find the key directly by key_id - key = db.query(AgentKey).filter(AgentKey.key_id == key_id).first() - - if not key: - print(f"โŒ Key not found for ID: {key_id}") - raise HTTPException(status_code=404, detail=f"Key not found for ID: {key_id}") - - # Check if key is active - if key.is_active != "true": - print(f"โŒ Key inactive for ID: {key_id}") - raise HTTPException(status_code=404, detail=f"Key is inactive for ID: {key_id}") - - # Get associated agent info (optional - for context) - agent = db.query(Agent).filter(Agent.id == key.agent_id).first() - - print(f"โœ… Retrieved key '{key_id}' (agent: {agent.name if agent else 'unknown'})") - return { - "key_id": key_id, - "is_active": key.is_active, - "public_key": key.public_key, - "algorithm": key.algorithm, - "description": key.description, - "agent_id": key.agent_id, - "agent_name": agent.name if agent else None, - "agent_domain": agent.domain if agent else None - } - - except HTTPException: - raise - except Exception as e: - print(f"โŒ Error retrieving key: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to retrieve key: {str(e)}") - -@app.get("/agents", response_model=list[AgentPublicInfo]) -async def list_agents(active_only: bool = True, db: Session = Depends(get_db)): - """ - List all registered agents (optionally active only) - """ - try: - query = db.query(Agent) - if active_only: - query = query.filter(Agent.is_active == "true") - - agents = query.all() - print(f"โœ… Retrieved {len(agents)} agents") - return [AgentPublicInfo.model_validate(agent) for agent in agents] - - except Exception as e: - print(f"โŒ Error listing agents: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") - -@app.put("/agents/{agent_id}", response_model=AgentResponse) -async def update_agent(agent_id: int, agent_update: AgentUpdate, db: Session = Depends(get_db)): - """ - Update specific fields of an existing agent - """ - try: - existing_agent = db.query(Agent).filter(Agent.id == agent_id).first() - - if not existing_agent: - print(f"โŒ Agent not found for ID: {agent_id}") - raise HTTPException(status_code=404, detail=f"Agent not found for ID: {agent_id}") - - # Update only provided fields - update_data = agent_update.dict(exclude_unset=True) - for field, value in update_data.items(): - setattr(existing_agent, field, value) - - db.commit() - db.refresh(existing_agent) - - print(f"โœ… Updated agent for ID: {agent_id}") - return { - "success": True, - "message": f"Agent updated for ID: {agent_id}", - "agent": existing_agent - } - - except HTTPException: - raise - except Exception as e: - db.rollback() - print(f"โŒ Error updating agent: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") - -@app.delete("/agents/{agent_id}", response_model=Message) -async def deactivate_agent(agent_id: int, db: Session = Depends(get_db)): - """ - Deactivate an agent (soft delete) - """ - try: - agent = db.query(Agent).filter(Agent.id == agent_id).first() - - if not agent: - print(f"โŒ Agent not found for ID: {agent_id}") - raise HTTPException(status_code=404, detail=f"Agent not found for ID: {agent_id}") - - agent.is_active = "false" - db.commit() - - print(f"โœ… Deactivated agent for ID: {agent_id}") - return {"message": f"Agent deactivated for ID: {agent_id}"} - - except HTTPException: - raise - except Exception as e: - db.rollback() - print(f"โŒ Error deactivating agent: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to deactivate agent: {str(e)}") - -# Legacy endpoint for domain-based lookup (for backward compatibility) -@app.get("/agents/domain/{domain}", response_model=AgentPublicInfo) -async def get_agent_by_domain(domain: str, db: Session = Depends(get_db)): - """ - Get agent information by domain (legacy endpoint) - """ - try: - agent = db.query(Agent).filter(Agent.domain == domain).first() - - if not agent: - print(f"โŒ Agent not found for domain: {domain}") - raise HTTPException(status_code=404, detail=f"Agent not found for domain: {domain}") - - if agent.is_active != "true": - print(f"โŒ Agent inactive for domain: {domain}") - raise HTTPException(status_code=404, detail=f"Agent is inactive for domain: {domain}") - - print(f"โœ… Retrieved agent info for domain: {domain}") - return AgentPublicInfo.model_validate(agent) - - except HTTPException: - raise - except Exception as e: - print(f"โŒ Error retrieving agent: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to retrieve agent: {str(e)}") - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=9002) diff --git a/agent-registry/models.py b/agent-registry/models.py deleted file mode 100644 index 72335b4..0000000 --- a/agent-registry/models.py +++ /dev/null @@ -1,49 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey -from sqlalchemy.orm import relationship -from datetime import datetime -from database import Base - -class Agent(Base): - __tablename__ = "agents" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(255), nullable=False, index=True) - domain = Column(String(255), unique=True, nullable=False, index=True) # Unique domain constraint - description = Column(Text) # Optional agent description - contact_email = Column(String(255)) # Optional contact email - is_active = Column(String(10), default="true", nullable=False) # Active status - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - # Relationship with keys - keys = relationship("AgentKey", back_populates="agent", cascade="all, delete-orphan") - - def __repr__(self): - return f"" - -class AgentKey(Base): - __tablename__ = "agent_keys" - - id = Column(Integer, primary_key=True, index=True) - agent_id = Column(Integer, ForeignKey("agents.id"), nullable=False, index=True) - key_id = Column(String(100), nullable=False, index=True) # User-defined key identifier - public_key = Column(Text, nullable=False) # PEM format RSA public key - algorithm = Column(String(50), default="RSA-SHA256", nullable=False) # Signature algorithm - description = Column(Text) # Optional key description - is_active = Column(String(10), default="true", nullable=False) # Key active status - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - # Relationship with agent - agent = relationship("Agent", back_populates="keys") - - def __repr__(self): - return f"" diff --git a/agent-registry/populate_sample_data.py b/agent-registry/populate_sample_data.py deleted file mode 100644 index 4934f47..0000000 --- a/agent-registry/populate_sample_data.py +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env python3 -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -""" -Sample script to populate the agent registry with initial data -""" - -import requests -import json - -# Agent Registry Service URL -BASE_URL = "http://localhost:9002" - -# Sample agent data with multiple keys -sample_agents = [ - { - "name": "TAP Agent 1", - "domain": "https://tapagent.com", - "description": "Official Tap Agent 1 service for merchant verification", - "contact_email": "support@tapagent.com", - "is_active": "true", - "keys": [ - { - "key_id": "primary", - "public_key": """-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoG2JyN6sWH0BSze3C8iK -6u6q7+0wo5ybcFX1kKquBDCLIKqY1hqvtVmj9wTGpCXQ2Jt8PtXXnSOhj69ng3mc -ypJjf72GyKrgHX+nYxcQrnrPXNDaDrhLVtxDsoGIwyVTiUGH5bX2qlIerwlfG9Jz -24HabfGSs6wpxXlfSt29giljSbX78g+Rb9TEV3joZjSQIn68iaKU147uVpv2JhCA -88X9l7fKMUSKDbiyhLRpDjutHrns8NYALPSyRLN645+Hcl7so+AWb9CR8+bdgBUq -GHYlyRsMdsQENFDDFS35M4oz/5MeXj+sIAWrq2ceI0LBttCH6cOcX/r1VpSqoUc1 -dQIDAQAB ------END PUBLIC KEY-----""", - "algorithm": "RSA-SHA256", - "description": "Primary signing key for Test Agent 1", - "is_active": "true" - }, - { - "key_id": "backup-2024", - "public_key": """-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt+SfMaqEpUwshjfJnQS2 -/D0DrXTVfhUP37pcp/eAmGPL1oaYwq0Br3tMiCpHHiTBkt56E4M334DCD+v/umVI -rPMPf9bharUYFAWwJB9DWL6C7UAslnRHsTvjZ02ImStRV9scaTmynQrHQE/oNtiZ -wcVjaXgIGkHuuze1AZ54PrQOUb435CtsFZ74oQmo9t7kMXrFQ19CIWJOXZlxDvoq -YsJYxeRXdQ6c+Ckb7m7l01OCbZmhiLacThHUx62GaZ+uAKrw+9SQ352Wachl1uc9 -8BfJYj2P0CuLcSeQt8Xk2aWktvK+cmnrsKIbiZYJ9DXBcU7ZH/jsw46izh58/2fe -TQIDAQAB ------END PUBLIC KEY-----""", - "algorithm": "RSA-SHA256", - "description": "Backup signing key for disaster recovery", - "is_active": "true" - }, - { - "key_id": "primary-ed25519", - "public_key": "09Xvvs6I2LOkF0EFb3ofNai87g0mWipuEMwVdi78m6E=", - "algorithm": "ed25519", - "description": "Primary Ed25519 signing key for modern crypto", - "is_active": "true" - } - ] - }, - { - "name": "TAP Agent 2", - "domain": "https://tapagent2.com", - "description": "Official TAP Agent 2 service for merchant verification", - "contact_email": "support@tapagent2.com", - "is_active": "true", - "keys": [ - { - "key_id": "primary", - "public_key": """-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzQ4ERbP6IED3/GiRs2+h -pvHLKTtXdi+hHkgbVIkTBB2bICzkRX1hRo3/UqWkloVEyqyaMSdnXEzuvcKw/Tec -Q6tx321jKH3X9kcDzrtAHtNVg9CbR6cjBc1gRETW+yyFiGX1X7Jbp+o2/lWXJZvf -M5WuQ1mR2x+LOin2V2UyeSyFjNU3hd1a2ceD7qwPnpDOyCGXQHin9ioh/KHkn6vf -IUAhs5j3rS3lJzqdgn9P+wO31cmAJjKbhHQO8+1Ygw+4Qx0L4HL2y+7mJyzJC4Dv -MC6/FhKmTVggXKkI+Q5/D1hOiBDtAeQ/AmmB3+HFF5Q67atxhMss1qXLWJeZC0iW -swIDAQAB ------END PUBLIC KEY-----""", - "algorithm": "RSA-SHA256", - "description": "Primary signing key for TAP Agent 2", - "is_active": "true" - } - ] - } -] - -def register_agent(agent_data): - """Register a single agent""" - try: - response = requests.post(f"{BASE_URL}/agents/register", json=agent_data) - if response.status_code == 200: - result = response.json() - print(f"โœ… Successfully registered: {agent_data['name']}") - print(f" Domain: {agent_data['domain']}") - print(f" Message: {result['message']}") - else: - print(f"โŒ Failed to register {agent_data['name']}: {response.status_code}") - print(f" Error: {response.text}") - except Exception as e: - print(f"โŒ Error registering {agent_data['name']}: {str(e)}") - print() - -def main(): - """Register all sample agents""" - print("๐Ÿš€ Populating Agent Registry with sample data...") - print(f"๐Ÿ“ก Agent Registry URL: {BASE_URL}") - print() - - # Check if service is running - try: - response = requests.get(f"{BASE_URL}/") - if response.status_code != 200: - print("โŒ Agent Registry Service is not responding correctly") - return - except Exception as e: - print(f"โŒ Cannot connect to Agent Registry Service: {str(e)}") - print(" Make sure the service is running on port 9000") - return - - print("โœ… Agent Registry Service is running") - print() - - # Register each agent - for agent in sample_agents: - register_agent(agent) - - print("๐Ÿ Sample data population completed!") - - # List all registered agents - try: - response = requests.get(f"{BASE_URL}/agents") - if response.status_code == 200: - agents = response.json() - print(f"๐Ÿ“‹ Total registered agents: {len(agents)}") - for agent in agents: - print(f" - {agent['name']} (ID: {agent['id']}, Domain: {agent['domain']})") - print(f" Keys: {len(agent.get('keys', []))}") - for key in agent.get('keys', []): - print(f" * {key['key_id']} ({key['algorithm']}) - {key['description']}") - else: - print("โŒ Failed to retrieve agent list") - except Exception as e: - print(f"โŒ Error retrieving agents: {str(e)}") - - print() - print("๐Ÿ” Testing agent lookup by ID...") - - # Test agent lookup by ID - try: - response = requests.get(f"{BASE_URL}/agents") - if response.status_code == 200: - agents = response.json() - if agents: - test_agent = agents[0] - agent_id = test_agent['id'] - - print(f"Testing lookup for agent ID: {agent_id}") - response = requests.get(f"{BASE_URL}/agents/{agent_id}") - if response.status_code == 200: - agent_info = response.json() - print(f"โœ… Successfully retrieved agent: {agent_info['name']}") - - # Test key lookup - if agent_info.get('keys'): - key_id = agent_info['keys'][0]['key_id'] - print(f"Testing key lookup for key ID: {key_id}") - response = requests.get(f"{BASE_URL}/agents/{agent_id}/keys/{key_id}") - if response.status_code == 200: - key_info = response.json() - print(f"โœ… Successfully retrieved key: {key_info['key_id']}") - else: - print(f"โŒ Failed to retrieve key: {response.status_code}") - else: - print(f"โŒ Failed to retrieve agent: {response.status_code}") - except Exception as e: - print(f"โŒ Error testing lookups: {str(e)}") - -if __name__ == "__main__": - main() diff --git a/agent-registry/registry_ui.py b/agent-registry/registry_ui.py deleted file mode 100644 index 1350c76..0000000 --- a/agent-registry/registry_ui.py +++ /dev/null @@ -1,604 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import streamlit as st -import requests -import pandas as pd -import json -from datetime import datetime -import base64 - -# Configuration -API_BASE_URL = "http://localhost:9002" - -# Page configuration -st.set_page_config( - page_title="Agent Registry Management", - page_icon="๐Ÿ”", - layout="wide", - initial_sidebar_state="expanded" -) - -def check_api_connection(): - """Check if the Agent Registry API is accessible""" - try: - response = requests.get(f"{API_BASE_URL}/", timeout=5) - return response.status_code == 200 - except: - return False - -def get_all_agents(): - """Fetch all agents from the API""" - try: - response = requests.get(f"{API_BASE_URL}/agents", timeout=10) - if response.status_code == 200: - return response.json() - else: - st.error(f"Failed to fetch agents: {response.status_code}") - return [] - except Exception as e: - st.error(f"Error fetching agents: {str(e)}") - return [] - -def get_agent_by_id(agent_id): - """Fetch a specific agent by ID""" - try: - response = requests.get(f"{API_BASE_URL}/agents/{agent_id}", timeout=10) - if response.status_code == 200: - return response.json() - elif response.status_code == 404: - return None - else: - st.error(f"Failed to fetch agent: {response.status_code}") - return None - except Exception as e: - st.error(f"Error fetching agent: {str(e)}") - return None - -def get_agent_key(agent_id, key_id): - """Fetch a specific key for an agent""" - try: - response = requests.get(f"{API_BASE_URL}/agents/{agent_id}/keys/{key_id}", timeout=10) - if response.status_code == 200: - return response.json() - elif response.status_code == 404: - return None - else: - st.error(f"Failed to fetch key: {response.status_code}") - return None - except Exception as e: - st.error(f"Error fetching key: {str(e)}") - return None - -def add_agent_key(agent_id, key_data): - """Add a new key to an existing agent""" - try: - response = requests.post(f"{API_BASE_URL}/agents/{agent_id}/keys", json=key_data, timeout=10) - if response.status_code == 200: - return True, response.json() - else: - return False, response.text - except Exception as e: - return False, str(e) - -def register_agent(agent_data): - """Register a new agent""" - try: - response = requests.post(f"{API_BASE_URL}/agents/register", json=agent_data, timeout=10) - if response.status_code == 200: - return True, response.json() - else: - return False, response.text - except Exception as e: - return False, str(e) - -def update_agent(agent_id, update_data): - """Update an agent by ID""" - try: - response = requests.put(f"{API_BASE_URL}/agents/{agent_id}", json=update_data, timeout=10) - if response.status_code == 200: - return True, response.json() - else: - return False, response.text - except Exception as e: - return False, str(e) - -def deactivate_agent(agent_id): - """Deactivate an agent by ID""" - try: - response = requests.delete(f"{API_BASE_URL}/agents/{agent_id}", timeout=10) - if response.status_code == 200: - return True, response.json() - else: - return False, response.text - except Exception as e: - return False, str(e) - -def create_signature(private_key_pem: str, data: str) -> str: - """Create a signature using the private key""" - try: - from cryptography.hazmat.primitives import serialization, hashes - from cryptography.hazmat.primitives.asymmetric import padding - from cryptography.hazmat.backends import default_backend - - private_key = serialization.load_pem_private_key( - private_key_pem.encode('utf-8'), - password=None, - backend=default_backend() - ) - - signature = private_key.sign( - data.encode('utf-8'), - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH - ), - hashes.SHA256() - ) - - return base64.b64encode(signature).decode('utf-8') - except Exception as e: - st.error(f"Error creating signature: {str(e)}") - return "" - -def main(): - st.title("๐Ÿ” Agent Registry Management") - st.markdown("---") - - # Check API connection - if not check_api_connection(): - st.error("โŒ Cannot connect to Agent Registry API") - st.info(f"Make sure the Agent Registry Service is running on {API_BASE_URL}") - st.stop() - - st.success("โœ… Connected to Agent Registry API") - - # Sidebar navigation - st.sidebar.title("Navigation") - page = st.sidebar.selectbox( - "Choose a page", - ["๐Ÿ“‹ View Agents", "โž• Register Agent", "โœ๏ธ Update Agent", "๐Ÿ” Agent Lookup", "๐Ÿงช Test Signature"] - ) - - if page == "๐Ÿ“‹ View Agents": - view_agents_page() - elif page == "โž• Register Agent": - register_agent_page() - elif page == "โœ๏ธ Update Agent": - update_agent_page() - elif page == "๐Ÿ” Agent Lookup": - agent_lookup_page() - elif page == "๐Ÿงช Test Signature": - test_signature_page() - -def view_agents_page(): - st.header("๐Ÿ“‹ Registered Agents") - - # Refresh button - if st.button("๐Ÿ”„ Refresh"): - st.rerun() - - agents = get_all_agents() - - if not agents: - st.info("No agents registered yet.") - return - - # Display agents in a table - df_data = [] - for agent in agents: - key_count = len(agent.get('keys', [])) - df_data.append({ - "ID": agent["id"], - "Name": agent["name"], - "Domain": agent["domain"], - "Keys": f"{key_count} key(s)", - "Status": "๐ŸŸข Active" if agent.get("is_active", True) else "๐Ÿ”ด Inactive", - "Description": agent.get("description", "")[:50] + "..." if agent.get("description") and len(agent.get("description", "")) > 50 else agent.get("description", ""), - }) - - df = pd.DataFrame(df_data) - st.dataframe(df, use_container_width=True) - - # Detailed view - st.subheader("Agent Details") - agent_options = [f"{agent['id']} - {agent['name']}" for agent in agents] - selected_option = st.selectbox("Select an agent to view details:", agent_options) - - if selected_option: - agent_id = selected_option.split(' - ')[0] - # Convert to int for comparison since API returns int IDs - try: - agent_id_int = int(agent_id) - selected_agent = next((agent for agent in agents if agent["id"] == agent_id_int), None) - except ValueError: - selected_agent = None - - if selected_agent: - col1, col2 = st.columns(2) - - with col1: - st.write("**ID:**", selected_agent["id"]) - st.write("**Name:**", selected_agent["name"]) - st.write("**Domain:**", selected_agent["domain"]) - st.write("**Status:**", "๐ŸŸข Active" if selected_agent.get("is_active", True) else "๐Ÿ”ด Inactive") - st.write("**Description:**", selected_agent.get("description", "N/A")) - st.write("**Contact Email:**", selected_agent.get("contact_email", "N/A")) - st.write("**Created:**", selected_agent.get("created_at", "N/A")) - - with col2: - st.write("**Associated Keys:**") - if selected_agent.get('keys'): - for i, key in enumerate(selected_agent['keys'], 1): - key_name = key.get('key_id', 'Unnamed') # API uses 'key_id' not 'key_name' - with st.expander(f"Key {i}: {key_name}"): - st.write(f"**Key ID:** {key.get('key_id', key.get('id', 'N/A'))}") - st.write(f"**Algorithm:** {key.get('algorithm', 'N/A')}") - st.write(f"**Description:** {key.get('description', 'N/A')}") - st.write(f"**Status:** {'๐ŸŸข Active' if key.get('is_active') == 'true' else '๐Ÿ”ด Inactive'}") - st.write(f"**Created:** {key.get('created_at', 'N/A')}") - if key.get('expires_at'): - st.write(f"**Expires:** {key['expires_at']}") - st.code(key['public_key'], language="text") - else: - st.info("No keys registered for this agent.") - - # Add key button - if st.button("โž• Add New Key", key=f"add_key_{agent_id}"): - st.session_state[f'show_add_key_{agent_id}'] = True - - # Add key form - if st.session_state.get(f'show_add_key_{agent_id}', False): - st.subheader("Add New Key") - with st.form(f"add_key_form_{agent_id}"): - key_name = st.text_input("Key Name", placeholder="e.g., Production Key 2024") - algorithm = st.selectbox("Algorithm", ["RS256", "RS384", "RS512"], index=0) - public_key = st.text_area("Public Key (PEM format)", - placeholder="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - height=150) - expires_at = st.date_input("Expiration Date (optional)") - - col1, col2 = st.columns(2) - with col1: - submit_key = st.form_submit_button("Add Key") - with col2: - cancel_key = st.form_submit_button("Cancel") - - if submit_key and public_key: - key_data = { - "key_name": key_name, - "algorithm": algorithm, - "public_key": public_key - } - if expires_at: - key_data["expires_at"] = expires_at.isoformat() - - success, result = add_agent_key(agent_id, key_data) - if success: - st.success("โœ… Key added successfully!") - st.session_state[f'show_add_key_{agent_id}'] = False - st.rerun() - else: - st.error(f"โŒ Failed to add key: {result}") - - if cancel_key: - st.session_state[f'show_add_key_{agent_id}'] = False - st.rerun() - -def register_agent_page(): - st.header("โž• Register New Agent") - - with st.form("register_agent_form"): - col1, col2 = st.columns(2) - - with col1: - name = st.text_input("Agent Name *", placeholder="e.g., Example Directory Service") - domain = st.text_input("Domain *", placeholder="e.g., https://directory.example.com") - contact_email = st.text_input("Contact Email", placeholder="directory-support@example.com") - - with col2: - description = st.text_area("Description", placeholder="Example payment directory service") - - st.subheader("Initial Key") - st.info("Add at least one key for the agent") - - col3, col4 = st.columns(2) - with col3: - key_name = st.text_input("Key Name *", placeholder="e.g., Production Key 2024") - algorithm = st.selectbox("Algorithm *", ["RS256", "RS384", "RS512"], index=0) - - with col4: - expires_at = st.date_input("Key Expiration Date (optional)") - - public_key = st.text_area( - "Public Key (PEM format) *", - placeholder="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - height=200 - ) - - submitted = st.form_submit_button("๐Ÿš€ Register Agent") - - if submitted: - if not name or not domain or not public_key or not key_name: - st.error("Please fill in all required fields (marked with *)") - return - - # Prepare agent data with initial key - agent_data = { - "name": name, - "domain": domain, - "description": description, - "contact_email": contact_email, - "initial_key": { - "key_name": key_name, - "algorithm": algorithm, - "public_key": public_key - } - } - - if expires_at: - agent_data["initial_key"]["expires_at"] = expires_at.isoformat() - - success, result = register_agent(agent_data) - - if success: - st.success("โœ… Agent registered successfully!") - st.json(result) - st.info(f"Agent ID: {result.get('id', 'N/A')} - Save this ID for future reference!") - else: - st.error(f"โŒ Failed to register agent: {result}") - -def update_agent_page(): - st.header("โœ๏ธ Update Agent") - - # First, select an agent to update - agents = get_all_agents() - if not agents: - st.info("No agents available to update.") - return - - agent_options = [f"{agent['id']} - {agent['name']}" for agent in agents] - selected_option = st.selectbox("Select agent to update:", agent_options) - - if selected_option: - agent_id = selected_option.split(' - ')[0] - # Convert to int for comparison since API returns int IDs - try: - agent_id_int = int(agent_id) - current_agent = next((agent for agent in agents if agent["id"] == agent_id_int), None) - except ValueError: - current_agent = None - - if current_agent: - st.subheader(f"Updating: {current_agent['name']} (ID: {current_agent['id']})") - - with st.form("update_agent_form"): - col1, col2 = st.columns(2) - - with col1: - name = st.text_input("Agent Name", value=current_agent["name"]) - contact_email = st.text_input("Contact Email", value=current_agent.get("contact_email", "")) - - with col2: - description = st.text_area("Description", value=current_agent.get("description", "")) - domain = st.text_input("Domain", value=current_agent["domain"]) - - submitted = st.form_submit_button("๐Ÿ’พ Update Agent") - - if submitted: - update_data = {} - - # Only include fields that have changed - if name != current_agent["name"]: - update_data["name"] = name - if description != current_agent.get("description", ""): - update_data["description"] = description - if contact_email != current_agent.get("contact_email", ""): - update_data["contact_email"] = contact_email - if domain != current_agent["domain"]: - update_data["domain"] = domain - - if not update_data: - st.info("No changes detected.") - return - - success, result = update_agent(agent_id, update_data) - - if success: - st.success("โœ… Agent updated successfully!") - st.json(result) - else: - st.error(f"โŒ Failed to update agent: {result}") - -def agent_lookup_page(): - st.header("๐Ÿ” Agent Lookup") - - col1, col2 = st.columns(2) - - with col1: - st.subheader("Lookup by Agent ID") - agent_id = st.text_input("Enter Agent ID:", placeholder="e.g., 1") - - if st.button("๐Ÿ” Lookup by ID") and agent_id: - agent = get_agent_by_id(agent_id) - - if agent: - st.success("โœ… Agent found!") - display_agent_details(agent) - else: - st.error("โŒ Agent not found for the specified ID.") - - with col2: - st.subheader("Lookup by Domain") - domain = st.text_input("Enter agent domain:", placeholder="https://directory.example.com") - - if st.button("๐Ÿ” Lookup by Domain") and domain: - # Search through all agents for matching domain - agents = get_all_agents() - if agents: - matching_agent = next((a for a in agents if a['domain'] == domain), None) - if matching_agent: - st.success("โœ… Agent found!") - display_agent_details(matching_agent) - else: - st.error("โŒ No agent found with the specified domain.") - else: - st.error("โŒ Failed to fetch agents.") - -def display_agent_details(agent): - """Helper function to display agent details""" - col1, col2 = st.columns(2) - - with col1: - st.write("**ID:**", agent["id"]) - st.write("**Name:**", agent["name"]) - st.write("**Domain:**", agent["domain"]) - st.write("**Status:**", "๐ŸŸข Active" if agent.get("is_active", True) else "๐Ÿ”ด Inactive") - st.write("**Description:**", agent.get("description", "N/A")) - st.write("**Contact Email:**", agent.get("contact_email", "N/A")) - st.write("**Created:**", agent.get("created_at", "N/A")) - - with col2: - st.write("**Associated Keys:**") - if agent.get('keys'): - for i, key in enumerate(agent['keys'], 1): - key_name = key.get('key_id', 'Unnamed') # API uses 'key_id' not 'key_name' - with st.expander(f"Key {i}: {key_name}"): - st.write(f"**Key ID:** {key.get('key_id', key.get('id', 'N/A'))}") - st.write(f"**Algorithm:** {key.get('algorithm', 'N/A')}") - st.write(f"**Description:** {key.get('description', 'N/A')}") - st.write(f"**Status:** {'๐ŸŸข Active' if key.get('is_active') == 'true' else '๐Ÿ”ด Inactive'}") - st.write(f"**Created:** {key.get('created_at', 'N/A')}") - if key.get('expires_at'): - st.write(f"**Expires:** {key['expires_at']}") - st.code(key['public_key'], language="text") - # Copy button for public key - if st.button(f"๐Ÿ“‹ Copy Key {i}", key=f"copy_key_{key.get('key_id', key.get('id', i))}"): - st.code(key['public_key']) - else: - st.info("No keys registered for this agent.") - -def test_signature_page(): - st.header("๐Ÿงช Test Signature Generation") - st.info("This page helps you test signature generation with private keys for registered agents.") - - # Select agent - agents = get_all_agents() - if not agents: - st.info("No agents available for testing.") - return - - agent_options = [f"{agent['id']} - {agent['name']}" for agent in agents] - selected_option = st.selectbox("Select agent for testing:", agent_options) - - if not selected_option: - return - - agent_id = selected_option.split(' - ')[0] - # Convert to int for comparison since API returns int IDs - try: - agent_id_int = int(agent_id) - selected_agent = next((agent for agent in agents if agent["id"] == agent_id_int), None) - except ValueError: - selected_agent = None - - if not selected_agent: - st.error("Selected agent not found.") - return - - st.subheader(f"Testing with: {selected_agent['name']}") - - # Select key from agent's keys - if not selected_agent.get('keys'): - st.warning("This agent has no keys registered. Please add a key first.") - return - - key_options = [f"{key.get('key_id', key.get('id', 'unknown'))} - {key.get('key_id', 'Unnamed')} ({key.get('algorithm', 'Unknown')})" - for key in selected_agent['keys']] - selected_key_option = st.selectbox("Select key to test with:", key_options) - - if not selected_key_option: - return - - key_id = selected_key_option.split(' - ')[0] - selected_key = next((key for key in selected_agent['keys'] if key.get("key_id", key.get("id")) == key_id), None) - - if not selected_key: - st.error("Selected key not found.") - return - - st.write(f"**Selected Key:** {selected_key.get('key_id', 'Unnamed')}") - st.write(f"**Algorithm:** {selected_key.get('algorithm', 'Unknown')}") - st.write(f"**Description:** {selected_key.get('description', 'N/A')}") - - # Show public key - with st.expander("View Public Key"): - st.code(selected_key['public_key'], language="text") - - # Test data - st.subheader("Test Data") - test_data = st.text_area( - "Data to sign (JSON format):", - value='{"merchant_id": "test123", "timestamp": "' + datetime.now().isoformat() + '"}', - height=100 - ) - - # Convert to base64 - if test_data: - try: - test_data_base64 = base64.b64encode(test_data.encode('utf-8')).decode('utf-8') - st.write("**Base64 encoded data:**") - st.code(test_data_base64) - except Exception as e: - st.error(f"Error encoding data: {e}") - return - - # Private key input - private_key = st.text_area( - "Private Key (PEM format) - For testing only:", - placeholder="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", - height=200, - help="This should be the private key corresponding to the selected public key" - ) - - if st.button("๐Ÿ” Generate Signature") and private_key and test_data: - try: - signature = create_signature(private_key, test_data_base64) - if signature: - st.success("โœ… Signature generated successfully!") - - col1, col2 = st.columns(2) - - with col1: - st.write("**Signature (base64):**") - st.code(signature) - - with col2: - st.write("**Headers for CDN test:**") - headers = { - "signature-agent-id": agent_id, - "signature-key-id": key_id, - "signature-input": test_data_base64, - "signature": signature - } - st.code(json.dumps(headers, indent=2)) - - # Test command - st.write("**cURL command for testing:**") - curl_cmd = f'''curl -X GET "http://localhost:3001/" \\ - -H "signature-agent-id: {agent_id}" \\ - -H "signature-key-id: {key_id}" \\ - -H "signature-input: {test_data_base64}" \\ - -H "signature: {signature}"''' - st.code(curl_cmd, language="bash") - - except Exception as e: - st.error(f"Error generating signature: {e}") - -if __name__ == "__main__": - main() diff --git a/agent-registry/requirements.txt b/agent-registry/requirements.txt deleted file mode 100644 index 109a419..0000000 --- a/agent-registry/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi==0.104.1 -uvicorn==0.24.0 -python-dotenv -sqlalchemy==2.0.23 -pydantic==2.5.0 -python-multipart>=0.0.18 -streamlit>=1.37.0 -requests>=2.32.4 -pandas==2.3.3 \ No newline at end of file diff --git a/agent-registry/schemas.py b/agent-registry/schemas.py deleted file mode 100644 index 44ddcc0..0000000 --- a/agent-registry/schemas.py +++ /dev/null @@ -1,195 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from pydantic import BaseModel, EmailStr, Field, validator -from typing import Optional, List -from datetime import datetime - -# Agent Key schemas -class AgentKeyBase(BaseModel): - """Base agent key schema""" - key_id: str = Field(..., min_length=1, max_length=100, description="Key identifier (e.g., 'primary', 'backup-2024')") - public_key: str = Field(..., min_length=20, description="Public key in PEM format (RSA) or base64 format (Ed25519)") - algorithm: str = Field("RSA-SHA256", description="Signature algorithm") - description: Optional[str] = Field(None, max_length=1000, description="Optional key description") - is_active: str = Field("true", description="Key active status") - - @validator('public_key', always=True) - def validate_public_key(cls, v, values): - """Validate public key format based on algorithm""" - # Get algorithm from values dict, fallback to RSA-SHA256 - algorithm = values.get('algorithm', 'RSA-SHA256') - if algorithm: - algorithm = algorithm.lower() - - # Auto-detect Ed25519 based on key format if algorithm detection fails - is_ed25519 = ('ed25519' in algorithm) or (len(v.strip()) < 100 and not v.strip().startswith('-----')) - - if is_ed25519: - # Ed25519 keys are base64 encoded, typically 44 characters - import base64 - try: - decoded = base64.b64decode(v.strip()) - if len(decoded) != 32: # Ed25519 public keys are exactly 32 bytes - raise ValueError('Ed25519 public key must be exactly 32 bytes when base64 decoded') - except Exception: - raise ValueError('Ed25519 public key must be valid base64 encoding') - else: - # RSA keys must be in PEM format - if not v.strip().startswith('-----BEGIN PUBLIC KEY-----'): - raise ValueError('RSA public key must be in PEM format starting with -----BEGIN PUBLIC KEY-----') - if not v.strip().endswith('-----END PUBLIC KEY-----'): - raise ValueError('RSA public key must be in PEM format ending with -----END PUBLIC KEY-----') - - return v.strip() - - @validator('is_active') - def validate_is_active(cls, v): - """Validate active status""" - if v not in ['true', 'false']: - raise ValueError('is_active must be "true" or "false"') - return v - -class AgentKeyCreate(AgentKeyBase): - """Schema for creating a new agent key""" - pass - -class AgentKeyUpdate(BaseModel): - """Schema for updating an existing agent key""" - key_id: Optional[str] = Field(None, min_length=1, max_length=100) - public_key: Optional[str] = Field(None, min_length=20) - algorithm: Optional[str] = Field(None) - description: Optional[str] = Field(None, max_length=1000) - is_active: Optional[str] = Field(None) - - @validator('public_key', always=True) - def validate_public_key(cls, v, values): - """Validate public key format if provided""" - if v is not None: - # Get algorithm from values dict, fallback to RSA-SHA256 - algorithm = values.get('algorithm', 'RSA-SHA256') - if algorithm: - algorithm = algorithm.lower() - - # Auto-detect Ed25519 based on key format if algorithm detection fails - is_ed25519 = ('ed25519' in algorithm) or (len(v.strip()) < 100 and not v.strip().startswith('-----')) - - if is_ed25519: - # Ed25519 keys are base64 encoded, typically 44 characters - import base64 - try: - decoded = base64.b64decode(v.strip()) - if len(decoded) != 32: # Ed25519 public keys are exactly 32 bytes - raise ValueError('Ed25519 public key must be exactly 32 bytes when base64 decoded') - except Exception: - raise ValueError('Ed25519 public key must be valid base64 encoding') - else: - # RSA keys must be in PEM format - if not v.strip().startswith('-----BEGIN PUBLIC KEY-----'): - raise ValueError('RSA public key must be in PEM format starting with -----BEGIN PUBLIC KEY-----') - if not v.strip().endswith('-----END PUBLIC KEY-----'): - raise ValueError('RSA public key must be in PEM format ending with -----END PUBLIC KEY-----') - - return v.strip() - return v - - @validator('is_active') - def validate_is_active(cls, v): - """Validate active status if provided""" - if v is not None and v not in ['true', 'false']: - raise ValueError('is_active must be "true" or "false"') - return v - -class AgentKeyFull(AgentKeyBase): - """Full agent key information including metadata""" - id: int - agent_id: int - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - -# Agent schemas -class AgentBase(BaseModel): - """Base agent schema with common fields""" - name: str = Field(..., min_length=1, max_length=255, description="Agent name") - domain: str = Field(..., min_length=1, max_length=255, description="Agent domain (e.g., https://directory.example.com)") - description: Optional[str] = Field(None, max_length=1000, description="Optional agent description") - contact_email: Optional[str] = Field(None, max_length=255, description="Optional contact email") - is_active: str = Field("true", description="Agent active status") - - @validator('domain') - def validate_domain(cls, v): - """Validate domain format""" - if not v.startswith(('http://', 'https://')): - raise ValueError('Domain must start with http:// or https://') - return v - - @validator('is_active') - def validate_is_active(cls, v): - """Validate active status""" - if v not in ['true', 'false']: - raise ValueError('is_active must be "true" or "false"') - return v - -class AgentCreate(AgentBase): - """Schema for creating a new agent""" - keys: List[AgentKeyCreate] = Field(default=[], description="Initial keys for the agent") - -class AgentUpdate(BaseModel): - """Schema for updating an existing agent""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = Field(None, max_length=1000) - contact_email: Optional[str] = Field(None, max_length=255) - is_active: Optional[str] = Field(None) - - @validator('is_active') - def validate_is_active(cls, v): - """Validate active status if provided""" - if v is not None and v not in ['true', 'false']: - raise ValueError('is_active must be "true" or "false"') - return v - -class AgentPublicInfo(BaseModel): - """Public information returned for agent lookups""" - id: int - name: str - domain: str - description: Optional[str] = None - is_active: str - keys: List[AgentKeyFull] = [] - - class Config: - from_attributes = True - -class AgentFull(AgentBase): - """Full agent information including metadata""" - id: int - created_at: datetime - updated_at: datetime - keys: List[AgentKeyFull] = [] - - class Config: - from_attributes = True - -class AgentResponse(BaseModel): - """Response schema for agent operations""" - success: bool - message: str - agent: AgentFull - -class AgentKeyResponse(BaseModel): - """Response schema for agent key operations""" - success: bool - message: str - key: AgentKeyFull - -class Message(BaseModel): - """Simple message response""" - message: str diff --git a/assets/tap-agent.png b/assets/tap-agent.png deleted file mode 100644 index 41b367d..0000000 Binary files a/assets/tap-agent.png and /dev/null differ diff --git a/assets/tap-merchant.png b/assets/tap-merchant.png deleted file mode 100644 index ba590a8..0000000 Binary files a/assets/tap-merchant.png and /dev/null differ diff --git a/assets/trusted-agent-protocol-flow.png b/assets/trusted-agent-protocol-flow.png deleted file mode 100644 index 5d31077..0000000 Binary files a/assets/trusted-agent-protocol-flow.png and /dev/null differ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..10dfaf9 --- /dev/null +++ b/bun.lock @@ -0,0 +1,49 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "trusted-agent-protocol", + "dependencies": { + "@interledger/http-signature-utils": "^2.0.3", + "@types/node-forge": "^1.3.14", + "node-forge": "^1.3.3", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@interledger/http-signature-utils": ["@interledger/http-signature-utils@2.0.3", "", { "dependencies": { "http-message-signatures": "^1.0.4", "httpbis-digest-headers": "^1.0.0", "jose": "^4.13.1", "uuid": "^9.0.0" } }, "sha512-/vn6+FxZ3g1A5ZC3vOVivjFns5A6F4lhEmdAGg3W9mD2uADXI575rIBcMdqI3wTOJo7qOXRutPXOG/jXONFs/Q=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.5", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-FuLxeLuSVOqHPxSN1fkcD8DLU21gAP7nCKqGRJ/FglbCUBs0NYN6TpHcdmyLeh8C0KwGIaZQJSv+OYG+KZz+Gw=="], + + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "http-message-signatures": ["http-message-signatures@1.0.4", "", { "dependencies": { "structured-headers": "^1.0.1" } }, "sha512-gavCQWnxHFg0BVlKs6CmYK7hNSH1o0x0mHTC68yBAHYOYuTVXPv52mEE7QuT5TenfiagTdOa/zPJzen4lEX7Rg=="], + + "httpbis-digest-headers": ["httpbis-digest-headers@1.0.0", "", { "dependencies": { "structured-headers": "^0.5.0" } }, "sha512-RpaFuZD3tG8wtsvjDv1u7O8wAvfpAxS20F3CrFPZOMn+IBb7E7yiqlN5Tks4E5tBEnTdpMOD151rJsUv03sAIg=="], + + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="], + + "structured-headers": ["structured-headers@1.0.1", "", {}, "sha512-QYBxdBtA4Tl5rFPuqmbmdrS9kbtren74RTJTcs0VSQNVV5iRhJD4QlYTLD0+81SBwUQctjEQzjTRI3WG4DzICA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "httpbis-digest-headers/structured-headers": ["structured-headers@0.5.0", "", {}, "sha512-oLnmXSsjhud+LxRJpvokwP8ImEB2wTg8sg30buwfVViKMuluTv3BlOJHUX9VW9pJ2nQOxmx87Z0kB86O4cphag=="], + } +} diff --git a/cdn-proxy/.env.example b/cdn-proxy/.env.example deleted file mode 100644 index 3cfd784..0000000 --- a/cdn-proxy/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# CDN Proxy Environment Variables -# Copy this file to .env and update values for your environment - -# Server Configuration -PORT=3001 - -# Merchant API Configuration -MERCHANT_API_URL=http://localhost:8000 - -# Debug Configuration -DEBUG=true diff --git a/cdn-proxy/.gitignore b/cdn-proxy/.gitignore deleted file mode 100644 index 911f003..0000000 --- a/cdn-proxy/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Environment files -.env -.env.local - -# Node -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Build outputs -build/ -dist/ diff --git a/cdn-proxy/package.json b/cdn-proxy/package.json deleted file mode 100644 index e071568..0000000 --- a/cdn-proxy/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "cdn-proxy-demo", - "version": "1.0.0", - "description": "Demo CDN proxy for signature verification", - "main": "server.js", - "scripts": { - "start": "node server.js", - "dev": "nodemon server.js" - }, - "dependencies": { - "axios": "1.12.2", - "express": "^4.18.2", - "dotenv": "^16.0.0", - "http-proxy-middleware": "^2.0.6" - }, - "devDependencies": { - "nodemon": "^3.0.1" - } -} diff --git a/cdn-proxy/server.js b/cdn-proxy/server.js deleted file mode 100644 index 703d9ce..0000000 --- a/cdn-proxy/server.js +++ /dev/null @@ -1,712 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -require('dotenv').config(); -const express = require('express'); -const { createProxyMiddleware } = require('http-proxy-middleware'); -const crypto = require('crypto'); -const axios = require('axios'); - - -// HTML escaping function to prevent XSS -function escapeHtml(unsafe) { - if (typeof unsafe !== 'string') { - return String(unsafe); - } - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// Safe HTML error response function -function sendErrorResponse(res, statusCode, title, message, details = null) { - const safeTitle = escapeHtml(title); - const safeMessage = escapeHtml(message); - const safeDetails = details ? escapeHtml(details) : null; - - const html = ` - - - - - - ${safeTitle} - - - -
-

${safeTitle}

-

${safeMessage}

- ${safeDetails ? `
Details: ${safeDetails}
` : ''} -
- - - `; - - res.status(statusCode).send(html); -} - -// Input validation functions -function validateKeyId(keyId) { - // Key IDs should be alphanumeric with limited special characters - if (typeof keyId !== 'string' || keyId.length > 100 || !/^[a-zA-Z0-9._-]+$/.test(keyId)) { - return false; - } - return true; -} - -function sanitizeLogOutput(input) { - // Sanitize data for logging to prevent log injection - if (input === null || input === undefined) { - return '[null]'; - } - if (typeof input !== 'string') { - input = String(input); - } - // Remove control characters, newlines, tabs that could be used for log injection - // Also limit length to prevent log flooding - return input - .replace(/[\x00-\x1F\x7F-\x9F]/g, '') // Remove control characters - .replace(/[\r\n\t]/g, ' ') // Replace line breaks with spaces - .replace(/\s+/g, ' ') // Collapse multiple spaces - .trim() - .substring(0, 200); // Limit length -} - -const app = express(); -const PORT = process.env.PORT || 3001; // Proxy runs on 3001, React on 3000 - -// Security middleware - Add CSP and other security headers -app.use((req, res, next) => { - // Content Security Policy to prevent XSS (allow external images for product images) - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none';"); - - // Additional security headers - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - - next(); -}); - -// Simple rate limiting to prevent abuse (TEMPORARILY DISABLED FOR DEBUGGING) -/* -const requestCounts = new Map(); -const RATE_LIMIT_WINDOW = 60000; // 1 minute -const RATE_LIMIT_MAX = 100; // requests per window - -app.use((req, res, next) => { - const clientIP = req.ip || req.connection.remoteAddress || 'unknown'; - const now = Date.now(); - - // Clean up old entries - for (const [ip, data] of requestCounts.entries()) { - if (now - data.windowStart > RATE_LIMIT_WINDOW) { - requestCounts.delete(ip); - } - } - - // Check rate limit - const clientData = requestCounts.get(clientIP) || { count: 0, windowStart: now }; - - if (now - clientData.windowStart > RATE_LIMIT_WINDOW) { - // Reset window - clientData.count = 1; - clientData.windowStart = now; - } else { - clientData.count++; - } - - requestCounts.set(clientIP, clientData); - - if (clientData.count > RATE_LIMIT_MAX) { - console.log(`โŒ Rate limit exceeded for IP: ${sanitizeLogOutput(clientIP)}`); - return sendErrorResponse(res, 429, '๐Ÿšซ Rate Limit Exceeded', - 'Too many requests. Please try again later.', null); - } - - next(); -}); -*/ - -// Agent Registry API base URL -const AGENT_REGISTRY_URL = 'http://localhost:9002'; - -// Cache for fetched keys to avoid repeated API calls -const keyCache = new Map(); -const CACHE_TTL = 5 ; // 5 milliseconds - -// Nonce cache to prevent replay attacks -const nonceCache = new Map(); -const NONCE_TTL = 3600000; // 1 hour - nonces older than this are purged - -// Cleanup old nonces periodically to prevent memory leaks -setInterval(() => { - const now = Date.now(); - for (const [nonce, timestamp] of nonceCache.entries()) { - if (now - timestamp > NONCE_TTL) { - nonceCache.delete(nonce); - } - } -}, 60000); // Clean up every minute - -// Function to fetch key directly by keyId from Agent Registry -async function getKeyById(keyId) { - const cacheKey = `key:${keyId}`; - - // Check cache first - if (keyCache.has(cacheKey)) { - const cached = keyCache.get(cacheKey); - if (Date.now() - cached.timestamp < CACHE_TTL) { - console.log('๐Ÿ“‹ Using cached key for keyId', sanitizeLogOutput(keyId)); - return cached.key; - } else { - // Remove expired cache entry - keyCache.delete(cacheKey); - } - } - - try { - console.log('๐Ÿ” Fetching key from Agent Registry - KeyId:', sanitizeLogOutput(keyId)); - const response = await axios.get(`${AGENT_REGISTRY_URL}/keys/${keyId}`); - - if (response.status === 200) { - const keyData = response.data; - console.log('โœ… Retrieved key data:', { keyId: sanitizeLogOutput(keyData.key_id), algorithm: sanitizeLogOutput(keyData.algorithm) }); - - // Cache the key - keyCache.set(cacheKey, { - key: keyData, - timestamp: Date.now() - }); - - return keyData; - } else { - console.log('โŒ Failed to fetch key - Status:', response.status); - return null; - } - } catch (error) { - console.error('โŒ Error fetching key:', sanitizeLogOutput(error.message)); - return null; - } -} - - - -// Parse RFC 9421 signature input string to extract components -function parseRFC9421SignatureInput(signatureInput) { - try { - // Parse RFC 9421 format: sig2=("@authority" "@path"); created=1735689600; expires=1735693200; keyId="key-id"; alg="rsa-pss-sha256"; nonce="123"; tag="agent-payment-auth" - const signatureMatch = signatureInput.match(/sig2=\(([^)]+)\);\s*(.+)/); - - if (!signatureMatch) { - throw new Error('Invalid RFC 9421 signature input format - must start with sig2=()'); - } - - const [, paramString, attributesString] = signatureMatch; - - // Parse parameters (what's being signed) - const params = paramString.split(/\s+/).map(p => p.replace(/['"]/g, '')); - - // Parse attributes - const attributes = {}; - const attributeMatches = attributesString.matchAll(/(\w+)=("[^"]*"|\d+)/g); - - for (const match of attributeMatches) { - const [, key, value] = match; - if (value.startsWith('"') && value.endsWith('"')) { - attributes[key] = value.slice(1, -1); // Remove quotes - } else { - attributes[key] = parseInt(value); // Parse numbers - } - } - - console.log('๐Ÿ” Parsed RFC 9421 signature input:'); - console.log(' - params:', params?.map(p => sanitizeLogOutput(p))); - console.log(' - attributes:', Object.fromEntries( - Object.entries(attributes).map(([k, v]) => [k, typeof v === 'string' ? sanitizeLogOutput(v) : v]) - )); - console.log(' - original signatureInput:', sanitizeLogOutput(signatureInput)); - - return { - params, - nonce: attributes.nonce, - created: attributes.created, - expires: attributes.expires, - keyId: attributes.keyId, - algorithm: attributes.alg, - tag: attributes.tag - }; - } catch (error) { - console.error('Failed to parse RFC 9421 signature input:', sanitizeLogOutput(error.message)); - return null; - } -} - -// Build RFC 9421 signature base string from request components -function buildRFC9421SignatureString(params, requestData, signatureInputHeader) { - const components = []; - - // Add the signed components first - for (const param of params) { - switch (param) { - case '@authority': - components.push(`"@authority": ${requestData.authority}`); - break; - case '@path': - components.push(`"@path": ${requestData.path}`); - break; - case 'content-type': - components.push(`"content-type": ${requestData.contentType || 'application/json'}`); - break; - case 'host': - components.push(`"host": ${requestData.host || requestData.authority}`); - break; - default: - // Handle custom headers if needed - if (requestData[param]) { - components.push(`"${param}": ${requestData[param]}`); - } - break; - } - } - - // Add @signature-params as the last component (RFC 9421 requirement) - // Extract just the parameters part (remove sig2= prefix if present) - let signatureParams = signatureInputHeader; - if (signatureInputHeader.startsWith('sig2=')) { - signatureParams = signatureInputHeader.substring(5); // Remove 'sig2=' prefix - } - components.push(`"@signature-params": ${signatureParams}`); - - const signatureBaseString = components.join('\n'); - - console.log('๐Ÿ” RFC 9421 Signature Base String:'); - console.log('---BEGIN SIGNATURE BASE STRING---'); - console.log(sanitizeLogOutput(signatureBaseString)); - console.log('---END SIGNATURE BASE STRING---'); - console.log('๐Ÿ“ Signature base string length:', signatureBaseString.length); - - return signatureBaseString; -} - -// Verify RSA signature with PSS padding (to match Python cryptography library) -async function verifyRSASignature(publicKeyPem, signatureBase64, signatureString) { - try { - console.log('๐Ÿ” RSA Verification:'); - console.log('- Signature string length:', signatureString.length); - console.log('- Signature base64 length:', signatureBase64.length); - console.log('- First 100 chars of signature string:', sanitizeLogOutput(signatureString.substring(0, 100))); - - // Create public key object with explicit PSS padding - const publicKey = crypto.createPublicKey({ - key: publicKeyPem, - format: 'pem', - type: 'spki' - }); - - // Decode base64 signature - const signatureBuffer = Buffer.from(signatureBase64, 'base64'); - - // Create verifier with PSS padding to match Python's PSS padding - const verifier = crypto.createVerify('RSA-SHA256'); - verifier.update(signatureString, 'utf-8'); - - // Verify with PSS padding options (matching Python's PSS.MAX_LENGTH) - const isValid = verifier.verify({ - key: publicKey, - padding: crypto.constants.RSA_PKCS1_PSS_PADDING, - saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX_SIGN - }, signatureBuffer); - - console.log('๐ŸŽฏ Verification result:', isValid ? 'VALID โœ…' : 'INVALID โŒ'); - return isValid; - } catch (error) { - console.error('โŒ Signature verification error:', sanitizeLogOutput(error.message)); - return false; - } -} - -// Verify Ed25519 signature -async function verifyEd25519Signature(publicKeyBase64, signatureBase64, signatureString) { - try { - console.log('๐Ÿ” Ed25519 Verification:'); - console.log('- Signature string length:', signatureString.length); - console.log('- Signature base64 length:', signatureBase64.length); - console.log('- Public key base64 length:', publicKeyBase64.length); - console.log('- First 100 chars of signature string:', sanitizeLogOutput(signatureString.substring(0, 100))); - - // Create Ed25519 public key from base64 raw bytes - const publicKeyBuffer = Buffer.from(publicKeyBase64, 'base64'); - console.log('- Public key buffer length:', publicKeyBuffer.length); - - // Ed25519 public keys should be exactly 32 bytes - if (publicKeyBuffer.length !== 32) { - throw new Error(`Ed25519 public key must be 32 bytes, got ${publicKeyBuffer.length} bytes`); - } - - // Create public key object using DER format for Ed25519 - // Ed25519 public key in DER format: 30 2a 30 05 06 03 2b 65 70 03 21 00 + 32 bytes of key - const derPrefix = Buffer.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]); - const derPublicKey = Buffer.concat([derPrefix, publicKeyBuffer]); - - const publicKey = crypto.createPublicKey({ - key: derPublicKey, - format: 'der', - type: 'spki' - }); - - // Decode base64 signature - const signatureBuffer = Buffer.from(signatureBase64, 'base64'); - console.log('- Signature buffer length:', signatureBuffer.length); - - // Ed25519 signatures should be exactly 64 bytes - if (signatureBuffer.length !== 64) { - throw new Error(`Ed25519 signature must be 64 bytes, got ${signatureBuffer.length} bytes`); - } - - // Verify Ed25519 signature (no hashing needed, Ed25519 is pure) - const isValid = crypto.verify(null, Buffer.from(signatureString, 'utf-8'), publicKey, signatureBuffer); - - console.log('๐ŸŽฏ Ed25519 Verification result:', isValid ? 'VALID โœ…' : 'INVALID โŒ'); - return isValid; - } catch (error) { - console.error('โŒ Ed25519 signature verification error:', sanitizeLogOutput(error.message)); - console.error('โŒ Error stack:', error.stack); - return false; - } -} - -// Signature verification middleware (simulates CDN) -const verifySignature = async (req, res, next) => { - console.log('๐Ÿ” CDN Proxy: Checking request headers...'); - console.log(`๐Ÿ“ Request: ${sanitizeLogOutput(req.method)} ${sanitizeLogOutput(req.url)}`); - - const signatureInput = req.headers['signature-input']; - const signature = req.headers['signature']; - - console.log('๐Ÿ” CDN Proxy: headers are', { - 'signature-input': signatureInput ? `${sanitizeLogOutput(signatureInput.substring(0, 50))}...` : sanitizeLogOutput(signatureInput), - 'signature': signature ? `${sanitizeLogOutput(signature.substring(0, 30))}...` : sanitizeLogOutput(signature) - }); - -let signatureData; - -// Parse RFC 9421 signature input -try { - signatureData = parseRFC9421SignatureInput(signatureInput); - if (!signatureData) { - throw new Error('Failed to parse RFC 9421 signature input'); - } -} catch (err) { - console.log('โŒ Failed to parse RFC 9421 signature-input:', sanitizeLogOutput(err.message)); - return sendErrorResponse(res, 403, '๐Ÿ” Invalid RFC 9421 Signature Input', - 'Could not parse signature-input header. Expected RFC 9421 format.', err.message); -} - -const signatureKeyId = signatureData.keyId; - - // Validate keyId format to prevent injection attacks - if (!validateKeyId(signatureKeyId)) { - console.log('โŒ CDN: Invalid keyId format - blocking'); - return sendErrorResponse(res, 403, '๐Ÿ”‘ Invalid Key ID Format', - 'The key ID contains invalid characters or is too long.', null); - } - - // Log incoming headers for demo (with sanitization) - console.log('Headers received:', { - 'signature-key-id': sanitizeLogOutput(signatureKeyId), - 'signature-input': sanitizeLogOutput(signatureInput), - 'signature': sanitizeLogOutput(signature), - 'user-agent': sanitizeLogOutput(req.headers['user-agent']) - }); - - // Bot detection simulation - if (!signatureKeyId || !signatureInput || !signature) { - console.log('โŒ CDN: Missing signature headers - blocking bot traffic'); - return sendErrorResponse(res, 403, '๐Ÿค– Bot Traffic Detected', - 'This site requires verification from trusted payment directory agents.', - 'Missing required signature headers: signature-input, signature'); - } - - try { - // Fetch the key directly by keyId - const keyInfo = await getKeyById(signatureKeyId); - - if (!keyInfo) { - console.log('โŒ CDN: Key not found - blocking'); - return sendErrorResponse(res, 403, '๐Ÿ”‘ Key Not Found', - 'The specified key ID was not found in the registry.', `Key ID: ${signatureKeyId}`); - } - - // Check if key is active - if (keyInfo.is_active !== 'true') { - console.log('โŒ CDN: Inactive key - blocking'); - return sendErrorResponse(res, 403, '๐Ÿ”‘ Inactive Key', - 'The specified key is not currently active.', `Key ID: ${keyInfo.key_id}`); - } - - console.log('โœ… Key validated:', { - keyId: sanitizeLogOutput(keyInfo.key_id), - algorithm: sanitizeLogOutput(keyInfo.algorithm) - }); - - // Validate signature timing - const now = Math.floor(Date.now() / 1000); - if (signatureData.created && signatureData.created > now + 60) { - console.log('โŒ CDN: Signature created time is in the future'); - return sendErrorResponse(res, 403, '๐Ÿ• Invalid Timestamp', - 'Signature created time is in the future.', null); - } - - if (signatureData.expires && signatureData.expires < now) { - console.log('โŒ CDN: Signature has expired'); - return sendErrorResponse(res, 403, 'โฐ Signature Expired', - 'The signature has expired and is no longer valid.', - `Expired at: ${new Date(signatureData.expires * 1000).toISOString()}`); - } - - // Validate nonce to prevent replay attacks - const nonce = signatureData.nonce; - if (!nonce) { - console.log('โŒ CDN: Missing nonce in signature'); - return sendErrorResponse(res, 403, '๐Ÿ” Missing Nonce', - 'Signature must include a nonce to prevent replay attacks.', null); - } - - // Check if nonce has been used before - if (nonceCache.has(nonce)) { - const previousUse = nonceCache.get(nonce); - console.log('โŒ CDN: Replay attack detected - nonce already used'); - console.log(' - Nonce:', sanitizeLogOutput(nonce)); - console.log(' - First seen:', new Date(previousUse).toISOString()); - console.log(' - Current time:', new Date().toISOString()); - return sendErrorResponse(res, 403, '๐Ÿšซ Replay Attack Detected', - 'This signature has already been used. Each request must have a unique nonce.', - `Nonce was previously used at ${new Date(previousUse).toISOString()}`); - } - - // Store nonce with current timestamp - nonceCache.set(nonce, Date.now()); - console.log('โœ… Nonce validated and cached:', sanitizeLogOutput(nonce)); - - // Build request data for signature verification - const requestData = { - authority: req.get('host') || req.headers.host, - path: req.url, - contentType: req.get('content-type'), - host: req.get('host') || req.headers.host - }; - - console.log('๐Ÿ” Request data for verification:', { - authority: sanitizeLogOutput(requestData.authority), - path: sanitizeLogOutput(requestData.path), - contentType: sanitizeLogOutput(requestData.contentType), - host: sanitizeLogOutput(requestData.host) - }); - console.log('๐Ÿ” Signature data parsed:', { - params: signatureData.params?.map(p => sanitizeLogOutput(p)), - keyId: sanitizeLogOutput(signatureData.keyId), - algorithm: sanitizeLogOutput(signatureData.algorithm), - created: signatureData.created, - expires: signatureData.expires - }); - - // Build RFC 9421 signature base string - const signatureBaseString = buildRFC9421SignatureString( - signatureData.params, - requestData, - signatureInput - ); - - // Extract signature value (remove sig2=: wrapper) - let signatureBase64 = signature; - const signatureMatch = signature.match(/sig2=:([^:]+):/); - if (signatureMatch) { - signatureBase64 = signatureMatch[1]; - } else { - console.log('โŒ CDN: Invalid signature format - expected sig2=:base64:'); - return sendErrorResponse(res, 403, '๐Ÿ” Invalid Signature Format', - 'The signature format is invalid.', 'Expected RFC 9421 signature format: sig2=:base64:'); - } - - // Verify RFC 9421 signature - const publicKey = keyInfo.public_key; - console.log('๐Ÿ” Verifying RFC 9421 signature...'); - console.log('๐Ÿ”‘ Using public key from agent registry for verification'); - console.log('๐Ÿ“‹ Key info:', { keyId: sanitizeLogOutput(keyInfo.key_id), algorithm: sanitizeLogOutput(keyInfo.algorithm) }); - console.log('๐Ÿ” Signature base64 to verify:', sanitizeLogOutput(signatureBase64.substring(0, 50)) + '...'); - - // Choose verification method based on algorithm - let isValidSignature = false; - const algorithm = signatureData.algorithm.toLowerCase(); - - if (algorithm === 'rsa-pss-sha256') { - console.log('๐Ÿ” Using RSA-PSS-SHA256 verification...'); - isValidSignature = await verifyRSASignature(publicKey, signatureBase64, signatureBaseString); - } else if (algorithm === 'ed25519') { - console.log('๐Ÿ” Using Ed25519 verification...'); - isValidSignature = await verifyEd25519Signature(publicKey, signatureBase64, signatureBaseString); - } else { - console.log(`โŒ CDN: Unsupported algorithm: ${algorithm}`); - return sendErrorResponse(res, 400, '๐Ÿ” Unsupported Algorithm', - `The signature algorithm '${algorithm}' is not supported.`, `Supported algorithms: rsa-pss-sha256, ed25519`); - } - - if (!isValidSignature) { - console.log(`โŒ CDN: ${algorithm} signature verification failed`); - return sendErrorResponse(res, 403, '๐Ÿ” Signature Verification Failed', - 'The signature could not be verified against the provided key.', `Key ID: ${keyInfo.key_id}, Algorithm: ${algorithm}`); - } - - console.log('โœ… CDN: RFC 9421 signature verification successful!'); - console.log('๐ŸŽฏ Authenticated request with:', { - keyId: sanitizeLogOutput(keyInfo.key_id), - algorithm: sanitizeLogOutput(signatureData.algorithm), - tag: sanitizeLogOutput(signatureData.tag), - nonce: sanitizeLogOutput(signatureData.nonce) - }); - - console.log('โœ… CDN: RFC 9421 signature verification successful!'); - console.log('๐ŸŽฏ Request authenticated with:', { - keyId: sanitizeLogOutput(keyInfo.key_id), - algorithm: sanitizeLogOutput(signatureData.algorithm), - tag: sanitizeLogOutput(signatureData.tag), - nonce: sanitizeLogOutput(signatureData.nonce) - }); - - // Signature is valid - just forward the request as-is (no headers needed) - console.log('๏ฟฝ Signature valid - forwarding request without modification'); - - next(); - } catch (error) { - console.error('โŒ CDN: Signature verification error:', sanitizeLogOutput(error.message)); - return sendErrorResponse(res, 500, '๐Ÿ› ๏ธ Server Error', - 'An internal error occurred during signature verification.', null); - } -}; - - - -// Add a test endpoint that bypasses signature verification -app.get('/test-proxy', (req, res) => { - const currentTime = escapeHtml(new Date().toISOString()); - res.send(` - - - - - - CDN Proxy Test - - - -
-

โœ… CDN Proxy is working!

-

This request bypassed signature verification

-

Time: ${currentTime}

-

Try to access React frontend

-
- - - `); -}); - -// Signature verification for /products/ URLs -app.use((req, res, next) => { - console.log(`๐Ÿš€ CDN-Proxy received: ${sanitizeLogOutput(req.method)} ${sanitizeLogOutput(req.url)} from ${sanitizeLogOutput(req.get('host'))}`); - console.log(`๐Ÿ” Request headers: ${Object.keys(req.headers).join(', ')}`); - - const url = req.url.toLowerCase(); - const hasSignatureHeaders = req.headers['signature-input'] && req.headers['signature']; - const isProductsApiRoute = url.startsWith('/product/'); - - if (isProductsApiRoute) { - // /products/ route - signature is required - if (!hasSignatureHeaders) { - console.log(`โŒ /products/ route requires signatures - rejecting: ${sanitizeLogOutput(req.url)}`); - return sendErrorResponse(res, 403, '๐Ÿ” Signature Required', - 'Access to /products/ requires verification from trusted payment directory agents.', - 'Missing required signature headers: signature-input, signature'); - } - - // Signature headers provided - verify them - console.log(`๐Ÿ” /products/ route with signatures - verifying: ${sanitizeLogOutput(req.url)}`); - verifySignature(req, res, next).catch(next); - } else { - // Non-/product/ route without signatures - allow - next(); - } -}); - -// Proxy API requests to backend (simple passthrough) -app.use('/api', createProxyMiddleware({ - target: 'http://localhost:8000', - changeOrigin: true, - logLevel: 'debug', - onProxyReq: (proxyReq, req, res) => { - console.log(`๐Ÿ”„ Forwarding API request to backend: ${sanitizeLogOutput(req.path)}`); - // Simple passthrough - no header manipulation needed - } -})); - -// Proxy everything else to React (simple passthrough) -app.use('/', createProxyMiddleware({ - target: 'http://localhost:3000', - changeOrigin: true, - logLevel: 'debug', - onProxyReq: (proxyReq, req, res) => { - console.log(`๐Ÿ”„ Forwarding request to React: ${sanitizeLogOutput(req.method)} ${sanitizeLogOutput(req.path)}`); - }, - onError: (err, req, res) => { - console.error('โŒ Proxy error forwarding to React:', sanitizeLogOutput(err.message)); - } -})); - -app.listen(PORT, () => { - console.log(` -๐Ÿš€ Demo CDN Proxy running on http://localhost:${PORT} - -Architecture: - Browser โ†’ Proxy:3001 (RFC 9421 CDN simulation) โ†’ React:3000 & Backend:8000 - -RFC 9421 HTTP Message Signatures Support: - โœ… Signature Input parsing: sig2=("@authority" "@path"); created=...; expires=...; keyId="..."; alg="rsa-pss-sha256" - โœ… Signature parsing: sig2=:base64-signature: - โœ… RSA-PSS-SHA256 verification - โœ… Timestamp validation - โœ… Agent Registry integration - -Test URLs: - ๐ŸŒ Normal access: http://localhost:${PORT} - ๐Ÿ” With RFC 9421 headers: curl -H "signature-input: sig2=(\\"@authority\\" \\"@path\\"); created=...; keyId=\\"key-id\\"" \\ - -H "signature: sig2=:base64-signature:" \\ - http://localhost:${PORT}/product/1 - - `); -}); - -module.exports = app; diff --git a/merchant-backend/.env.example b/merchant-backend/.env.example deleted file mode 100644 index 9cdd9d2..0000000 --- a/merchant-backend/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Merchant Backend Environment Variables -# Copy this file to .env and update values for your environment - -# Database Configuration -DATABASE_URL=sqlite:///./merchant.db - -# CORS Configuration -ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3001,http://localhost:3003 - -# Debug Configuration -DEBUG=true diff --git a/merchant-backend/.gitignore b/merchant-backend/.gitignore deleted file mode 100644 index 9d47a32..0000000 --- a/merchant-backend/.gitignore +++ /dev/null @@ -1,19 +0,0 @@ -# Environment files -.env -.env.local - -# Virtual environments -venv/ -env/ -.venv/ - -# Python -__pycache__/ -*.pyc -*.pyo -*.db -*.sqlite - -# Build outputs -build/ -dist/ diff --git a/merchant-backend/README.md b/merchant-backend/README.md deleted file mode 100644 index b2b288c..0000000 --- a/merchant-backend/README.md +++ /dev/null @@ -1,226 +0,0 @@ -# Merchant Backend - -Sample e-commerce backend service demonstrating TAP (Trusted Agent Protocol) integration with FastAPI. - -## Installation - -```bash -# Install dependencies (from root directory) -pip install -r requirements.txt - -# Initialize sample database -cd merchant-backend -python create_sample_data.py - -# Start server -python -m uvicorn app.main:app --reload --port 8000 -``` - -Access the API at http://localhost:8000 (docs at /docs) - -## Key Features - -- **Product Management** - CRUD operations for products -- **Shopping Cart** - Session-based cart with persistence -- **Order Processing** - Order creation and tracking -- **TAP Integration** - RFC 9421 signature verification support - -## Sample API Endpoints - -- `GET /products` - List all products -- `POST /cart/add` - Add item to cart -- `POST /orders` - Create order from cart -- `GET /orders` - View order history - -## Architecture - -This sample demonstrates: -- FastAPI async patterns -- SQLAlchemy ORM with SQLite -- Integration with signature verification -- RESTful API design principles -- RESTful API design principles - -## Data Management - -### Initialize Sample Data -```bash -# Create database and populate with sample products -python create_sample_data.py -``` - -### Update Database Schema -```bash -# Run database migrations -python update_database.py -``` - -### Database Operations -```python -# Direct database access (in Python shell) -from app.database.database import get_db -from app.models.models import Product - -# Example: Get all products -db = next(get_db()) -products = db.query(Product).all() -``` - -## Authentication & Security - -### JWT Authentication -- Bearer token authentication -- Configurable token expiration -- Automatic token refresh mechanism - -### Password Security -- Bcrypt password hashing -- Strong password requirements -- Secure session management - -### CORS Configuration -- Configurable allowed origins -- Development and production settings -- Secure headers and credentials handling - -## TAP Integration - -### Signature Verification -- RFC 9421 HTTP Message Signatures support -- Integration with CDN Proxy for signature validation -- Agent Registry integration for public key retrieval -- Ed25519 and RSA-PSS-SHA256 algorithm support - -### Request Flow -1. Client makes request (through CDN Proxy) -2. CDN validates signature headers -3. Verified requests reach merchant backend -4. Backend processes business logic -5. Response returned through proxy chain - -## Development - -### Development Server -```bash -# Start with auto-reload -uvicorn app.main:app --reload --port 8000 - -# Start with specific host/port -uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - -# Production server -uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -### Database Management -```bash -# Reset database -rm merchant.db -python create_sample_data.py - -# Update schema -python update_database.py - -# Backup database -cp merchant.db merchant_backup.db -``` - -### Logging -```bash -# View logs in development -tail -f logs/merchant-backend.log - -# Set log level in .env -LOG_LEVEL=DEBUG # DEBUG, INFO, WARNING, ERROR -``` - -## Testing - -### Manual Testing -- **API Docs**: Visit http://localhost:8000/docs for interactive testing -- **ReDoc**: Visit http://localhost:8000/redoc for detailed documentation -- **Health Check**: GET http://localhost:8000/ - -### Sample API Calls -```bash -# Get all products -curl http://localhost:8000/products - -# Get specific product -curl http://localhost:8000/products/1 - -# Add item to cart -curl -X POST http://localhost:8000/cart/add \ - -H "Content-Type: application/json" \ - -d '{"product_id": 1, "quantity": 2}' - -# Create order -curl -X POST http://localhost:8000/orders \ - -H "Content-Type: application/json" \ - -d '{"customer_name": "John Doe", "customer_email": "john@example.com"}' -``` - -## Production Deployment - -### Environment Setup -```bash -# Production environment variables -DEBUG=false -LOG_LEVEL=WARNING -DATABASE_URL=postgresql://user:pass@localhost/merchant_db -``` - -### Docker Deployment -```dockerfile -FROM python:3.9-slim - -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . -EXPOSE 8000 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] -``` - -### Process Management -```bash -# Using systemd (Linux) -sudo systemctl start merchant-backend -sudo systemctl enable merchant-backend - -# Using PM2 (Node.js process manager) -pm2 start "uvicorn app.main:app --host 0.0.0.0 --port 8000" --name merchant-backend -``` - -## Integration with TAP Ecosystem - -### Required Services -1. **CDN Proxy** (port 3001): Request routing and signature verification -2. **Agent Registry** (port 8080): Agent public key management -3. **Merchant Frontend** (port 3001): User interface - -### Service Dependencies -- Database (SQLite/PostgreSQL) -- Agent Registry for signature verification -- CDN Proxy for secure request routing - -## Performance Considerations - -- **Database Connection Pooling**: Efficient database connections -- **Async Operations**: Non-blocking I/O for better concurrency -- **Response Caching**: Cache frequently accessed data -- **Request Logging**: Structured logging for monitoring -- **Error Handling**: Comprehensive error responses - -## Troubleshooting - -### Common Issues -- **Database connection errors**: Check DATABASE_URL and file permissions -- **Port conflicts**: Ensure port 8000 is available -- **CORS issues**: Verify ALLOWED_ORIGINS configuration -- **Authentication failures**: Check SECRET_KEY and token configuration - -### Debug Mode -Set `DEBUG=true` and `LOG_LEVEL=DEBUG` for detailed logging and error traces. diff --git a/merchant-backend/app/database/database.py b/merchant-backend/app/database/database.py deleted file mode 100644 index c10627f..0000000 --- a/merchant-backend/app/database/database.py +++ /dev/null @@ -1,36 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import os -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from app.models.models import Base -import os - -# Database URL - using SQLite for simplicity, can be changed to PostgreSQL/MySQL -SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./merchant.db") - -engine = create_engine( - SQLALCHEMY_DATABASE_URL, - connect_args={"check_same_thread": False} if "sqlite" in SQLALCHEMY_DATABASE_URL else {} -) - -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -def create_tables(): - """Create all tables in the database""" - Base.metadata.create_all(bind=engine) - -def get_db(): - """Get database session""" - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/merchant-backend/app/main.py b/merchant-backend/app/main.py deleted file mode 100644 index fac5fec..0000000 --- a/merchant-backend/app/main.py +++ /dev/null @@ -1,94 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import logging -import time -import json -import os -from dotenv import load_dotenv -from fastapi import FastAPI, Depends, HTTPException, Request, Response - -# Load environment variables -load_dotenv() -from fastapi.middleware.cors import CORSMiddleware -from app.database.database import create_tables -from app.routes import products, cart, orders, auth - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Create FastAPI app -app = FastAPI( - title="Reference Merchant API", - description="A reference implementation of an e-commerce backend API", - version="1.0.0" -) - -# Add simple request logging middleware -@app.middleware("http") -async def log_requests(request: Request, call_next): - start_time = time.time() - - # Log request without consuming body - logger.info(f"๐Ÿ”ต REQUEST: {request.method} {request.url}") - - # Process request - response = await call_next(request) - - # Log response - process_time = time.time() - start_time - logger.info(f"๐ŸŸข RESPONSE: {response.status_code} - {process_time:.3f}s") - - return response - -# Configure CORS -app.add_middleware( - CORSMiddleware, - allow_origins=os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3001,http://localhost:3001,http://localhost:3003").split(","), # React app URL + Vite dev server - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routers -app.include_router(products.router, prefix="/api") -app.include_router(cart.router, prefix="/api") -app.include_router(orders.router, prefix="/api") -app.include_router(auth.router, prefix="/api") - -@app.on_event("startup") -def startup_event(): - """Create database tables on startup""" - logger.info("๐Ÿš€ Starting Reference Merchant API...") - create_tables() - logger.info("โœ… Database tables created/verified") - -@app.get("/") -def read_root(): - """Root endpoint""" - return {"message": "Welcome to Reference Merchant API"} - -@app.get("/health") -def health_check(): - """Health check endpoint""" - return {"status": "healthy"} - -if __name__ == "__main__": - import uvicorn - # Run development server - uvicorn.run( - app, - host="0.0.0.0", - port=8000, - log_level="info", - access_log=True - ) diff --git a/merchant-backend/app/models/__init__.py b/merchant-backend/app/models/__init__.py deleted file mode 100644 index 1fe3741..0000000 --- a/merchant-backend/app/models/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/merchant-backend/app/models/models.py b/merchant-backend/app/models/models.py deleted file mode 100644 index b942d84..0000000 --- a/merchant-backend/app/models/models.py +++ /dev/null @@ -1,92 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship -from datetime import datetime - -Base = declarative_base() - -class Product(Base): - __tablename__ = "products" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(255), nullable=False, index=True) - description = Column(Text) - price = Column(Float, nullable=False) - category = Column(String(100), index=True) - image_url = Column(String(500)) - stock_quantity = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.utcnow) - - # Relationship with cart items - cart_items = relationship("CartItem", back_populates="product") - order_items = relationship("OrderItem", back_populates="product") - -class Cart(Base): - __tablename__ = "carts" - - id = Column(Integer, primary_key=True, index=True) - session_id = Column(String(255), unique=True, index=True) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationship with cart items - items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") - -class CartItem(Base): - __tablename__ = "cart_items" - - id = Column(Integer, primary_key=True, index=True) - cart_id = Column(Integer, ForeignKey("carts.id"), nullable=False) - product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - quantity = Column(Integer, nullable=False, default=1) - - # Relationships - cart = relationship("Cart", back_populates="items") - product = relationship("Product", back_populates="cart_items") - -class Order(Base): - __tablename__ = "orders" - - id = Column(Integer, primary_key=True, index=True) - order_number = Column(String(100), unique=True, index=True) - customer_email = Column(String(255), nullable=False) - customer_name = Column(String(255), nullable=False) - total_amount = Column(Float, nullable=False) - status = Column(String(50), default="pending") # pending, confirmed, shipped, delivered, cancelled - # Optional additional fields - shipping_address = Column(Text, nullable=True) - billing_address = Column(Text, nullable=True) - phone = Column(String(50), nullable=True) - special_instructions = Column(Text, nullable=True) - payment_method = Column(String(50), nullable=True) - billing_different = Column(Boolean, default=False) - # Payment information (stored securely - in production, use tokenization) - card_last_four = Column(String(4), nullable=True) # Only store last 4 digits - card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc. - payment_status = Column(String(20), default="pending") # pending, processed, failed - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationship with order items - items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") - -class OrderItem(Base): - __tablename__ = "order_items" - - id = Column(Integer, primary_key=True, index=True) - order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) - product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - quantity = Column(Integer, nullable=False) - price = Column(Float, nullable=False) # Price at the time of order - - # Relationships - order = relationship("Order", back_populates="items") - product = relationship("Product", back_populates="order_items") diff --git a/merchant-backend/app/routes/__init__.py b/merchant-backend/app/routes/__init__.py deleted file mode 100644 index 1fe3741..0000000 --- a/merchant-backend/app/routes/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/merchant-backend/app/routes/auth.py b/merchant-backend/app/routes/auth.py deleted file mode 100644 index 7399b95..0000000 --- a/merchant-backend/app/routes/auth.py +++ /dev/null @@ -1,141 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel -from typing import Optional -from app.security.signature_verification import signature_verifier -import base64 -import json - -router = APIRouter(prefix="/auth", tags=["authentication"]) - -class SignatureVerificationRequest(BaseModel): - signature_agent: str - signature_input: str - signature: str - authority: str - path: str - directory_agent: Optional[str] = None - query_param: Optional[str] = None - -class SignatureVerificationResponse(BaseModel): - is_trusted: bool - message: str - agent_name: Optional[str] = None - -@router.post("/verify-signature", response_model=SignatureVerificationResponse) -def verify_signature(verification_request: SignatureVerificationRequest): - """Verify the signature from a trusted agent.""" - - try: - # Prepare request data for signature verification - request_data = { - "authority": verification_request.authority, - "path": verification_request.path, - "directory-agent": verification_request.directory_agent or "", - "query-param": verification_request.query_param or "" - } - - # Verify the signature - is_trusted, message = signature_verifier.is_trusted_agent( - verification_request.signature_agent, - verification_request.signature_input, - verification_request.signature, - request_data - ) - - agent_name = None - if is_trusted: - # Extract agent name from the trusted agents - for agent_url, agent_info in signature_verifier.trusted_agents.items(): - if agent_url in verification_request.signature_agent: - agent_name = agent_info["name"] - break - - return SignatureVerificationResponse( - is_trusted=is_trusted, - message=message, - agent_name=agent_name - ) - - except Exception as e: - raise HTTPException(status_code=400, detail=f"Signature verification failed: {str(e)}") - - - -@router.get("/check-verification") -def check_verification(request: Request): - """Check if request was verified by CDN/Proxy""" - - print("๐Ÿ” /api/auth/check-verification endpoint called!") - print(f"๐Ÿ” Request headers: {dict(request.headers)}") - - # Check headers set by CDN/Proxy - agent_verified = request.headers.get("x-signature-verified") or request.headers.get("x-agent-verified") - agent_name = request.headers.get("x-signature-key-id") or request.headers.get("x-agent-name") - verified_by = request.headers.get("x-verified-by") - agent_data = request.headers.get("x-agent-data") - - decoded_agent_data = None - if agent_data: - try: - decoded_agent_data = base64.b64decode(agent_data).decode("utf-8") - print(f"Decoded agent data: {decoded_agent_data}") - except Exception as e: - print(f"Failed to decode agent data: {e}") - - access_allowed = True - - access_url = None - if decoded_agent_data: - try: - data_json = json.loads(decoded_agent_data) - access_url = data_json.get("accessUrl") - if access_url and "admin" in access_url: - access_allowed = False - token = data_json.get("token") - jwt_body = None - if token: - try: - # JWT format: header.payload.signature - parts = token.split(".") - if len(parts) == 3: - payload_b64 = parts[1] - # Add padding if necessary - padding = '=' * (-len(payload_b64) % 4) - payload_b64 += padding - payload_bytes = base64.urlsafe_b64decode(payload_b64) - jwt_body = json.loads(payload_bytes.decode("utf-8")) - print(f"JWT body: {jwt_body}") - else: - print("Invalid JWT format.") - except Exception as e: - print(f"Failed to decode JWT body: {e}") - except json.JSONDecodeError: - print("Failed to parse decoded agent data as JSON.") - - print(f"Verification headers: verified={agent_verified}, name={agent_name}, by={verified_by}, data={decoded_agent_data}") - - if agent_verified == "true": - if not access_allowed: - return { - "verified": False, - "message": "Access Denied." - } - return { - "verified": True, - "agent_name": agent_name, - "verified_by": verified_by or "CDN", - "message": f"Request verified by {verified_by or 'CDN'}: {agent_name}" - } - else: - return { - "verified": False, - "message": "Request not verified by CDN" - } diff --git a/merchant-backend/app/routes/cart.py b/merchant-backend/app/routes/cart.py deleted file mode 100644 index fbd0e22..0000000 --- a/merchant-backend/app/routes/cart.py +++ /dev/null @@ -1,955 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from app.database.database import get_db -from app.models.models import ( - Cart as CartModel, - CartItem as CartItemModel, - Product as ProductModel, - Order as OrderModel, - OrderItem as OrderItemModel -) -from app.schemas import ( - CartCreate, Cart, CartItemCreate, CartItemUpdate, - CartFinalizeRequest, CartFinalizeResponse, - CartFulfillRequest, CartFulfillResponse, Message -) -import uuid - -router = APIRouter(prefix="/cart", tags=["cart"]) - -def generate_order_number(): - """Generate a unique order number""" - from datetime import datetime - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - random_suffix = uuid.uuid4().hex[:6].upper() - return f"ORD-{timestamp}-{random_suffix}" - -@router.post("/", response_model=Cart) -def create_cart(db: Session = Depends(get_db)): - """Create a new cart with a unique session ID""" - session_id = str(uuid.uuid4()) - db_cart = CartModel(session_id=session_id) - db.add(db_cart) - db.commit() - db.refresh(db_cart) - return db_cart - -@router.get("/{session_id}", response_model=Cart) -def get_cart(session_id: str, db: Session = Depends(get_db)): - """Get cart by session ID""" - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - return cart - -@router.post("/{session_id}/items", response_model=Cart) -def add_item_to_cart( - session_id: str, - item: CartItemCreate, - db: Session = Depends(get_db) -): - """Add an item to the cart""" - # Get or create cart - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - cart = CartModel(session_id=session_id) - db.add(cart) - db.commit() - db.refresh(cart) - - # Check if product exists - product = db.query(ProductModel).filter(ProductModel.id == item.product_id).first() - if not product: - raise HTTPException(status_code=404, detail="Product not found") - - # Check if item already exists in cart - existing_item = db.query(CartItemModel).filter( - CartItemModel.cart_id == cart.id, - CartItemModel.product_id == item.product_id - ).first() - - if existing_item: - # Update quantity - existing_item.quantity += item.quantity - else: - # Add new item - cart_item = CartItemModel( - cart_id=cart.id, - product_id=item.product_id, - quantity=item.quantity - ) - db.add(cart_item) - - db.commit() - db.refresh(cart) - return cart - -@router.put("/{session_id}/items/{product_id}", response_model=Cart) -def update_cart_item( - session_id: str, - product_id: int, - item_update: CartItemUpdate, - db: Session = Depends(get_db) -): - """Update the quantity of an item in the cart""" - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - cart_item = db.query(CartItemModel).filter( - CartItemModel.cart_id == cart.id, - CartItemModel.product_id == product_id - ).first() - - if not cart_item: - raise HTTPException(status_code=404, detail="Item not found in cart") - - if item_update.quantity <= 0: - # Remove item if quantity is 0 or negative - db.delete(cart_item) - else: - cart_item.quantity = item_update.quantity - - db.commit() - db.refresh(cart) - return cart - -@router.delete("/{session_id}/items/{product_id}", response_model=Message) -def remove_item_from_cart( - session_id: str, - product_id: int, - db: Session = Depends(get_db) -): - """Remove an item from the cart""" - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - cart_item = db.query(CartItemModel).filter( - CartItemModel.cart_id == cart.id, - CartItemModel.product_id == product_id - ).first() - - if not cart_item: - raise HTTPException(status_code=404, detail="Item not found in cart") - - db.delete(cart_item) - db.commit() - - return Message(message="Item removed from cart successfully") - -@router.delete("/{session_id}", response_model=Message) -def clear_cart(session_id: str, db: Session = Depends(get_db)): - """Clear all items from the cart""" - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - # Delete all cart items - db.query(CartItemModel).filter(CartItemModel.cart_id == cart.id).delete() - db.commit() - - return Message(message="Cart cleared successfully") - -@router.post("/{session_id}/checkout") -def checkout_cart( - session_id: str, - checkout_data: dict, - db: Session = Depends(get_db) -): - """Checkout cart and create an order with payment processing""" - from app.models.models import Order as OrderModel, OrderItem as OrderItemModel - from datetime import datetime - import uuid - import re - - - - def generate_order_number(): - """Generate a unique order number""" - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - unique_id = str(uuid.uuid4())[:8].upper() - return f"ORD-{timestamp}-{unique_id}" - - def detect_card_brand(card_number): - """Detect card brand from card number""" - card_number = re.sub(r'\D', '', card_number) # Remove non-digits - - if card_number.startswith('4'): - return 'Visa' - elif card_number.startswith(('51', '52', '53', '54', '55')) or card_number.startswith('2'): - return 'Mastercard' - elif card_number.startswith(('34', '37')): - return 'American Express' - elif card_number.startswith('6'): - return 'Discover' - else: - return 'Unknown' - - def validate_card_number(card_number): - """Basic Luhn algorithm validation""" - card_number = re.sub(r'\D', '', card_number) - if len(card_number) < 13 or len(card_number) > 19: - return False - - # Luhn algorithm - def luhn_checksum(card_num): - def digits_of(n): - return [int(d) for d in str(n)] - digits = digits_of(card_num) - odd_digits = digits[-1::-2] - even_digits = digits[-2::-2] - checksum = sum(odd_digits) - for d in even_digits: - checksum += sum(digits_of(d*2)) - return checksum % 10 - - return luhn_checksum(card_number) == 0 - - def validate_expiry(expiry_date): - """Validate expiry date format MM/YY or MM/YYYY""" - if not expiry_date: - return False - - try: - if '/' in expiry_date: - month, year = expiry_date.split('/') - month = int(month) - year = int(year) - - # Convert 2-digit year to 4-digit - if year < 100: - year += 2000 - - # Basic validation - if month < 1 or month > 12: - return False - - # Check if card is not expired - from datetime import datetime - current_date = datetime.now() - if year < current_date.year or (year == current_date.year and month < current_date.month): - return False - - return True - except (ValueError, IndexError): - return False - - return False - - def process_payment(card_data, amount): - """ - Mock payment processing function - In production, this would integrate with a real payment processor like Stripe, Square, etc. - """ - card_number = card_data.get('card_number', '') - expiry_date = card_data.get('expiry_date', '') - cvv = card_data.get('cvv', '') - - # Validate card number - if not validate_card_number(card_number): - return {"success": False, "error": "Invalid card number"} - - # Validate expiry date - if not validate_expiry(expiry_date): - return {"success": False, "error": "Invalid or expired card"} - - # Validate CVV - if not cvv or len(cvv) < 3 or len(cvv) > 4: - return {"success": False, "error": "Invalid CVV"} - - # Mock payment processing - always succeeds for demo - # In production, make API call to payment processor here - return { - "success": True, - "transaction_id": f"txn_{uuid.uuid4().hex[:12]}", - "card_brand": detect_card_brand(card_number), - "last_four": card_number[-4:] - } - - # Get cart by session_id - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - if not cart.items: - raise HTTPException(status_code=400, detail="Cart is empty") - - # Calculate total amount - total_amount = sum(item.product.price * item.quantity for item in cart.items) - - # Extract required fields from checkout_data - customer_email = checkout_data.get('customer_email') - customer_name = checkout_data.get('customer_name') - - if not customer_email or not customer_name: - raise HTTPException( - status_code=400, - detail="Customer email and name are required" - ) - - # Extract payment information - handle both frontend and MCP agent formats - payment_data = { - 'card_number': checkout_data.get('card_number'), - 'expiry_date': checkout_data.get('expiry_date'), - 'cvv': checkout_data.get('cvv'), - 'name_on_card': checkout_data.get('name_on_card') or checkout_data.get('cardholder_name') - } - - # If no payment data provided (e.g., from MCP agent demo), use mock data - if not any([payment_data['card_number'], payment_data['expiry_date'], payment_data['cvv']]): - payment_data = { - 'card_number': '4111111111111111', # Demo Visa card - 'expiry_date': '12/25', - 'cvv': '123', - 'name_on_card': checkout_data.get('customer_name', 'Demo Customer') - } - - - # Validate payment information is provided - if not all([payment_data['card_number'], payment_data['expiry_date'], payment_data['cvv']]): - raise HTTPException( - status_code=400, - detail="Complete payment information is required (card number, expiry date, CVV)" - ) - - # Process payment - payment_result = process_payment(payment_data, total_amount) - - if not payment_result['success']: - raise HTTPException( - status_code=400, - detail=f"Payment failed: {payment_result['error']}" - ) - - # Handle shipping address - handle both string and object formats - shipping_address = checkout_data.get('shipping_address', {}) - if isinstance(shipping_address, str): - shipping_address_str = shipping_address - elif isinstance(shipping_address, dict): - shipping_address_str = f"{shipping_address.get('street', '')}, {shipping_address.get('city', '')}, {shipping_address.get('state', '')} {shipping_address.get('zip', '')}, {shipping_address.get('country', '')}" - else: - shipping_address_str = "No shipping address provided" - - # Create order with all the form data - order = OrderModel( - order_number=generate_order_number(), - customer_email=customer_email, - customer_name=customer_name, - total_amount=total_amount, - status="confirmed", # Set to confirmed since payment succeeded - shipping_address=shipping_address_str, - phone=checkout_data.get('customer_phone'), - special_instructions=checkout_data.get('special_instructions'), - billing_address=checkout_data.get('billing_address', shipping_address_str), - payment_method=checkout_data.get('payment_method', {}).get('type', 'credit_card') if isinstance(checkout_data.get('payment_method'), dict) else checkout_data.get('payment_method', 'credit_card'), - billing_different=checkout_data.get('billing_different', False), - # Store payment information securely - card_last_four=payment_result['last_four'], - card_brand=payment_result['card_brand'], - payment_status="processed" - ) - - db.add(order) - db.commit() - db.refresh(order) - - # Create order items from cart items - for cart_item in cart.items: - order_item = OrderItemModel( - order_id=order.id, - product_id=cart_item.product_id, - quantity=cart_item.quantity, - price=cart_item.product.price - ) - db.add(order_item) - - # Clear cart after successful checkout - from app.models.models import CartItem as CartItemModel - db.query(CartItemModel).filter(CartItemModel.cart_id == cart.id).delete() - - db.commit() - db.refresh(order) - - # Reload order with all relationships to ensure product data is available - from sqlalchemy.orm import joinedload - order = db.query(OrderModel).options( - joinedload(OrderModel.items).joinedload(OrderItemModel.product) - ).filter(OrderModel.id == order.id).first() - - - - # Build items with complete product information - order_items = [] - for item in order.items: - # If product relationship is None, fetch it explicitly - if item.product is None: - product = db.query(ProductModel).filter(ProductModel.id == item.product_id).first() - else: - product = item.product - - # Build item with complete product data - order_item = { - "id": item.id, - "product_id": item.product_id, - "product_name": product.name if product else f'Product {item.product_id}', - "quantity": item.quantity, - "price": float(item.price), # Frontend expects 'price', not 'unit_price' - "unit_price": float(item.price), - "total_price": float(item.price * item.quantity), - "product": { - "id": product.id if product else item.product_id, - "name": product.name if product else f'Product {item.product_id}', - "price": float(product.price) if product else float(item.price), - "image_url": (product.image_url if product and product.image_url else "/placeholder/150/150"), - "description": (product.description if product and product.description else "") - } - } - order_items.append(order_item) - - # Calculate order totals for response - subtotal = sum(item.price * item.quantity for item in order.items) - tax_amount = subtotal * 0.08 # 8% tax - shipping_cost = 15.00 if subtotal < 50 else 0.00 # Free shipping over $50 - - return { - "message": "Order created and payment processed successfully", - "data": { - "order": { - "id": order.id, - "order_number": order.order_number, - "customer_name": order.customer_name, - "customer_email": order.customer_email, - "total_amount": float(order.total_amount), - "subtotal": float(subtotal), - "tax_amount": float(tax_amount), - "shipping_cost": float(shipping_cost), - "status": order.status, - "payment_status": order.payment_status, - "created_at": order.created_at.isoformat(), - "items": order_items - }, - "payment": { - "method": checkout_data.get('payment_method', {}).get('type', 'credit_card') if isinstance(checkout_data.get('payment_method'), dict) else checkout_data.get('payment_method', 'credit_card'), - "transaction_id": payment_result.get('transaction_id'), - "amount_charged": float(order.total_amount), - "status": "processed", - "card_brand": order.card_brand, - "last_four": order.card_last_four - } - }, - # Keep MCP-compatible fields for backward compatibility - "status": "success", - "order": { - "id": order.id, - "order_number": order.order_number, - "customer_name": order.customer_name, - "customer_email": order.customer_email, - "total_amount": float(order.total_amount), - "subtotal": float(subtotal), - "tax_amount": float(tax_amount), - "shipping_cost": float(shipping_cost), - "status": order.status, - "created_at": order.created_at.isoformat(), - "items": [{ - "product_id": item.product_id, - "product_name": item.product.name, - "quantity": item.quantity, - "unit_price": float(item.price), - "total_price": float(item.price * item.quantity) - } for item in order.items] - }, - "payment": { - "method": checkout_data.get('payment_method', {}).get('type', 'credit_card') if isinstance(checkout_data.get('payment_method'), dict) else checkout_data.get('payment_method', 'credit_card'), - "transaction_id": payment_result.get('transaction_id'), - "amount_charged": float(order.total_amount), - "status": "processed" - }, - "fulfillment": { - "tracking_number": f"TRK{uuid.uuid4().hex[:10].upper()}", - "shipping_carrier": "Standard Shipping", - "estimated_delivery": "5-7 business days", - "status": "processing" - } - } - -@router.post("/{session_id}/finalize", status_code=402, response_model=CartFinalizeResponse) -def finalize_cart( - session_id: str, - finalize_data: CartFinalizeRequest, - db: Session = Depends(get_db) -): - """ - Finalize cart with shipping, tax, coupons etc and return 402 Payment Required - This endpoint implements the x402 protocol for payment processing - """ - # Get cart by session_id - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - if not cart.items: - raise HTTPException(status_code=400, detail="Cart is empty") - - # Extract shipping and billing information - shipping_address = finalize_data.shipping_address - billing_address = finalize_data.billing_address or finalize_data.shipping_address - customer_info = finalize_data.customer_info - - # Calculate base amount - subtotal = sum(item.product.price * item.quantity for item in cart.items) - - # Calculate shipping (simplified logic - in production this would be more complex) - shipping_cost = 0.0 - if shipping_address.country.upper() == 'US': - if subtotal < 50: - shipping_cost = 9.99 - else: - shipping_cost = 0.0 # Free shipping over $50 - else: - shipping_cost = 19.99 # International shipping - - # Calculate tax (simplified - 8% for US, 0% for international) - tax_rate = 0.08 if shipping_address.country.upper() == 'US' else 0.0 - tax_amount = subtotal * tax_rate - - # Apply coupons/discounts (placeholder) - discount_amount = 0.0 - coupon_code = finalize_data.coupon_code - if coupon_code: - # Simple coupon logic - in production, this would check a coupons table - if coupon_code.upper() == 'SAVE10': - discount_amount = subtotal * 0.10 - elif coupon_code.upper() == 'FREESHIP': - shipping_cost = 0.0 - - # Calculate final amount - total_amount = subtotal + shipping_cost + tax_amount - discount_amount - - # Generate payment session ID - import uuid - payment_session_id = str(uuid.uuid4()) - - # Store finalized cart details (in production, you might want to cache this) - finalized_cart_data = { - 'session_id': session_id, - 'payment_session_id': payment_session_id, - 'subtotal': subtotal, - 'shipping_cost': shipping_cost, - 'tax_amount': tax_amount, - 'discount_amount': discount_amount, - 'total_amount': total_amount, - 'shipping_address': shipping_address.dict(), - 'billing_address': billing_address.dict(), - 'customer_info': customer_info.dict(), - 'coupon_code': coupon_code, - 'items': [ - { - 'product_id': item.product_id, - 'product_name': item.product.name, - 'quantity': item.quantity, - 'unit_price': item.product.price, - 'total_price': item.product.price * item.quantity - } for item in cart.items - ] - } - - # In production, store this in Redis or database with expiration - # For now, we'll use a simple in-memory store (not production ready) - if not hasattr(finalize_cart, '_finalized_carts'): - finalize_cart._finalized_carts = {} - finalize_cart._finalized_carts[payment_session_id] = finalized_cart_data - - # Return 402 Payment Required with x402 protocol headers and payment details - from fastapi import Response - from fastapi.responses import JSONResponse - - response_data = { - "error": "Payment Required", - "message": "Cart finalized. Payment required to complete order.", - "payment_session_id": payment_session_id, - "amount": { - "subtotal": round(subtotal, 2), - "shipping": round(shipping_cost, 2), - "tax": round(tax_amount, 2), - "discount": round(discount_amount, 2), - "total": round(total_amount, 2), - "currency": "USD" - }, - "payment_methods": [ - { - "type": "credit_card", - "provider": "merchant_payment_processor", - "endpoint": f"http://localhost:8000/api/cart/{session_id}/fulfill", - "method": "POST", - "required_fields": [ - "payment_session_id", - "card_number", - "expiry_date", - "cvv", - "cardholder_name" - ] - } - ], - "expires_at": "2024-12-31T23:59:59Z", # In production, set reasonable expiration - "order_summary": { - "items": finalized_cart_data['items'], - "shipping_address": shipping_address.dict(), - "customer": customer_info.dict() - } - } - - # Create response with 402 status and x402 headers - response = JSONResponse( - status_code=402, - content=response_data - ) - - # Add x402 protocol headers - response.headers["X-Payment-Required"] = "true" - response.headers["X-Payment-Session-ID"] = payment_session_id - response.headers["X-Payment-Amount"] = str(round(total_amount, 2)) - response.headers["X-Payment-Currency"] = "USD" - response.headers["X-Payment-Provider"] = "merchant_payment_processor" - - return response - -@router.post("/{session_id}/fulfill", response_model=CartFulfillResponse) -def fulfill_cart( - session_id: str, - payment_data: CartFulfillRequest, - db: Session = Depends(get_db) -): - """ - Fulfill cart after payment confirmation - This endpoint completes the x402 protocol flow - """ - from app.models.models import Order as OrderModel, OrderItem as OrderItemModel - from datetime import datetime - import uuid - - def generate_order_number(): - """Generate a unique order number""" - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - unique_id = str(uuid.uuid4())[:8].upper() - return f"ORD-{timestamp}-{unique_id}" - - # Get payment session ID from request - payment_session_id = payment_data.payment_session_id - - # Retrieve finalized cart data - if not hasattr(finalize_cart, '_finalized_carts') or payment_session_id not in finalize_cart._finalized_carts: - raise HTTPException(status_code=404, detail="Payment session not found or expired") - - finalized_data = finalize_cart._finalized_carts[payment_session_id] - - # Verify cart still exists - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - # Extract payment information - card_number = payment_data.card_number - expiry_date = payment_data.expiry_date - cvv = payment_data.cvv - cardholder_name = payment_data.cardholder_name - - # Process payment (mock implementation) - def process_payment_with_provider(payment_info, amount): - """Mock payment processing - in production, integrate with Stripe, Square, etc.""" - import re - - # Basic validation - card_clean = re.sub(r'\D', '', payment_info['card_number']) - if len(card_clean) < 13 or len(card_clean) > 19: - return {"success": False, "error": "Invalid card number"} - - # Mock successful payment - return { - "success": True, - "transaction_id": f"txn_{uuid.uuid4().hex[:12]}", - "provider_reference": f"ref_{uuid.uuid4().hex[:8]}", - "last_four": card_clean[-4:], - "card_brand": "Visa" if card_clean.startswith('4') else "Mastercard" - } - - # Process payment - payment_result = process_payment_with_provider({ - 'card_number': card_number, - 'expiry_date': expiry_date, - 'cvv': cvv, - 'cardholder_name': cardholder_name - }, finalized_data['total_amount']) - - if not payment_result['success']: - raise HTTPException(status_code=400, detail=f"Payment failed: {payment_result['error']}") - - # Create order - customer_info = finalized_data['customer_info'] - order = OrderModel( - order_number=generate_order_number(), - customer_email=customer_info.get('email'), - customer_name=customer_info.get('name'), - total_amount=finalized_data['total_amount'], - status="confirmed", - shipping_address=str(finalized_data['shipping_address']), - billing_address=str(finalized_data['billing_address']), - phone=customer_info.get('phone'), - payment_method="credit_card", - card_last_four=payment_result['last_four'], - card_brand=payment_result['card_brand'], - payment_status="processed" - ) - - db.add(order) - db.commit() - db.refresh(order) - - # Create order items - for item_data in finalized_data['items']: - order_item = OrderItemModel( - order_id=order.id, - product_id=item_data['product_id'], - quantity=item_data['quantity'], - price=item_data['unit_price'] - ) - db.add(order_item) - - # Clear cart after successful fulfillment - from app.models.models import CartItem as CartItemModel - db.query(CartItemModel).filter(CartItemModel.cart_id == cart.id).delete() - - db.commit() - - # Clean up payment session data - del finalize_cart._finalized_carts[payment_session_id] - - # Generate tracking number (mock) - tracking_number = f"TRK{uuid.uuid4().hex[:10].upper()}" - - return { - "status": "fulfilled", - "message": "Order completed successfully", - "order": { - "id": order.id, - "order_number": order.order_number, - "status": order.status, - "total_amount": order.total_amount, - "created_at": order.created_at - }, - "payment": { - "transaction_id": payment_result['transaction_id'], - "provider_reference": payment_result['provider_reference'], - "status": "completed" - }, - "fulfillment": { - "tracking_number": tracking_number, - "estimated_delivery": "5-7 business days", - "shipping_carrier": "Standard Shipping" - } - } - - -@router.post("/{session_id}/x402/checkout") -async def x402_checkout( - session_id: str, - checkout_data: dict, - db: Session = Depends(get_db) -): - """ - Machine-to-machine x402 checkout endpoint - Accepts delegation token as payment and settles through Payment Facilitator - """ - try: - # Extract delegation token and agent info - delegation_token = checkout_data.get('delegation_token') - agent_id = checkout_data.get('agent_id') - - if not delegation_token or not agent_id: - raise HTTPException( - status_code=400, - detail="delegation_token and agent_id are required for x402 checkout" - ) - - # Get cart by session_id - cart = db.query(CartModel).filter(CartModel.session_id == session_id).first() - if not cart: - raise HTTPException(status_code=404, detail="Cart not found") - - if not cart.items: - raise HTTPException(status_code=400, detail="Cart is empty") - - # Calculate totals - subtotal = sum(item.quantity * item.product.price for item in cart.items) - shipping_cost = 15.00 # Standard shipping - tax_rate = 0.0875 # 8.75% tax - tax_amount = subtotal * tax_rate - total_amount = subtotal + shipping_cost + tax_amount - - # Prepare items for settlement request - items = [] - for cart_item in cart.items: - items.append({ - "product_id": cart_item.product_id, - "name": cart_item.product.name, - "quantity": cart_item.quantity, - "price": float(cart_item.product.price) - }) - - # Prepare settlement request to Payment Facilitator - merchant_id = "merchant_123" # Your merchant ID - merchant_name = "Reference Merchant" - - # Generate merchant signature for settlement - settlement_data = f"{merchant_id}:{session_id}:{total_amount}" - merchant_secret = f"merchant_{merchant_id}_secret" - - import hmac - import hashlib - merchant_signature = hmac.new( - merchant_secret.encode('utf-8'), - settlement_data.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - - settlement_request = { - "delegation_token": delegation_token, - "merchant_id": merchant_id, - "merchant_name": merchant_name, - "cart_id": session_id, - "amount": total_amount, - "currency": "USD", - "items": items, - "merchant_signature": merchant_signature - } - - # Call Payment Facilitator to settle payment - import requests - facilitator_url = "http://localhost:8001" - - try: - settlement_response = requests.post( - f"{facilitator_url}/x402/settle", - json=settlement_request, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - - if settlement_response.status_code != 200: - error_detail = settlement_response.text - raise HTTPException( - status_code=402, # Payment Required - detail=f"Payment settlement failed: {error_detail}" - ) - - settlement_data = settlement_response.json() - receipt = settlement_data["transaction_receipt"] - - except requests.RequestException as e: - raise HTTPException( - status_code=503, - detail=f"Payment Facilitator unavailable: {str(e)}" - ) - - # Payment settled successfully, create order - order = OrderModel( - order_number=generate_order_number(), - customer_email=f"agent_{agent_id}@system.local", - customer_name=f"Agent {agent_id}", - total_amount=total_amount, - status="confirmed", - payment_method="x402_delegation", - payment_status="processed", - # Store x402 payment details - card_last_four=None, # Not applicable for x402 - card_brand="x402_token" # Indicate x402 payment - ) - - db.add(order) - db.commit() - db.refresh(order) - - # Create order items from cart - for cart_item in cart.items: - order_item = OrderItemModel( - order_id=order.id, - product_id=cart_item.product_id, - quantity=cart_item.quantity, - price=cart_item.product.price - ) - db.add(order_item) - - # Clear cart after successful checkout - from app.models.models import CartItem as CartItemModel - db.query(CartItemModel).filter(CartItemModel.cart_id == cart.id).delete() - - db.commit() - - # Generate tracking number - tracking_number = f"TRK{uuid.uuid4().hex[:10].upper()}" - - # Return comprehensive order details to agent - return { - "status": "success", - "message": "x402 checkout completed successfully", - "order": { - "id": order.id, - "order_number": order.order_number, - "customer_name": order.customer_name, - "customer_email": order.customer_email, - "total_amount": float(order.total_amount), - "subtotal": float(subtotal), - "tax_amount": float(tax_amount), - "shipping_cost": float(shipping_cost), - "status": order.status, - "payment_method": order.payment_method, - "payment_status": order.payment_status, - "created_at": order.created_at.isoformat(), - "items": [ - { - "product_id": item.product_id, - "product_name": item.product.name, - "quantity": item.quantity, - "unit_price": float(item.price), - "total_price": float(item.quantity * item.price) - } - for item in order.items - ] - }, - "payment": { - "method": "x402_delegation", - "receipt_id": receipt["receipt_id"], - "transaction_id": receipt["transaction_id"], - "payment_rail": receipt["payment_rail_used"], - "amount_charged": float(receipt["amount"]), - "processing_fee": float(receipt["processing_fee"]), - "net_amount": float(receipt["net_amount"]), - "status": "completed" - }, - "delegation": { - "remaining_limit": float(settlement_data["remaining_delegation_limit"]), - "agent_id": agent_id - }, - "fulfillment": { - "tracking_number": tracking_number, - "estimated_delivery": "5-7 business days", - "shipping_carrier": "Standard Shipping", - "status": "processing" - } - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"x402 checkout failed: {str(e)}" - ) diff --git a/merchant-backend/app/routes/orders.py b/merchant-backend/app/routes/orders.py deleted file mode 100644 index ecd8b4f..0000000 --- a/merchant-backend/app/routes/orders.py +++ /dev/null @@ -1,112 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session -from app.database.database import get_db -from app.models.models import ( - Order as OrderModel, - OrderItem as OrderItemModel -) -from app.schemas import Order, OrderList, Message -import uuid -from datetime import datetime - -router = APIRouter(prefix="/orders", tags=["orders"]) - -def generate_order_number(): - """Generate a unique order number""" - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - unique_id = str(uuid.uuid4())[:8].upper() - return f"ORD-{timestamp}-{unique_id}" - -# Checkout functionality moved to /cart/{session_id}/checkout - -@router.get("/", response_model=OrderList) -def get_orders( - customer_email: str = None, - status: str = None, - limit: int = 20, - offset: int = 0, - db: Session = Depends(get_db) -): - """Get orders with optional filtering""" - query = db.query(OrderModel) - - if customer_email: - query = query.filter(OrderModel.customer_email == customer_email) - - if status: - query = query.filter(OrderModel.status == status) - - total = query.count() - orders = query.order_by(OrderModel.created_at.desc()).offset(offset).limit(limit).all() - - return OrderList(orders=orders, total=total) - -@router.get("/{order_id}", response_model=Order) -def get_order(order_id: int, db: Session = Depends(get_db)): - """Get a specific order by ID""" - order = db.query(OrderModel).filter(OrderModel.id == order_id).first() - if not order: - raise HTTPException(status_code=404, detail="Order not found") - return order - -@router.get("/number/{order_number}", response_model=Order) -def get_order_by_number(order_number: str, db: Session = Depends(get_db)): - """Get a specific order by order number""" - order = db.query(OrderModel).filter(OrderModel.order_number == order_number).first() - if not order: - raise HTTPException(status_code=404, detail="Order not found") - return order - -@router.put("/{order_id}/status", response_model=Order) -def update_order_status( - order_id: int, - status: str, - db: Session = Depends(get_db) -): - """Update order status""" - valid_statuses = ["pending", "confirmed", "shipped", "delivered", "cancelled"] - if status not in valid_statuses: - raise HTTPException( - status_code=400, - detail=f"Invalid status. Must be one of: {', '.join(valid_statuses)}" - ) - - order = db.query(OrderModel).filter(OrderModel.id == order_id).first() - if not order: - raise HTTPException(status_code=404, detail="Order not found") - - order.status = status - order.updated_at = datetime.utcnow() - - db.commit() - db.refresh(order) - - return order - -@router.delete("/{order_id}", response_model=Message) -def cancel_order(order_id: int, db: Session = Depends(get_db)): - """Cancel an order (only if status is pending or confirmed)""" - order = db.query(OrderModel).filter(OrderModel.id == order_id).first() - if not order: - raise HTTPException(status_code=404, detail="Order not found") - - if order.status not in ["pending", "confirmed"]: - raise HTTPException( - status_code=400, - detail="Order cannot be cancelled. Only pending or confirmed orders can be cancelled." - ) - - order.status = "cancelled" - order.updated_at = datetime.utcnow() - - db.commit() - - return Message(message=f"Order {order.order_number} has been cancelled successfully") diff --git a/merchant-backend/app/routes/products.py b/merchant-backend/app/routes/products.py deleted file mode 100644 index 57ac2c6..0000000 --- a/merchant-backend/app/routes/products.py +++ /dev/null @@ -1,224 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import JSONResponse -from sqlalchemy.orm import Session -from typing import Optional -import requests -import logging -from app.database.database import get_db -from app.models.models import Product as ProductModel -from app.schemas import Product, ProductList, ProductSearch, ProductCreate -from sqlalchemy import and_, or_ - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/products", tags=["products"]) - -@router.get("/", response_model=ProductList) -def search_products( - query: Optional[str] = Query(None, description="Search query for product name or description"), - category: Optional[str] = Query(None, description="Filter by category"), - min_price: Optional[float] = Query(None, description="Minimum price filter"), - max_price: Optional[float] = Query(None, description="Maximum price filter"), - limit: int = Query(20, ge=1, le=100, description="Number of products to return"), - offset: int = Query(0, ge=0, description="Number of products to skip"), - db: Session = Depends(get_db) -): - """Search and filter products""" - - # Build query - filters = [] - - if query: - filters.append( - or_( - ProductModel.name.ilike(f"%{query}%"), - ProductModel.description.ilike(f"%{query}%") - ) - ) - - if category: - filters.append(ProductModel.category.ilike(f"%{category}%")) - - if min_price is not None: - filters.append(ProductModel.price >= min_price) - - if max_price is not None: - filters.append(ProductModel.price <= max_price) - - # Apply filters - query_obj = db.query(ProductModel) - if filters: - query_obj = query_obj.filter(and_(*filters)) - - # Get total count - total = query_obj.count() - - # Apply pagination and get results - products = query_obj.offset(offset).limit(limit).all() - - return ProductList( - products=products, - total=total, - limit=limit, - offset=offset - ) - -@router.get("/premium/search") -def premium_search_products( - request: Request, - query: Optional[str] = Query(None, description="Search query for product name or description"), - category: Optional[str] = Query(None, description="Filter by category"), - min_price: Optional[float] = Query(None, description="Minimum price filter"), - max_price: Optional[float] = Query(None, description="Maximum price filter"), - limit: int = Query(20, ge=1, le=100, description="Number of products to return"), - offset: int = Query(0, ge=0, description="Number of products to skip"), - delegate_token: Optional[str] = Query(None, description="x402 delegation token for payment"), - db: Session = Depends(get_db) -): - """Premium search endpoint that requires x402 payment via delegation token""" - - # Check if delegation token is provided - if not delegate_token: - # Return 402 Payment Required with payment details - payment_details = { - "error": "Payment Required", - "message": "This premium search endpoint requires payment via x402 delegation token", - "payment_required": True, - "payment_details": { - "amount": 0.50, # $0.50 for premium search - "currency": "USD", - "payment_type": "x402_delegation", - "payment_facilitator_url": "http://localhost:8001", - "service_description": "Premium Product Search with Enhanced Features", - "features": [ - "Advanced search algorithms", - "Detailed product analytics", - "Personalized recommendations", - "Priority response time" - ] - } - } - return JSONResponse( - status_code=402, - content=payment_details - ) - - # Verify delegation token with Payment Facilitator - try: - pf_response = requests.post( - "http://localhost:8001/verify-delegation", - json={ - "delegation_token": delegate_token, - "amount": 0.50, - "merchant_id": "merchant_123", - "service": "premium_search" - }, - timeout=5 - ) - - if pf_response.status_code != 200: - logger.error(f"Payment Facilitator verification failed: {pf_response.status_code}") - raise HTTPException( - status_code=402, - detail="Invalid or expired delegation token" - ) - - verification_result = pf_response.json() - if not verification_result.get("valid", False): - raise HTTPException( - status_code=402, - detail="Delegation token verification failed" - ) - - logger.info(f"โœ… Delegation token verified for premium search: {verification_result}") - - except requests.RequestException as e: - logger.error(f"Failed to verify delegation token: {e}") - raise HTTPException( - status_code=502, - detail="Payment verification service unavailable" - ) - - # Token verified, proceed with premium search - logger.info(f"๐Ÿ” Premium search authorized for query: '{query}'") - - # Build enhanced query with premium features - filters = [] - - if query: - # Enhanced search with stemming and fuzzy matching (premium feature) - filters.append( - or_( - ProductModel.name.ilike(f"%{query}%"), - ProductModel.description.ilike(f"%{query}%"), - ProductModel.category.ilike(f"%{query}%") # Also search in category - ) - ) - - if category: - filters.append(ProductModel.category.ilike(f"%{category}%")) - - if min_price is not None: - filters.append(ProductModel.price >= min_price) - - if max_price is not None: - filters.append(ProductModel.price <= max_price) - - # Apply filters with premium sorting - query_obj = db.query(ProductModel) - if filters: - query_obj = query_obj.filter(and_(*filters)) - - # Premium feature: Sort by relevance and popularity - query_obj = query_obj.order_by(ProductModel.stock_quantity.desc(), ProductModel.price.asc()) - - # Get total count - total = query_obj.count() - - # Apply pagination - products = query_obj.offset(offset).limit(limit).all() - - # Premium response with enhanced data - return { - "products": products, - "total": total, - "limit": limit, - "offset": offset, - "premium_features": { - "enhanced_search": True, - "relevance_sorted": True, - "payment_confirmed": True, - "service_tier": "premium" - }, - "search_analytics": { - "query_processed": query, - "results_found": len(products), - "search_time_ms": 45, # Simulated faster response - "relevance_score": 0.95 - } - } - -@router.get("/{product_id}", response_model=Product) -def get_product(product_id: int, db: Session = Depends(get_db)): - """Get a specific product by ID""" - product = db.query(ProductModel).filter(ProductModel.id == product_id).first() - if not product: - raise HTTPException(status_code=404, detail="Product not found") - return product - -@router.post("/", response_model=Product) -def create_product(product: ProductCreate, db: Session = Depends(get_db)): - """Create a new product (admin functionality)""" - db_product = ProductModel(**product.dict()) - db.add(db_product) - db.commit() - db.refresh(db_product) - return db_product diff --git a/merchant-backend/app/schemas.py b/merchant-backend/app/schemas.py deleted file mode 100644 index d74c2a9..0000000 --- a/merchant-backend/app/schemas.py +++ /dev/null @@ -1,207 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from pydantic import BaseModel, EmailStr -from typing import List, Optional -from datetime import datetime - -# Product schemas -class ProductBase(BaseModel): - name: str - description: Optional[str] = None - price: float - category: Optional[str] = None - image_url: Optional[str] = None - stock_quantity: int = 0 - -class ProductCreate(ProductBase): - pass - -class Product(ProductBase): - id: int - created_at: datetime - - class Config: - from_attributes = True - -# Cart schemas -class CartItemBase(BaseModel): - product_id: int - quantity: int - -class CartItemCreate(CartItemBase): - pass - -class CartItemUpdate(BaseModel): - quantity: int - -class CartItem(CartItemBase): - id: int - product: Product - - class Config: - from_attributes = True - -class CartBase(BaseModel): - session_id: str - -class CartCreate(CartBase): - pass - -class Cart(CartBase): - id: int - items: List[CartItem] = [] - - class Config: - from_attributes = True - -# x402 Protocol Schemas -class CustomerInfo(BaseModel): - name: str - email: EmailStr - phone: Optional[str] = None - -class Address(BaseModel): - street: str - city: str - state: str - postal_code: str - country: str - -class CartFinalizeRequest(BaseModel): - customer_info: CustomerInfo - shipping_address: Address - billing_address: Optional[Address] = None - coupon_code: Optional[str] = None - -class PaymentAmount(BaseModel): - subtotal: float - shipping: float - tax: float - discount: float - total: float - currency: str = "USD" - -class PaymentMethod(BaseModel): - type: str - provider: str - endpoint: str - method: str - required_fields: List[str] - -class OrderSummaryItem(BaseModel): - product_id: int - product_name: str - quantity: int - unit_price: float - total_price: float - -class OrderSummary(BaseModel): - items: List[OrderSummaryItem] - shipping_address: Address - customer: CustomerInfo - -class CartFinalizeResponse(BaseModel): - error: str - message: str - payment_session_id: str - amount: PaymentAmount - payment_methods: List[PaymentMethod] - expires_at: str - order_summary: OrderSummary - -class CartFulfillRequest(BaseModel): - payment_session_id: str - card_number: str - expiry_date: str - cvv: str - cardholder_name: str - -class OrderInfo(BaseModel): - id: int - order_number: str - status: str - total_amount: float - created_at: datetime - -class PaymentInfo(BaseModel): - transaction_id: str - provider_reference: str - status: str - -class FulfillmentInfo(BaseModel): - tracking_number: str - estimated_delivery: str - shipping_carrier: str - -class CartFulfillResponse(BaseModel): - status: str - message: str - order: OrderInfo - payment: PaymentInfo - fulfillment: FulfillmentInfo - -# Order schemas -class OrderItemBase(BaseModel): - product_id: int - quantity: int - price: float - -class OrderItem(OrderItemBase): - id: int - product: Product - - class Config: - from_attributes = True - -class OrderBase(BaseModel): - customer_email: EmailStr - customer_name: str - -class OrderCreate(OrderBase): - cart_id: int - # Optional shipping information - shipping_address: Optional[str] = None - phone: Optional[str] = None - special_instructions: Optional[str] = None - -class Order(OrderBase): - id: int - order_number: str - total_amount: float - status: str - items: List[OrderItem] = [] - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - -# Search and filter schemas -class ProductSearch(BaseModel): - query: Optional[str] = None - category: Optional[str] = None - min_price: Optional[float] = None - max_price: Optional[float] = None - limit: int = 20 - offset: int = 0 - -# Response schemas -class ProductList(BaseModel): - products: List[Product] - total: int - limit: int - offset: int - -class OrderList(BaseModel): - orders: List[Order] - total: int - -# Message schemas -class Message(BaseModel): - message: str diff --git a/merchant-backend/app/security/__init__.py b/merchant-backend/app/security/__init__.py deleted file mode 100644 index 77c6ebc..0000000 --- a/merchant-backend/app/security/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -# Security module for signature verification diff --git a/merchant-backend/app/security/signature_verification.py b/merchant-backend/app/security/signature_verification.py deleted file mode 100644 index acafe35..0000000 --- a/merchant-backend/app/security/signature_verification.py +++ /dev/null @@ -1,174 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import re -import time -import json -from typing import Dict, Optional, Tuple -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa, padding -from cryptography.exceptions import InvalidSignature -import base64 -import hashlib - -publicKey = """-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAysHJFJ9uoVvU1sH2x3TV -bwW3nfyp34eOb8w177Ei/Bx8pk+8Ibu1yulV0nCBl/c9insg1k2x7dw1jRDZHJBG -wIpCdRL0GKm6qIdtsjeOcMnkI5ET0zGpxkhuRUwRblYW3LAdAq1Gja1WSPQKRT8r -EhUsmSlDWgAf0rFna15Ok6zOO3q21LtrEjnJSrgO+cr33YH0IAdALD7hqtPYK7+/ -7dD/XIgGW9cX0USMxdDBUt8TnN2TYar5YXetpMnFOPpQHiGpKTkDwrRggcthUyuC -e1CoL/9a/DWilJwd481QkurvZqGaKegX5DlI+jLNvfi8TWMS3jCjknyOLg54KU86 -LQIDAQAB ------END PUBLIC KEY-----""" - -class SignatureVerifier: - def __init__(self): - # In production, these would be loaded from secure storage/config - self.trusted_agents = { - "https://directory.example.com": { - "public_key": self._load_public_key("example"), - "name": "Example Directory" - }, - "https://payment.sample.org": { - "public_key": self._load_public_key("sample"), - "name": "Sample Payment Directory" - } - } - - def _load_public_key(self, agent_name: str): - """Load public key for the agent. In production, load from secure storage.""" - - if agent_name == "example": - return serialization.load_pem_public_key(publicKey.encode("utf-8")) - elif agent_name == "sample": - # Replace with actual sample public key in production - return serialization.load_pem_public_key(publicKey.encode("utf-8")) - else: - raise ValueError(f"Unknown agent name: {agent_name}") - - - def parse_signature_headers(self, signature_agent: str, signature_input: str, signature: str) -> Optional[Dict]: - """Parse the signature headers and extract components.""" - try: - # Parse Signature-Agent - agent_url = signature_agent.strip('"') - - # Parse Signature-Input - signature_input_pattern = r'sig1=\("([^"]+)"\);\s*nonce="([^"]+)";\s*created=(\d+);\s*expires=(\d+);\s*keyid="([^"]+)";\s*tag="([^"]+)"' - match = re.match(signature_input_pattern, signature_input.strip()) - - if not match: - return None - - signature_params, nonce, created, expires, keyid, tag = match.groups() - - # Parse Signature - signature_pattern = r'sig1=:([^:]+):' - sig_match = re.match(signature_pattern, signature.strip()) - - if not sig_match: - return None - - signature_value = sig_match.group(1) - - return { - "agent_url": agent_url, - "signature_params": signature_params.split(" "), - "nonce": nonce, - "created": int(created), - "expires": int(expires), - "keyid": keyid, - "tag": tag, - "signature": signature_value - } - except Exception as e: - print(f"Error parsing signature headers: {e}") - return None - - def verify_signature(self, parsed_data: Dict, request_data: Dict) -> Tuple[bool, str]: - """Verify the signature against the request data.""" - try: - agent_url = parsed_data["agent_url"] - - # Check if agent is trusted - if agent_url not in self.trusted_agents: - return False, f"Unknown agent: {agent_url}" - - # Check timestamp validity - current_time = int(time.time()) - if current_time < parsed_data["created"]: - return False, "Signature created in the future" - - if current_time > parsed_data["expires"]: - return False, "Signature expired" - - # Build signature string - signature_string = self._build_signature_string( - parsed_data["signature_params"], - request_data, - parsed_data["nonce"], - parsed_data["created"], - parsed_data["expires"] - ) - - # Verify signature - public_key = self.trusted_agents[agent_url]["public_key"] - signature_bytes = base64.b64decode(parsed_data["signature"]) - - try: - public_key.verify( - signature_bytes, - signature_string.encode('utf-8'), - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH - ), - hashes.SHA256() - ) - return True, f"Verified agent: {self.trusted_agents[agent_url]['name']}" - except InvalidSignature: - return False, "Invalid signature" - - except Exception as e: - return False, f"Verification error: {str(e)}" - - def _build_signature_string(self, params: list, request_data: Dict, nonce: str, created: int, expires: int) -> str: - """Build the signature string from the parameters.""" - signature_parts = [] - - for param in params: - if param == "@authority": - signature_parts.append(f'"@authority": "{request_data.get("authority", "")}"') - elif param == "@path": - signature_parts.append(f'"@path": "{request_data.get("path", "")}"') - elif param == "directory-agent": - signature_parts.append(f'"directory-agent": "{request_data.get("directory-agent", "")}"') - elif param == "query-param": - signature_parts.append(f'"query-param": "{request_data.get("query-param", "")}"') - - signature_parts.extend([ - f'"nonce": "{nonce}"', - f'"created": {created}', - f'"expires": {expires}' - ]) - - return "\n".join(signature_parts) - - def is_trusted_agent(self, signature_agent: str, signature_input: str, signature: str, request_data: Dict) -> Tuple[bool, str]: - """Main method to verify if the request is from a trusted agent.""" - # Parse headers - parsed_data = self.parse_signature_headers(signature_agent, signature_input, signature) - - if not parsed_data: - return False, "Invalid signature format" - - # Verify signature - return self.verify_signature(parsed_data, request_data) - -# Global instance -signature_verifier = SignatureVerifier() diff --git a/merchant-backend/create_sample_data.py b/merchant-backend/create_sample_data.py deleted file mode 100644 index a0bc20b..0000000 --- a/merchant-backend/create_sample_data.py +++ /dev/null @@ -1,197 +0,0 @@ -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from app.database.database import SessionLocal, create_tables -from app.models.models import Product - -def create_sample_products(): - """Create sample products for testing""" - db = SessionLocal() - - sample_products = [ - { - "name": "Wireless Headphones", - "description": "High-quality wireless headphones with noise cancellation", - "price": 199.99, - "category": "Electronics", - "image_url": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e", - "stock_quantity": 50 - }, - { - "name": "Smartphone", - "description": "Latest smartphone with advanced camera and long battery life", - "price": 699.99, - "category": "Electronics", - "image_url": "https://images.unsplash.com/photo-1511707171634-5f897ff02aa9", - "stock_quantity": 30 - }, - { - "name": "Running Shoes", - "description": "Comfortable running shoes for daily exercise", - "price": 129.99, - "category": "Sports", - "image_url": "https://images.unsplash.com/photo-1542291026-7eec264c27ff", - "stock_quantity": 75 - }, - { - "name": "Coffee Maker", - "description": "Automatic coffee maker with programmable timer", - "price": 89.99, - "category": "Kitchen", - "image_url": "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085", - "stock_quantity": 25 - }, - { - "name": "Laptop", - "description": "High-performance laptop for work and gaming", - "price": 1299.99, - "category": "Electronics", - "image_url": "https://images.unsplash.com/photo-1496181133206-80ce9b88a853", - "stock_quantity": 15 - }, - { - "name": "Yoga Mat", - "description": "Non-slip yoga mat for home workouts", - "price": 29.99, - "category": "Sports", - "image_url": "https://images.unsplash.com/photo-1544367567-0f2fcb009e0b", - "stock_quantity": 100 - }, - { - "name": "Desk Lamp", - "description": "LED desk lamp with adjustable brightness", - "price": 49.99, - "category": "Home", - "image_url": "https://images.unsplash.com/photo-1507473885765-e6ed057f782c", - "stock_quantity": 40 - }, - { - "name": "Bluetooth Speaker", - "description": "Portable Bluetooth speaker with excellent sound quality", - "price": 79.99, - "category": "Electronics", - "image_url": "https://images.unsplash.com/photo-1608043152269-423dbba4e7e1", - "stock_quantity": 60 - }, - { - "name": "Water Bottle", - "description": "Insulated stainless steel water bottle", - "price": 24.99, - "category": "Sports", - "image_url": "https://images.unsplash.com/photo-1602143407151-7111542de6e8", - "stock_quantity": 80 - }, - { - "name": "Book: Python Programming", - "description": "Comprehensive guide to Python programming", - "price": 39.99, - "category": "Books", - "image_url": "https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c", - "stock_quantity": 35 - }, - { - "name": "Gaming Headset", - "description": "High-quality gaming headset with noise cancellation", - "price": 89.99, - "category": "Electronics", - "image_url": "https://images.unsplash.com/photo-1599669454699-248893623440", - "stock_quantity": 25 - }, - { - "name": "Yoga Mat", - "description": "Non-slip premium yoga mat for all exercises", - "price": 29.99, - "category": "Sports", - "image_url": "https://images.unsplash.com/photo-1544367567-0f2fcb009e0b", - "stock_quantity": 40 - }, - { - "name": "Air Fryer", - "description": "Digital air fryer for healthy cooking", - "price": 119.99, - "category": "Kitchen", - "image_url": "https://images.unsplash.com/photo-1618336753974-aae8e04506aa", - "stock_quantity": 15 - }, - { - "name": "Desk Lamp", - "description": "LED desk lamp with adjustable brightness", - "price": 45.99, - "category": "Home", - "image_url": "https://images.unsplash.com/photo-1513475382585-d06e58bcb0e0", - "stock_quantity": 30 - }, - { - "name": "Cookbook: Healthy Meals", - "description": "Collection of nutritious and delicious recipes", - "price": 24.99, - "category": "Books", - "image_url": "https://images.unsplash.com/photo-1481627834876-b7833e8f5570", - "stock_quantity": 45 - }, - { - "name": "Wireless Mouse", - "description": "Ergonomic wireless mouse with long battery life", - "price": 34.99, - "category": "Electronics", - "image_url": "https://images.unsplash.com/photo-1527864550417-7fd91fc51a46", - "stock_quantity": 55 - }, - { - "name": "Running Shoes", - "description": "Lightweight running shoes with superior cushioning", - "price": 129.99, - "category": "Sports", - "image_url": "https://images.unsplash.com/photo-1542291026-7eec264c27ff", - "stock_quantity": 20 - }, - { - "name": "Stand Mixer", - "description": "Professional stand mixer for baking enthusiasts", - "price": 299.99, - "category": "Kitchen", - "image_url": "https://images.unsplash.com/photo-1578662996442-48f60103fc96", - "stock_quantity": 8 - }, - { - "name": "Throw Pillows Set", - "description": "Set of 4 decorative throw pillows", - "price": 49.99, - "category": "Home", - "image_url": "https://images.unsplash.com/photo-1586023492125-27b2c045efd7", - "stock_quantity": 35 - }, - { - "name": "Book: Web Development", - "description": "Modern web development techniques and best practices", - "price": 44.99, - "category": "Books", - "image_url": "https://images.unsplash.com/photo-1555066931-4365d14bab8c", - "stock_quantity": 28 - } - ] - - # Check if products already exist - existing_products = db.query(Product).count() - if existing_products > 0: - print(f"Database already has {existing_products} products. Skipping sample data creation.") - db.close() - return - - # Create products - for product_data in sample_products: - product = Product(**product_data) - db.add(product) - - db.commit() - print(f"Created {len(sample_products)} sample products") - db.close() - -if __name__ == "__main__": - create_tables() - create_sample_products() diff --git a/merchant-backend/merchant.db b/merchant-backend/merchant.db deleted file mode 100644 index d7e78bc..0000000 Binary files a/merchant-backend/merchant.db and /dev/null differ diff --git a/merchant-backend/requirements.txt b/merchant-backend/requirements.txt deleted file mode 100644 index 6a88331..0000000 --- a/merchant-backend/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -sqlalchemy==2.0.23 -python-dotenv -pydantic[email]==2.5.0 -python-multipart>=0.0.18 -python-jose[cryptography]>=3.4.0 -passlib[bcrypt]==1.7.4 -cryptography>=43.0.1 diff --git a/merchant-backend/update_database.py b/merchant-backend/update_database.py deleted file mode 100644 index bb82632..0000000 --- a/merchant-backend/update_database.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -# ยฉ 2025 Visa. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -""" -Script to update the database schema with new columns for the orders table -""" - -import sqlite3 -import os -from pathlib import Path - -# Database file path -DB_PATH = Path(__file__).parent / "merchant.db" - -def update_database(): - """Add new columns to the orders table""" - - if not DB_PATH.exists(): - print("Database file not found. Please run the backend server first to create the database.") - return - - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - - try: - # Check if the new columns already exist - cursor.execute("PRAGMA table_info(orders)") - columns = [column[1] for column in cursor.fetchall()] - - new_columns = [ - ('billing_address', 'TEXT'), - ('payment_method', 'VARCHAR(50)'), - ('billing_different', 'BOOLEAN DEFAULT 0'), - ('card_last_four', 'VARCHAR(4)'), - ('card_brand', 'VARCHAR(20)'), - ('payment_status', 'VARCHAR(20) DEFAULT "pending"') - ] - - for column_name, column_type in new_columns: - if column_name not in columns: - print(f"Adding column: {column_name}") - cursor.execute(f"ALTER TABLE orders ADD COLUMN {column_name} {column_type}") - else: - print(f"Column {column_name} already exists") - - conn.commit() - print("Database schema updated successfully!") - - except sqlite3.Error as e: - print(f"Error updating database: {e}") - conn.rollback() - - finally: - conn.close() - -if __name__ == "__main__": - update_database() diff --git a/merchant-frontend/.env b/merchant-frontend/.env deleted file mode 100644 index e34ef44..0000000 --- a/merchant-frontend/.env +++ /dev/null @@ -1,15 +0,0 @@ -# React Frontend Environment Variables -# API Configuration -# VITE_API_BASE_URL=http://localhost:8000 # Uncomment for production -# In development, we use Vite proxy, so leave this empty or use relative URLs -VITE_API_URL=/api - -# CDN Configuration -VITE_CDN_PROXY_URL=http://localhost:3001 - -# Development Configuration -VITE_DEBUG_MODE=true - -# Application Configuration -VITE_APP_NAME=Merchant Frontend -VITE_APP_VERSION=0.1.0 diff --git a/merchant-frontend/.env.example b/merchant-frontend/.env.example deleted file mode 100644 index ac1e222..0000000 --- a/merchant-frontend/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# React Frontend Environment Variables -# Copy this file to .env and update values for your environment - -# API Configuration -VITE_API_BASE_URL=http://localhost:8000 -VITE_API_URL=/api - -# CDN Configuration -VITE_CDN_PROXY_URL=http://localhost:3001 - -# Development Configuration -VITE_DEBUG_MODE=true - -# Application Configuration -VITE_APP_NAME=Merchant Frontend -VITE_APP_VERSION=0.1.0 diff --git a/merchant-frontend/README.md b/merchant-frontend/README.md deleted file mode 100644 index d4d0497..0000000 --- a/merchant-frontend/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Merchant Frontend - -A sample React e-commerce frontend demonstrating integration with the Trusted Agent Protocol (TAP) for signature-based authentication. - -## Environment Configuration - -Create a `.env` file: - -```bash -VITE_API_BASE_URL=http://localhost:8000 -VITE_CDN_PROXY_URL=http://localhost:3001 -``` - -## Features - -- ๐Ÿ›๏ธ **E-commerce Sample**: Products, shopping cart, and checkout flow -- ๐Ÿ” **TAP Integration**: Works with signature-verified requests -- ๐Ÿ“ฑ **Responsive Design**: Mobile-friendly interface -- ๐Ÿ›’ **Cart Management**: Session-based shopping cart - - -## Quick Start - -```bash -# Install dependencies -npm install - -# Start development server -npm run dev -``` - -Available at http://localhost:3001 - -## Sample Merchant UI -![](../assets/tap-merchant.png) - -> **Note**: Requires Merchant Backend (port 8000) and CDN Proxy (port 3001) - -## Key Pages - -- **Product Catalog** - Browse available products -- **Product Details** - Individual product view with cart functionality -- **Shopping Cart** - Review and manage cart items -- **Checkout** - Complete purchase flow -- **Orders** - Order history and tracking - - -## Technology Stack - -- **React 19** with Vite for fast development -- **React Router** for client-side routing -- **Axios** for API requests -- **Context API** for state management - -## Development - -```bash -# Build for production -npm run build && npm run preview -``` - -## Architecture - -This sample demonstrates: -- Modern React patterns with hooks and context -- Integration with signature-verified APIs -- E-commerce UI/UX best practices -- Error handling and loading states diff --git a/merchant-frontend/index.html b/merchant-frontend/index.html deleted file mode 100644 index 7ff146b..0000000 --- a/merchant-frontend/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - TAP Sample Merchant - - - - -
- - - diff --git a/merchant-frontend/package.json b/merchant-frontend/package.json deleted file mode 100644 index 03e4289..0000000 --- a/merchant-frontend/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "tap-sample-merchant-ui", - "version": "0.1.0", - "_env_example": { - "note": "Create a .env file with: VITE_API_BASE_URL=http://localhost:8000, VITE_CDN_PROXY_URL=http://localhost:3001" - }, - "private": true, - "type": "module", - "dependencies": { - - "axios": "^1.6.2", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": "^6.20.1", - "web-vitals": "^2.1.4" - }, - "devDependencies": { - - "@vitejs/plugin-react": "^5.0.0", - "esbuild": "0.25.10", - "jsdom": "^22.1.0", - "vite": "^5.4.20" - - }, - "scripts": { - "dev": "vite", - "start": "vite", - "build": "vite build", - "preview": "vite preview" - - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "proxy": "http://localhost:8000" -} diff --git a/merchant-frontend/public/favicon.ico b/merchant-frontend/public/favicon.ico deleted file mode 100644 index e69de29..0000000 diff --git a/merchant-frontend/public/index.html b/merchant-frontend/public/index.html deleted file mode 100644 index 42e4c3f..0000000 --- a/merchant-frontend/public/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - Reference Merchant - - - - -
- - diff --git a/merchant-frontend/src/App.jsx b/merchant-frontend/src/App.jsx deleted file mode 100644 index f8f95f3..0000000 --- a/merchant-frontend/src/App.jsx +++ /dev/null @@ -1,78 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React from 'react'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import { ToastProvider } from './context/ToastContext'; -import { CartProvider } from './context/CartContext'; -import Header from './components/Header'; -import ProductsPage from './pages/ProductsPage'; -import ProductDetailsPage from './pages/ProductDetailsPage'; -import CartPage from './pages/CartPage'; -import CheckoutPage from './pages/CheckoutPage'; -import OrderSuccessPage from './pages/OrderSuccessPage'; -import OrdersPage, { OrderDetailPage } from './pages/OrdersPage'; - - -function App() { - return ( - - - -
-
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - -
-
-
-

© 2025 TAP Sample Merchant. All rights reserved.

-
-
-
-
-
-
- ); -} - -const styles = { - app: { - minHeight: '100vh', - backgroundColor: '#f8f9fa', - display: 'flex', - flexDirection: 'column', - }, - main: { - flex: 1, - }, - footer: { - backgroundColor: '#2c3e50', - color: 'white', - padding: '2rem 0', - marginTop: '4rem', - }, - footerContent: { - maxWidth: '1200px', - margin: '0 auto', - padding: '0 1rem', - textAlign: 'center', - }, -}; - -export default App; diff --git a/merchant-frontend/src/components/Header.jsx b/merchant-frontend/src/components/Header.jsx deleted file mode 100644 index e7e7372..0000000 --- a/merchant-frontend/src/components/Header.jsx +++ /dev/null @@ -1,80 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React from 'react'; -import { Link } from 'react-router-dom'; -import { useCart } from '../context/CartContext'; - -const Header = () => { - const { getCartItemCount } = useCart(); - - return ( -
-
- - TAP Sample Merchant - - - -
-
- ); -}; - -const styles = { - header: { - backgroundColor: '#2c3e50', - color: 'white', - padding: '1rem 0', - boxShadow: '0 2px 4px rgba(0,0,0,0.1)', - }, - container: { - maxWidth: '1200px', - margin: '0 auto', - padding: '0 1rem', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }, - logo: { - fontSize: '1.5rem', - fontWeight: 'bold', - color: 'white', - textDecoration: 'none', - }, - nav: { - display: 'flex', - gap: '2rem', - alignItems: 'center', - }, - navLink: { - color: 'white', - textDecoration: 'none', - padding: '0.5rem 1rem', - borderRadius: '4px', - transition: 'background-color 0.3s', - }, - cartLink: { - color: 'white', - textDecoration: 'none', - padding: '0.5rem 1rem', - backgroundColor: '#e74c3c', - borderRadius: '4px', - fontWeight: 'bold', - }, -}; - -export default Header; diff --git a/merchant-frontend/src/components/ProductCard.jsx b/merchant-frontend/src/components/ProductCard.jsx deleted file mode 100644 index 1b72bc7..0000000 --- a/merchant-frontend/src/components/ProductCard.jsx +++ /dev/null @@ -1,139 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useCart } from '../context/CartContext'; - -const ProductCard = ({ product }) => { - const { addToCart } = useCart(); - const navigate = useNavigate(); - const [isHovered, setIsHovered] = useState(false); - - const handleAddToCart = (e) => { - e.stopPropagation(); // Prevent card click navigation - addToCart(product.id, 1); - }; - - const handleCardClick = () => { - navigate(`/product/${product.id}`); - }; - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {product.name} { - e.target.src = '/placeholder/300/200'; - }} - /> -
-

{product.name}

-

{product.description}

-
- {product.category} - Stock: {product.stock_quantity} -
-
- ${product.price.toFixed(2)} - -
-
-
- ); -}; - -const styles = { - card: { - border: '1px solid #ddd', - borderRadius: '8px', - overflow: 'hidden', - backgroundColor: 'white', - boxShadow: '0 2px 4px rgba(0,0,0,0.1)', - transition: 'transform 0.2s, box-shadow 0.2s', - cursor: 'pointer', - }, - cardHovered: { - transform: 'translateY(-2px)', - boxShadow: '0 4px 8px rgba(0,0,0,0.15)', - }, - image: { - width: '100%', - height: '200px', - objectFit: 'cover', - }, - content: { - padding: '1rem', - }, - name: { - margin: '0 0 0.5rem 0', - fontSize: '1.2rem', - fontWeight: 'bold', - color: '#2c3e50', - }, - description: { - margin: '0 0 1rem 0', - color: '#666', - fontSize: '0.9rem', - lineHeight: '1.4', - }, - details: { - display: 'flex', - justifyContent: 'space-between', - marginBottom: '1rem', - fontSize: '0.8rem', - }, - category: { - backgroundColor: '#ecf0f1', - padding: '0.25rem 0.5rem', - borderRadius: '4px', - color: '#2c3e50', - }, - stock: { - color: '#666', - }, - footer: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }, - price: { - fontSize: '1.25rem', - fontWeight: 'bold', - color: '#e74c3c', - }, - addButton: { - backgroundColor: '#3498db', - color: 'white', - border: 'none', - padding: '0.5rem 1rem', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '0.9rem', - transition: 'background-color 0.3s', - }, -}; - -export default ProductCard; diff --git a/merchant-frontend/src/components/SearchFilters.jsx b/merchant-frontend/src/components/SearchFilters.jsx deleted file mode 100644 index 5a7c693..0000000 --- a/merchant-frontend/src/components/SearchFilters.jsx +++ /dev/null @@ -1,169 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React, { useState } from 'react'; - -const SearchFilters = ({ onSearch, onFilter }) => { - const [searchQuery, setSearchQuery] = useState(''); - const [category, setCategory] = useState(''); - const [minPrice, setMinPrice] = useState(''); - const [maxPrice, setMaxPrice] = useState(''); - - const categories = [ - 'Electronics', - 'Sports', - 'Kitchen', - 'Home', - 'Books', - ]; - - const handleSearch = (e) => { - e.preventDefault(); - onSearch(searchQuery); - }; - - const handleFilterChange = () => { - onFilter({ - category: category || undefined, - min_price: minPrice ? parseFloat(minPrice) : undefined, - max_price: maxPrice ? parseFloat(maxPrice) : undefined, - }); - }; - - const clearFilters = () => { - setCategory(''); - setMinPrice(''); - setMaxPrice(''); - onFilter({}); - }; - - return ( -
-
- setSearchQuery(e.target.value)} - style={styles.searchInput} - /> - -
- -
- - - { - setMinPrice(e.target.value); - setTimeout(handleFilterChange, 0); - }} - style={styles.priceInput} - min="0" - step="0.01" - /> - - { - setMaxPrice(e.target.value); - setTimeout(handleFilterChange, 0); - }} - style={styles.priceInput} - min="0" - step="0.01" - /> - - -
-
- ); -}; - -const styles = { - container: { - backgroundColor: 'white', - padding: '1rem', - borderRadius: '8px', - boxShadow: '0 2px 4px rgba(0,0,0,0.1)', - marginBottom: '2rem', - }, - searchForm: { - display: 'flex', - marginBottom: '1rem', - gap: '0.5rem', - }, - searchInput: { - flex: 1, - padding: '0.75rem', - border: '1px solid #ddd', - borderRadius: '4px', - fontSize: '1rem', - }, - searchButton: { - backgroundColor: '#3498db', - color: 'white', - border: 'none', - padding: '0.75rem 1.5rem', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '1rem', - }, - filters: { - display: 'flex', - gap: '1rem', - flexWrap: 'wrap', - alignItems: 'center', - }, - select: { - padding: '0.5rem', - border: '1px solid #ddd', - borderRadius: '4px', - fontSize: '0.9rem', - }, - priceInput: { - padding: '0.5rem', - border: '1px solid #ddd', - borderRadius: '4px', - fontSize: '0.9rem', - width: '120px', - }, - clearButton: { - backgroundColor: '#95a5a6', - color: 'white', - border: 'none', - padding: '0.5rem 1rem', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '0.9rem', - }, -}; - -export default SearchFilters; diff --git a/merchant-frontend/src/components/Toast.jsx b/merchant-frontend/src/components/Toast.jsx deleted file mode 100644 index e935c3c..0000000 --- a/merchant-frontend/src/components/Toast.jsx +++ /dev/null @@ -1,133 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React, { useEffect } from 'react'; - -const Toast = ({ message, type = 'success', onClose, duration = 3000 }) => { - useEffect(() => { - if (duration > 0) { - const timer = setTimeout(() => { - onClose(); - }, duration); - - return () => clearTimeout(timer); - } - }, [onClose, duration]); - - const getToastStyles = () => { - const baseStyles = { - position: 'fixed', - top: '20px', - right: '20px', - padding: '1rem 1.5rem', - borderRadius: '8px', - color: 'white', - fontWeight: '500', - fontSize: '0.9rem', - zIndex: 9999, - maxWidth: '400px', - boxShadow: '0 4px 12px rgba(0,0,0,0.2)', - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - animation: 'slideInRight 0.3s ease-out', - cursor: 'pointer', - }; - - const typeStyles = { - success: { - backgroundColor: '#27ae60', - borderLeft: '4px solid #2ecc71', - }, - error: { - backgroundColor: '#e74c3c', - borderLeft: '4px solid #c0392b', - }, - warning: { - backgroundColor: '#f39c12', - borderLeft: '4px solid #e67e22', - }, - info: { - backgroundColor: '#3498db', - borderLeft: '4px solid #2980b9', - }, - }; - - return { ...baseStyles, ...typeStyles[type] }; - }; - - const getIcon = () => { - switch (type) { - case 'success': - return 'โœ“'; - case 'error': - return 'โœ•'; - case 'warning': - return 'โš '; - case 'info': - return 'โ„น'; - default: - return 'โœ“'; - } - }; - - return ( - <> - -
- {getIcon()} - {message} - -
- - ); -}; - -export default Toast; diff --git a/merchant-frontend/src/context/CartContext.jsx b/merchant-frontend/src/context/CartContext.jsx deleted file mode 100644 index 77588f5..0000000 --- a/merchant-frontend/src/context/CartContext.jsx +++ /dev/null @@ -1,206 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React, { createContext, useContext, useReducer, useEffect } from 'react'; -import { cartAPI } from '../services/api'; -import { useToast } from './ToastContext'; - -const CartContext = createContext(); - -const initialState = { - cart: null, - sessionId: null, - loading: false, - error: null, -}; - -function cartReducer(state, action) { - switch (action.type) { - case 'SET_LOADING': - return { ...state, loading: action.payload }; - case 'SET_ERROR': - return { ...state, error: action.payload, loading: false }; - case 'SET_CART': - return { ...state, cart: action.payload, loading: false, error: null }; - case 'SET_SESSION_ID': - return { ...state, sessionId: action.payload }; - case 'CLEAR_CART': - return { ...state, cart: null }; - default: - return state; - } -} - -export function CartProvider({ children }) { - const [state, dispatch] = useReducer(cartReducer, initialState); - const { showSuccess, showError } = useToast(); - - // Initialize cart session - useEffect(() => { - const initializeCart = async () => { - let sessionId = localStorage.getItem('cartSessionId'); - - if (!sessionId) { - try { - const response = await cartAPI.createCart(); - sessionId = response.data.session_id; - localStorage.setItem('cartSessionId', sessionId); - } catch (error) { - console.error('Failed to create cart:', error); - dispatch({ type: 'SET_ERROR', payload: 'Failed to initialize cart' }); - return; - } - } - - dispatch({ type: 'SET_SESSION_ID', payload: sessionId }); - await loadCart(sessionId); - }; - - initializeCart(); - }, []); - - const loadCart = async (sessionId) => { - dispatch({ type: 'SET_LOADING', payload: true }); - try { - const response = await cartAPI.getCart(sessionId); - dispatch({ type: 'SET_CART', payload: response.data }); - } catch (error) { - console.error('Failed to load cart:', error); - dispatch({ type: 'SET_ERROR', payload: 'Failed to load cart' }); - } - }; - - const addToCart = async (productId, quantity = 1) => { - if (!state.sessionId) { - showError('Cart session not initialized'); - return; - } - - dispatch({ type: 'SET_LOADING', payload: true }); - - // Add timeout to prevent hanging - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Request timeout')), 15000) - ); - - try { - console.log('๐Ÿ›’ Adding to cart:', { sessionId: state.sessionId, productId, quantity }); - - const response = await Promise.race([ - cartAPI.addItemToCart(state.sessionId, { - product_id: productId, - quantity: quantity, - }), - timeoutPromise - ]); - - console.log('๐Ÿ›’ Cart response:', response.data); - dispatch({ type: 'SET_CART', payload: response.data }); - - // Show success toast - const productName = response.data.items.find(item => item.product.id === productId)?.product.name || 'Product'; - showSuccess(`${productName} added to cart!`); - } catch (error) { - console.error('๐Ÿ”ด Failed to add item to cart:', error); - - let errorMessage = 'Failed to add item to cart'; - if (error.message === 'Request timeout') { - errorMessage = 'Request timed out. Please try again.'; - } else if (error.response?.status === 404) { - errorMessage = 'Product not found'; - } else if (error.response?.status === 500) { - errorMessage = 'Server error. Please try again later.'; - } - - dispatch({ type: 'SET_ERROR', payload: errorMessage }); - showError(errorMessage); - } - }; - - const updateCartItem = async (productId, quantity) => { - if (!state.sessionId) return; - - dispatch({ type: 'SET_LOADING', payload: true }); - try { - const response = await cartAPI.updateCartItem(state.sessionId, productId, quantity); - dispatch({ type: 'SET_CART', payload: response.data }); - showSuccess('Cart updated'); - } catch (error) { - console.error('Failed to update cart item:', error); - dispatch({ type: 'SET_ERROR', payload: 'Failed to update cart item' }); - showError('Failed to update cart item'); - } - }; - - const removeFromCart = async (productId) => { - if (!state.sessionId) return; - - dispatch({ type: 'SET_LOADING', payload: true }); - try { - await cartAPI.removeItemFromCart(state.sessionId, productId); - await loadCart(state.sessionId); - showSuccess('Item removed from cart'); - } catch (error) { - console.error('Failed to remove item from cart:', error); - dispatch({ type: 'SET_ERROR', payload: 'Failed to remove item from cart' }); - showError('Failed to remove item from cart'); - } - }; - - const clearCart = async () => { - if (!state.sessionId) return; - - dispatch({ type: 'SET_LOADING', payload: true }); - try { - await cartAPI.clearCart(state.sessionId); - dispatch({ type: 'CLEAR_CART' }); - showSuccess('Cart cleared'); - } catch (error) { - console.error('Failed to clear cart:', error); - dispatch({ type: 'SET_ERROR', payload: 'Failed to clear cart' }); - showError('Failed to clear cart'); - } - }; - - const getCartTotal = () => { - if (!state.cart || !state.cart.items) return 0; - return state.cart.items.reduce((total, item) => { - return total + (item.product.price * item.quantity); - }, 0); - }; - - const getCartItemCount = () => { - if (!state.cart || !state.cart.items) return 0; - return state.cart.items.reduce((total, item) => total + item.quantity, 0); - }; - - const value = { - ...state, - addToCart, - updateCartItem, - removeFromCart, - clearCart, - getCartTotal, - getCartItemCount, - }; - - return ( - - {children} - - ); -} - -export function useCart() { - const context = useContext(CartContext); - if (!context) { - throw new Error('useCart must be used within a CartProvider'); - } - return context; -} diff --git a/merchant-frontend/src/context/ToastContext.jsx b/merchant-frontend/src/context/ToastContext.jsx deleted file mode 100644 index 965bb28..0000000 --- a/merchant-frontend/src/context/ToastContext.jsx +++ /dev/null @@ -1,91 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React, { createContext, useContext, useState } from 'react'; -import Toast from '../components/Toast'; - -const ToastContext = createContext(); - -export function ToastProvider({ children }) { - const [toasts, setToasts] = useState([]); - - const showToast = (message, type = 'success', duration = 3000) => { - const id = Date.now() + Math.random(); - const newToast = { - id, - message, - type, - duration, - }; - - setToasts(prev => [...prev, newToast]); - - // Auto remove after duration - if (duration > 0) { - setTimeout(() => { - removeToast(id); - }, duration); - } - - return id; - }; - - const removeToast = (id) => { - setToasts(prev => prev.filter(toast => toast.id !== id)); - }; - - const showSuccess = (message, duration) => showToast(message, 'success', duration); - const showError = (message, duration) => showToast(message, 'error', duration); - const showWarning = (message, duration) => showToast(message, 'warning', duration); - const showInfo = (message, duration) => showToast(message, 'info', duration); - - const value = { - showToast, - showSuccess, - showError, - showWarning, - showInfo, - removeToast, - }; - - return ( - - {children} - - {/* Render toasts */} -
- {toasts.map((toast) => ( - removeToast(toast.id)} - duration={0} // Duration is handled by the context - /> - ))} -
-
- ); -} - -export function useToast() { - const context = useContext(ToastContext); - if (!context) { - throw new Error('useToast must be used within a ToastProvider'); - } - return context; -} diff --git a/merchant-frontend/src/index.jsx b/merchant-frontend/src/index.jsx deleted file mode 100644 index 41dc672..0000000 --- a/merchant-frontend/src/index.jsx +++ /dev/null @@ -1,19 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App.jsx'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); diff --git a/merchant-frontend/src/pages/CartPage.jsx b/merchant-frontend/src/pages/CartPage.jsx deleted file mode 100644 index e10ea51..0000000 --- a/merchant-frontend/src/pages/CartPage.jsx +++ /dev/null @@ -1,274 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useCart } from '../context/CartContext'; - -const CartPage = () => { - const { cart, updateCartItem, removeFromCart, clearCart, getCartTotal } = useCart(); - const navigate = useNavigate(); - - const handleQuantityChange = (productId, newQuantity) => { - if (newQuantity > 0) { - updateCartItem(productId, newQuantity); - } - }; - - const handleRemoveItem = (productId) => { - removeFromCart(productId); - }; - - const handleProceedToCheckout = () => { - navigate('/checkout'); - }; - - if (!cart || !cart.items || cart.items.length === 0) { - return ( -
-

Your Cart

-
-

Your cart is empty

- -
-
- ); - } - - return ( -
-

Your Cart

- -
-
- {cart.items.map(item => ( -
- {item.product.name} -
-

{item.product.name}

-

${item.product.price.toFixed(2)} each

-
-
- - {item.quantity} - -
-
- ${(item.product.price * item.quantity).toFixed(2)} -
- -
- ))} - -
- -
-
- -
-
-

Order Summary

-
- Total: ${getCartTotal().toFixed(2)} -
-
- Taxes, Discounts and shipping calculated at checkout -
- -
-
-
-
- ); -}; - -const styles = { - container: { - maxWidth: '1200px', - margin: '0 auto', - padding: '2rem 1rem', - }, - title: { - fontSize: '2rem', - marginBottom: '2rem', - color: '#2c3e50', - }, - emptyCart: { - textAlign: 'center', - padding: '3rem', - backgroundColor: '#f8f9fa', - borderRadius: '8px', - }, - shopButton: { - backgroundColor: '#3498db', - color: 'white', - border: 'none', - padding: '0.75rem 1.5rem', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '1rem', - marginTop: '1rem', - }, - cartContent: { - display: 'grid', - gridTemplateColumns: '2fr 1fr', - gap: '2rem', - }, - cartItems: { - backgroundColor: 'white', - borderRadius: '8px', - padding: '1rem', - boxShadow: '0 2px 4px rgba(0,0,0,0.1)', - }, - cartItem: { - display: 'flex', - alignItems: 'center', - padding: '1rem', - borderBottom: '1px solid #eee', - gap: '1rem', - }, - itemImage: { - width: '80px', - height: '80px', - objectFit: 'cover', - borderRadius: '4px', - }, - itemDetails: { - flex: 1, - }, - itemName: { - margin: '0 0 0.5rem 0', - fontSize: '1.1rem', - color: '#2c3e50', - }, - itemPrice: { - margin: 0, - color: '#666', - }, - quantityControls: { - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - }, - quantityButton: { - width: '32px', - height: '32px', - borderRadius: '50%', - border: '1px solid #ddd', - backgroundColor: 'white', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - quantity: { - minWidth: '40px', - textAlign: 'center', - fontSize: '1.1rem', - fontWeight: 'bold', - }, - itemTotal: { - fontSize: '1.2rem', - fontWeight: 'bold', - color: '#e74c3c', - minWidth: '80px', - textAlign: 'right', - }, - removeButton: { - backgroundColor: '#e74c3c', - color: 'white', - border: 'none', - padding: '0.5rem 1rem', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '0.9rem', - }, - cartActions: { - padding: '1rem', - textAlign: 'right', - borderTop: '1px solid #eee', - }, - clearButton: { - backgroundColor: '#95a5a6', - color: 'white', - border: 'none', - padding: '0.5rem 1rem', - borderRadius: '4px', - cursor: 'pointer', - }, - checkoutSection: { - backgroundColor: 'white', - borderRadius: '8px', - padding: '1rem', - boxShadow: '0 2px 4px rgba(0,0,0,0.1)', - height: 'fit-content', - }, - orderSummary: { - paddingBottom: '1rem', - borderBottom: '1px solid #eee', - }, - totalAmount: { - fontSize: '1.5rem', - fontWeight: 'bold', - color: '#e74c3c', - marginTop: '1rem', - marginBottom: '0.5rem', - }, - disclaimer: { - fontSize: '0.8rem', - color: '#666', - fontStyle: 'italic', - marginBottom: '1.5rem', - textAlign: 'left', - }, - checkoutButton: { - backgroundColor: '#27ae60', - color: 'white', - border: 'none', - padding: '1rem 2rem', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '1.1rem', - fontWeight: 'bold', - width: '100%', - }, -}; - -export default CartPage; diff --git a/merchant-frontend/src/pages/CheckoutPage.jsx b/merchant-frontend/src/pages/CheckoutPage.jsx deleted file mode 100644 index 69bff0b..0000000 --- a/merchant-frontend/src/pages/CheckoutPage.jsx +++ /dev/null @@ -1,809 +0,0 @@ -/* ยฉ 2025 Visa. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useCart } from '../context/CartContext'; -import { ordersAPI } from '../services/api'; - -const CheckoutPage = () => { - const { cart, sessionId, getCartTotal, clearCart } = useCart(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const [formData, setFormData] = useState({ - // Contact Information - email: '', - phone: '', - - // Shipping Address - firstName: '', - lastName: '', - company: '', - address1: '', - address2: '', - city: '', - state: '', - zipCode: '', - country: 'United States', - - // Billing Address - billingDifferent: false, - billingFirstName: '', - billingLastName: '', - billingCompany: '', - billingAddress1: '', - billingAddress2: '', - billingCity: '', - billingState: '', - billingZipCode: '', - billingCountry: 'United States', - - // Payment - paymentMethod: 'credit_card', - cardNumber: '', - expiryDate: '', - cvv: '', - nameOnCard: '', - - // Additional Options - specialInstructions: '', - newsletter: false, - }); - - // Redirect if cart is empty - React.useEffect(() => { - if (!cart || !cart.items || cart.items.length === 0) { - navigate('/cart'); - } - }, [cart, navigate]); - - const handleInputChange = (e) => { - const { name, value, type, checked } = e.target; - setFormData(prev => ({ - ...prev, - [name]: type === 'checkbox' ? checked : value - })); - }; - - const validateForm = () => { - const required = ['email', 'firstName', 'lastName', 'address1', 'city', 'state', 'zipCode']; - - // Add billing address fields if billing is different - if (formData.billingDifferent) { - required.push('billingFirstName', 'billingLastName', 'billingAddress1', 'billingCity', 'billingState', 'billingZipCode'); - } - - // Add payment fields - required.push('cardNumber', 'expiryDate', 'cvv', 'nameOnCard'); - - const missing = required.filter(field => !formData[field].trim()); - - if (missing.length > 0) { - setError(`Please fill in the following required fields: ${missing.join(', ')}`); - return false; - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(formData.email)) { - setError('Please enter a valid email address'); - return false; - } - - // Basic card number validation (remove spaces and check length) - const cardNumber = formData.cardNumber.replace(/\s/g, ''); - if (cardNumber.length < 13 || cardNumber.length > 19) { - setError('Please enter a valid card number'); - return false; - } - - // Expiry date validation (MM/YY or MM/YYYY format) - const expiryRegex = /^(0[1-9]|1[0-2])\/([0-9]{2}|[0-9]{4})$/; - if (!expiryRegex.test(formData.expiryDate)) { - setError('Please enter expiry date in MM/YY format'); - return false; - } - - // CVV validation - const cvv = formData.cvv.trim(); - if (cvv.length < 3 || cvv.length > 4) { - setError('Please enter a valid CVV (3-4 digits)'); - return false; - } - - return true; - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - - if (!validateForm()) { - return; - } - - setLoading(true); - setError(null); - - try { - // Format shipping address - const shippingAddress = `${formData.firstName} ${formData.lastName}\n${formData.company ? formData.company + '\n' : ''}${formData.address1}\n${formData.address2 ? formData.address2 + '\n' : ''}${formData.city}, ${formData.state} ${formData.zipCode}\n${formData.country}`; - - // Format billing address if different - let billingAddress = null; - if (formData.billingDifferent) { - billingAddress = `${formData.billingFirstName} ${formData.billingLastName}\n${formData.billingCompany ? formData.billingCompany + '\n' : ''}${formData.billingAddress1}\n${formData.billingAddress2 ? formData.billingAddress2 + '\n' : ''}${formData.billingCity}, ${formData.billingState} ${formData.billingZipCode}\n${formData.billingCountry}`; - } - - const checkoutData = { - customer_name: `${formData.firstName} ${formData.lastName}`, - customer_email: formData.email, - shipping_address: shippingAddress, - billing_address: billingAddress, - phone: formData.phone, - special_instructions: formData.specialInstructions || null, - payment_method: formData.paymentMethod, - billing_different: formData.billingDifferent, - // Payment information - card_number: formData.cardNumber, - expiry_date: formData.expiryDate, - cvv: formData.cvv, - name_on_card: formData.nameOnCard, - // Additional contact info - newsletter: formData.newsletter, - // Full form data for reference - form_data: formData - }; - - const response = await ordersAPI.checkout(sessionId, checkoutData); - - // Clear the cart after successful checkout - await clearCart(); - - // Navigate to success page with order details - navigate('/order-success', { - state: { - order: response.data.order, - payment: response.data.payment, - customerInfo: formData - } - }); - - } catch (err) { - setError('Failed to process your order. Please try again.'); - console.error('Checkout error:', err); - } finally { - setLoading(false); - } - }; - - if (!cart || !cart.items || cart.items.length === 0) { - return null; // Will redirect via useEffect - } - - const subtotal = getCartTotal(); - const shipping = 9.99; // Fixed shipping for demo - const tax = subtotal * 0.08; // 8% tax - const total = subtotal + shipping + tax; - - return ( -
-
- {/* Header */} -
- -

Checkout

-
- -
- {/* Checkout Form */} -
-
- {error &&
{error}
} - - {/* Contact Information */} -
-

Contact Information

-
- - -
- -
- - {/* Shipping Address */} -
-

Shipping Address

-
- - -
- - - -
- - - -
- -
- - {/* Shipping Method */} -
-

Shipping Method

-
- -
-
- - {/* Payment */} -
-

Payment

-
- -
- - {formData.paymentMethod === 'credit_card' && ( -
- -
- - -
- -
- )} -
- - {/* Billing Address */} -
- - - {formData.billingDifferent && ( -
-

Billing Address

-
- - -
- - - -
- - - -
- -
- )} -
- - {/* Special Instructions */} -
-

Special Instructions

-