Samples / September 14, 2026
Original Idea

"I want to make a website like Airbnb. People should be able to list their spaces for rent, and others should be able to search for places, pick dates on a calendar, and pay to book them."

Rentify

Target: Cursor & AWS Serverless Category: Two-Sided Marketplace Downstream: Agentic SDD
D
David

Architecture Design Document

Architecture Design Document

TL;DR

"The takeaway is that this architecture uses a PostgreSQL GiST exclusion constraint and Stripe Connect to mathematically prevent double-bookings and safely route funds in a serverless environment."

Rentify is a two-sided short-term rental marketplace where hosts list homes or rooms and guests search, book, and pay for overnight stays. It runs entirely on AWS Serverless (Lambda, API Gateway, RDS Proxy, PostgreSQL) with Stripe Connect handling all payment routing. The two most important architectural choices are an atomic database-level availability lock that makes double-bookings physically impossible, and a Stripe Connect escrow model that holds guest funds and releases them to hosts only after check-in.

Executive Summary

Rentify is built as a serverless REST API backend consumed by a web frontend. Every request flows through AWS API Gateway to a Node.js Lambda function layer, which connects to PostgreSQL via RDS Proxy. The RDS Proxy pool is the critical bridge between the stateless Lambda execution environment and the stateful relational database — it prevents connection exhaustion at scale.

The core business logic is split into two domains: Listing Management (host-side CRUD, calendar rules, pricing overrides, photo ordering) and Booking & Payments (guest-side search, reservation flow, Stripe Connect fund routing). These domains are implemented as logical service modules within the Lambda layer, not as separate deployed functions per route — this keeps cold-start surface area small and simplifies local development under SDD/Cursor.

The escrow model is enforced entirely via Stripe: a PaymentIntent is created and captured at booking time, held in the platform's Stripe account, and transferred to the host's connected account after the check-in timestamp passes. Host cancellations trigger an unconditional full refund with no operator intervention required. Guest-side service fees are captured as Stripe's application fee on the charge.

CCPA compliance is handled via a soft-delete + data-scrubbing flow: user records are flagged for deletion, PII fields are overwritten with null values on a scheduled Lambda, and booking history is retained in anonymized form for the 3-year retention window. PCI DSS scope is SAQ A — no raw card data ever touches the application layer.

The system targets hundreds of users in a single AWS region with cost optimization as the primary scaling constraint. Three environments (dev, staging, production) are deployed via GitHub Actions with environment-specific parameter sets stored in AWS SSM Parameter Store.

System Overview

Architecture Diagram

graph LR
    subgraph client["Client Layer"]
        browser["Web Browser"]
        react["React 18 SPA"]
    end

    subgraph cdn["CDN & Edge"]
        cf["CloudFront"]
        s3web["S3 - Static Hosting"]
    end

    subgraph apigw["API Layer"]
        agw["API Gateway REST"]
        auth_lam["Lambda Authorizer"]
    end

    subgraph compute["Lambda Functions"]
        authFn["Auth Lambda"]
        listFn["Listings Lambda"]
        searchFn["Search Lambda"]
        bookFn["Bookings Lambda"]
        subgraph scheduled["Scheduled Lambdas"]
            payoutFn["Payout Scheduler"]
            ccpaFn["CCPA Scrubber"]
        end
        webhookFn["Payments Webhook"]
        userFn["Users Lambda"]
        adminFn["Admin Lambda"]
    end

    subgraph data["Data Layer"]
        proxy["RDS Proxy"]
        rds["PostgreSQL 15 RDS"]
    end

    subgraph storage["Storage & Config"]
        s3photos["S3 - Photos & Exports"]
        ssm["SSM Parameter Store"]
    end

    subgraph external["External Services"]
        stripe["Stripe Connect"]
        ses["AWS SES"]
        cw["CloudWatch Logs"]
        eb["EventBridge Scheduler"]
        sns["SNS - Alerting"]
    end

    browser --> react
    react --> cf
    cf --> s3web
    react --> agw
    agw --> auth_lam
    auth_lam --> agw
    agw --> authFn
    agw --> listFn
    agw --> searchFn
    agw --> bookFn
    agw --> webhookFn
    agw --> userFn
    agw --> adminFn
    eb --> payoutFn
    eb --> ccpaFn
    authFn --> proxy
    listFn --> proxy
    searchFn --> proxy
    bookFn --> proxy
    webhookFn --> proxy
    userFn --> proxy
    adminFn --> proxy
    payoutFn --> proxy
    ccpaFn --> proxy
    proxy --> rds
    listFn --> s3photos
    ccpaFn --> s3photos
    userFn --> s3photos
    authFn --> ssm
    bookFn --> ssm
    webhookFn --> ssm
    bookFn --> stripe
    payoutFn --> stripe
    webhookFn --> stripe
    listFn --> stripe
    stripe --> agw
    bookFn --> ses
    payoutFn --> ses
    ccpaFn --> ses
    authFn --> cw
    bookFn --> cw
    payoutFn --> cw
    ccpaFn --> cw
    cw --> sns
    sns --> ses

The system has five logical tiers:

  1. Client layer — A React single-page application hosted on S3 + CloudFront. It communicates exclusively with API Gateway over HTTPS. No direct AWS service calls from the browser.

  2. API Gateway — A single REST API with JWT authorizer (via a Lambda authorizer function). Routes requests to the correct Lambda handler. Throttling and CORS are configured at this layer.

  3. Lambda compute layer — Node.js functions organized into handler modules: Auth, Listings, Search, Bookings, Payments (webhooks), Users (profile + CCPA), and Admin. Each module is a separate Lambda function to allow independent scaling and deployment. They share a common database client layer and a shared utilities package (deployed as a Lambda Layer).

  4. Data layer — PostgreSQL 15 on AWS RDS (single db.t3.medium instance per environment), accessed exclusively through RDS Proxy. The Proxy manages the connection pool and provides IAM authentication to the Lambda functions, eliminating database credentials from function environment variables.

  5. External integrations — Stripe Connect (payments, payouts, webhooks) and AWS-native services: SES for transactional email, CloudWatch for structured logging and alerting, S3 for listing photo storage, SSM Parameter Store for secrets.

Data flows directionally: a guest's search request hits API Gateway → Search Lambda → RDS Proxy → PostgreSQL (read). A booking request hits API Gateway → Bookings Lambda → begins a database transaction that acquires a row-level lock on the availability record, then calls Stripe to create and capture the PaymentIntent, then commits the booking row — all within the same logical operation. If Stripe fails, the transaction rolls back and no booking record is created.

Architectural Constraints Summary

Serverless on AWS is non-negotiable. This drove the RDS Proxy requirement — Lambda's ephemeral execution model would exhaust PostgreSQL's connection limit within minutes without a connection pooler in front of it. Every component outside the database and S3 is stateless by design.

Atomic availability locking is the hardest business rule in the system. PostgreSQL's GiST Exclusion Constraint using the && overlap operator on a tsrange column is the mechanism. This cannot be handled at the application layer alone — application-level pessimistic locking is vulnerable to external scripts and administrative operations, whereas the GiST constraint provides an immutable database-level invariant against double bookings so that concurrent Lambda invocations cannot possibly proceed with overlapping slots.

Stripe Connect escrow drives the entire payment data model. The platform never stores card data (SAQ A scope), but it does store Stripe entity references: PaymentIntent IDs, Transfer IDs, and connected account IDs. These are stored in the booking and payout records in PostgreSQL.

CCPA compliance adds a data lifecycle requirement that conflicts slightly with the 3-year retention rule. The resolution: a booking row is never deleted, but all PII columns on the user record (name, email, phone, profile photo) are scrubbed. The booking row retains only anonymized financial and calendar data, which satisfies both the retention requirement (platform revenue records) and the deletion right (no personal data remains traceable to the user).

Cost optimization at small scale means a single RDS instance per environment (not Multi-AZ in dev/staging), a single Lambda function per logical domain (not per route), and S3 + CloudFront for the frontend rather than a dedicated server. Multi-AZ is enabled only in production to meet the under-1-hour unplanned downtime target.

Single account with role toggle means the user table carries both host and guest attributes, and a roles array field determines what the user can do. No separate user tables — just a role set that is additive (a user becomes a host by onboarding onto Stripe Connect, not by creating a new account).

Component Architecture

API Gateway

A single REST API deployed per environment. The JWT Lambda Authorizer runs before every protected route, validates the token, and injects the decoded user ID and roles into the Lambda event context. Public routes (search, listing detail, auth) bypass the authorizer. Throttling is set to 1,000 requests per second burst, 500 steady-state per environment — sized for small-scale and adjustable via SSM. CORS is configured at API Gateway level with an explicit allowed-origins list per environment.

Lambda Authorizer

Validates JWTs signed by the Auth Lambda using a shared HMAC secret stored in SSM. Caches authorization results for 5 minutes to reduce cold overhead. On failure, returns a deny policy — the calling function never executes.

Auth Lambda

Handles user registration, login, and token refresh. On registration, creates the user record in PostgreSQL with a guest role by default. Issues short-lived access tokens (15-minute expiry) and long-lived refresh tokens (30-day expiry) stored in the refresh_tokens table. Password hashing uses bcrypt with a work factor of 12. No third-party identity provider — credentials are managed in-house.

Listings Lambda

All host-side listing operations: create, read, update, delete a listing; manage the availability calendar (blocked dates, minimum stay, allowed check-in days); manage per-night pricing with weekend and seasonal override rules; manage photos (upload URL generation, ordering, deletion); manage amenities and house rules; toggle instant-book vs. manual approval. Photo uploads use S3 pre-signed URLs — the Lambda generates the URL and returns it to the client; the client uploads directly to S3. The Lambda then records the S3 object key and display order in the listing_photos table.

Availability rule evaluation logic: When a host sets availability rules, they are stored as structured records (blocked date ranges, minimum-stay integers, allowed check-in weekday bitmask). When the Bookings Lambda evaluates a booking request, it queries these rules and applies them in sequence: (1) check that all requested nights fall outside blocked ranges, (2) check that the stay length meets the minimum, (3) check that the check-in day is permitted. All three must pass before the atomic lock is attempted.

Pricing calculation logic: The nightly rate for a stay is calculated by iterating each calendar night in the requested range and looking up applicable overrides in priority order: (1) seasonal override if the date falls within a defined season range, (2) weekend override if the date is Friday or Saturday, (3) base nightly rate. The sum of all per-night rates plus the guest service fee (a fixed percentage stored in SSM, default 12%) forms the total charge amount passed to Stripe.

Search Lambda

Handles guest-side listing discovery. Accepts filters: destination (matched against a text location field using PostgreSQL ILIKE), check-in/check-out dates, guest count, and price range. Returns paginated listing summaries with availability pre-checked against the bookings table. Search is intentionally simple — no geospatial indexing, no Elasticsearch. A composite PostgreSQL index on (location, max_guests, base_price) covers the filter columns. Availability is checked by a subquery that excludes listing IDs with any confirmed booking overlapping the requested dates. Default page size is 20 listings; maximum is 100.

Bookings Lambda

The most complex component. Handles the full reservation lifecycle:

Booking creation (instant-book path):

  1. Validate the requested dates pass availability rule evaluation (Listings logic, called as an internal module).
  2. Open a PostgreSQL serializable transaction.
  3. Attempt to insert the booking row. The PostgreSQL GiST exclusion constraint on the bookings table (tsrange(check_in_date, check_out_date) WITH &&) automatically enforces the lock. If an overlap exists, it fails with a constraint violation error returned to the guest.
  4. Check that no confirmed booking overlaps the requested dates (double-check inside the lock).
  5. Insert a bookings row in pending status.
  6. Call Stripe to create and capture a PaymentIntent for the total amount with the platform application fee. If Stripe returns success, update the booking status to confirmed and commit. If Stripe fails, roll back — no booking row is persisted.
  7. Send a confirmation email via SES.

Booking creation (manual approval path): Steps 1–5 above, then instead of capturing payment, create a Stripe PaymentIntent in requires_capture state (authorized but not settled). The booking remains in pending_approval status. The host has 24 hours to accept or decline via the Bookings Lambda's approval endpoint. On acceptance, the PaymentIntent is captured and the booking moves to confirmed. On decline or timeout (a scheduled Lambda checks for expired approvals every 15 minutes), the PaymentIntent is cancelled and the booking moves to declined, freeing the availability lock.

Cancellation (host-initiated): Update booking to cancelled_by_host. Immediately call Stripe to issue a full refund against the captured PaymentIntent. Send email to guest. No operator approval required.

Cancellation (guest-initiated): Apply the host's cancellation policy stored on the listing (flexible, moderate, or strict — stored as an enum). Calculate refund amount based on policy and days until check-in. Call Stripe refund API for the calculated amount. Update booking status.

Post-check-in payout: A scheduled Lambda (EventBridge rule, runs every hour) queries for confirmed bookings where check_in_date < NOW() and payout_status = pending. For each, it calls Stripe to create a Transfer from the platform account to the host's connected Stripe account for the host payout amount (total charge minus application fee). Updates payout_status to completed and records the Transfer ID.

Payments Lambda (Webhook Handler)

Receives Stripe webhook events. Validates the webhook signature using the Stripe signing secret stored in SSM. Handles: payment_intent.succeeded (confirms booking if not already confirmed), payment_intent.payment_failed (moves booking to payment_failed status), transfer.failed (logs and triggers a Slack/email alert for manual operator review), account.updated (updates host's Stripe account verification status in the database). Webhook endpoint is unauthenticated at the API Gateway level but signature-validated at the function level.

Users Lambda

Handles profile management and CCPA flows. CCPA deletion: marks the user record with deletion_requested_at timestamp and deletion_status = pending. A scheduled Lambda (EventBridge, runs nightly) finds all users with deletion_status = pending and deletion_requested_at older than 30 days (grace period), scrubs all PII fields on the user record, updates deletion_status = completed. Booking rows with that user's ID are retained but the foreign key to the user record points to a scrubbed shell. CCPA opt-out (sale/sharing of data): sets a do_not_sell boolean on the user record; this flag is checked before any data is included in any analytics or third-party export.

Admin Lambda

Internal-only endpoints accessible only to users with the admin or support roles. Admin capabilities: view all users, listings, and bookings; manually trigger a payout retry; flag a listing as inactive; impersonate a user (read-only context for support agents). Support agents have read-only access — all write operations in the Admin Lambda check that the caller's role is admin, not support.

Payout Scheduler Lambda

An EventBridge-triggered Lambda (every hour) that handles post-check-in payout initiation. Separate from the Bookings Lambda to isolate the scheduled execution context from the request-response context. Idempotent — it checks payout_status before calling Stripe, so re-runs on the same booking are safe.

CCPA Scrubber Lambda

An EventBridge-triggered Lambda (nightly at 02:00 UTC) that performs PII scrubbing for users past their deletion grace period. Also handles a data export request flow (CCPA right-to-know): when a user requests their data export, this Lambda assembles the export payload from PostgreSQL, writes it to a time-limited S3 pre-signed URL, and emails the link to the user via SES.

Data Architecture

Core Tables

users — One row per account. Stores hashed password, email, display name, phone, profile photo S3 key, roles array (guest, host, admin, support), Stripe connected account ID (null until host onboarding), deletion_requested_at, deletion_status, do_not_sell flag. Soft-deleted via PII scrubbing, never hard-deleted.

refresh_tokens — One row per active refresh token. Stores a hashed token value, the user ID, expiry timestamp, and a revoked flag. Cleaned up by a nightly job that removes expired rows.

listings — One row per listing. References the host user ID. Stores location text, description, max guests, property type enum (entire_home, room), base nightly price, instant-book boolean, cancellation policy enum, status enum (draft, active, inactive). Never hard-deleted — status set to inactive on deletion.

listing_photos — One row per photo. References listing ID. Stores S3 object key, display order integer, alt text.

listing_amenities — Junction table: listing ID × amenity enum value.

Data Contracts

Entity Attribute Type / Constraints Business Rule & Access Control
availability_rules rule_type ENUM ('blocked_range', 'min_stay', 'checkin_days') Defines how rules apply to listing availability. Hosts only.
availability_rules start_date / end_date DATE Applies to blocked_range. Checked before booking.
availability_rules value / bitmask INT / BIT(7) Stores min nights or check-in days mask.
bookings listing_id / guest_user_id UUID, FK Links guest to listing.
bookings stay_range tsrange, GiST Exclusion Constraint (&&) Guarantees atomic prevention of overlapping bookings at the database schema level.
bookings status ENUM ('pending', 'confirmed', 'declined'...) Managed strictly by Lambda flows.
bookings stripe_pi_id VARCHAR Stripe PaymentIntent ID used for capturing funds.
bookings payout_status ENUM ('pending', 'completed', 'failed') Controls Stripe Connect Transfer execution.

pricing_overrides — One row per override per listing. Override type enum: weekend, seasonal. Stores date range (nullable for weekend type) and override nightly price.

refunds — One row per refund event. References booking ID. Stores Stripe Refund ID, amount, reason enum, created timestamp.

stripe_webhook_events — Idempotency log. Stores Stripe event ID and processing status. The webhook handler checks this table before processing — if the event ID already exists, it short-circuits and returns success. Prevents duplicate processing on Stripe retries.

Data Lifecycle

New bookings enter at pending or pending_approval and transition through the status machine. Confirmed bookings are retained indefinitely (anonymized after user deletion request). Declined and expired bookings are retained for audit. Refresh tokens older than 30 days are purged nightly. Stripe webhook event log rows older than 90 days are purged nightly. Listing photos whose S3 objects are orphaned (listing deleted) are cleaned up by the nightly CCPA Scrubber Lambda, which also handles S3 object deletion for scrubbed user profile photos.

Indexing Strategy

  • bookings(listing_id, check_in_date, check_out_date) — overlap queries
  • bookings(guest_user_id) — guest booking history
  • listings(location, max_guests, base_price) — search filter composite
  • availability_rules(listing_id) — rule lookup per listing
  • pricing_overrides(listing_id) — override lookup per listing
  • stripe_webhook_events(stripe_event_id) — unique index for idempotency

Backup and Retention

RDS automated backups: 7-day retention in dev/staging, 35-day in production. Point-in-time recovery enabled in production. S3 versioning enabled on the photo bucket. Database data retained for 3 years per policy; archived booking rows past 3 years are exported to S3 Glacier via a yearly Lambda job and then deleted from RDS.

Integration Architecture

Stripe Connect

Purpose: Payment processing, escrow, host onboarding, fund routing, and refunds.

Auth: The platform's Stripe secret key is stored in SSM Parameter Store and retrieved at Lambda cold start. Host connected accounts are created via Stripe Connect's hosted onboarding (OAuth flow): the Listings Lambda generates a Stripe Connect onboarding URL, the host completes onboarding on Stripe's hosted page, and Stripe redirects back to the platform with an authorization code. The Auth/Users Lambda exchanges the code for the connected account ID and stores it on the user record.

Operations:

  • Create PaymentIntent (charge guest at booking, with application fee for service fee capture)
  • Capture PaymentIntent (on host approval for manual-approval listings)
  • Cancel PaymentIntent (on decline or expiry)
  • Create Refund (on host or guest cancellation)
  • Create Transfer (host payout post check-in)
  • Create Connect onboarding link (host registration)
  • Retrieve Account (check host verification status)

Failure behavior: If PaymentIntent creation fails, the booking transaction rolls back — no booking is created and the guest sees a payment failure message. If a Transfer fails (payout), the Payout Scheduler Lambda marks payout_status = failed, logs the error to CloudWatch, and sends an alert to the operator Slack channel. The operator must manually retry via the Admin Lambda's payout retry endpoint. Stripe Connect onboarding failures leave the user in an unverified host state — they cannot publish a listing until account.updated webhook confirms verification.

AWS SES

Purpose: Transactional email — booking confirmation, booking cancellation, payout notification, CCPA data export link.

Auth: Lambda execution role has ses:SendEmail permission via IAM. Emails are sent from a verified SES domain.

Failure behavior: SES errors are logged to CloudWatch. Email delivery failure does not roll back a booking or payout — it is treated as a non-critical side effect. A CloudWatch alarm triggers if SES error rate exceeds 5% over a 5-minute window.

AWS S3

Purpose: Listing photo storage, CCPA data export file staging.

Auth: Lambda generates pre-signed PUT URLs for client-side photo upload. The S3 bucket is private; no public access. CloudFront serves photos via an Origin Access Control (OAC) policy — the bucket grants read access only to the CloudFront distribution.

Failure behavior: Pre-signed URL generation is a local operation (no S3 call required) and cannot fail. If the client's upload to S3 fails, the photo record is not written to the database — the client must retry the upload and then call the Listings Lambda to confirm the upload. Orphaned S3 objects (upload initiated but never confirmed) are cleaned up by an S3 lifecycle rule that deletes unconfirmed objects after 24 hours (objects in the pending/ prefix are moved to this prefix on URL generation and renamed to the photos/ prefix on confirmation).

AWS CloudWatch

Purpose: Structured logging, error alerting.

Auth: Lambda execution role has CloudWatch Logs write permissions via IAM.

Operations: All Lambda functions emit structured JSON logs using AWS Lambda Powertools for TypeScript (@aws-lambda-powertools/logger, tracer, metrics). This provides cold-start tracing and correlation ID injection from API Gateway through to Stripe webhooks. A CloudWatch Log Metric Filter detects "level":"error" log entries. A CloudWatch Alarm on this metric triggers an SNS topic that sends email and posts to a Slack webhook (the Slack webhook URL is stored in SSM). Alert threshold: more than 5 errors in a 1-minute window.

AWS SSM Parameter Store

Purpose: Secrets and environment-specific configuration.

Auth: Lambda execution role has ssm:GetParameter and ssm:GetParametersByPath permissions scoped to the /rentify/{env}/ path prefix.

Parameters stored: Database connection string (SecureString), Stripe secret key (SecureString), Stripe webhook signing secret (SecureString), JWT signing secret (SecureString), Slack webhook URL (SecureString), service fee percentage (String, default 12), throttle limits (String), allowed CORS origins (String).

Retrieval: Parameters are retrieved at Lambda cold start and cached in the execution context for the lifetime of the warm instance. Re-retrieved on each cold start.

Security Architecture

Input Validation

All request bodies are validated by a shared validation layer (using Zod schemas deployed in the Lambda Layer) before reaching any business logic. Validation rejects unexpected fields, enforces type constraints, and sanitizes string fields (trim whitespace, enforce maximum lengths). Validation errors return a structured error response without exposing internal schema details. Date range inputs are checked for logical consistency (check-out must be after check-in, maximum stay 365 nights) at the validation layer before any database query is executed.

Authentication and Authorization

JWTs are validated by the Lambda Authorizer on every protected route. The authorizer injects user ID and roles into the Lambda event context — business logic trusts this injected context rather than re-reading from the database. Role-based access is enforced at the handler level: host operations check for host role, admin operations check for admin role. Support agents (support role) are explicitly denied write operations via a role check in the Admin Lambda. Users can only modify their own resources — every write operation validates that the resource's owner ID matches the caller's injected user ID.

Secrets Management

No secrets in environment variables or code. All secrets are stored in SSM Parameter Store as SecureString (encrypted with the default AWS KMS key). Lambda execution roles are scoped to the minimum required SSM paths. Database credentials are not used directly — RDS Proxy uses IAM authentication, and Lambda functions authenticate to the Proxy via IAM tokens generated at runtime.

PCI DSS (SAQ A)

No card data touches the application layer. Stripe.js on the frontend collects card details directly in Stripe-hosted iframes. The backend only handles Stripe token references (PaymentIntent IDs). The Stripe webhook handler validates signatures before processing any event. This keeps the platform in SAQ A scope.

CCPA

Data deletion is a first-class flow with a 30-day grace period. The do_not_sell flag is enforced at the data export layer. User data export (right-to-know) is provided via a secure, time-limited S3 pre-signed URL emailed to the verified address. CCPA requests are logged in a dedicated ccpa_requests table with timestamp, request type, and processing status for audit purposes.

Web-Facing Security

  • HTTPS enforcement: API Gateway enforces HTTPS. CloudFront redirects HTTP to HTTPS. No HTTP endpoints.
  • CORS: API Gateway is configured with an explicit Access-Control-Allow-Origin list per environment (no wildcard in staging or production). Preflight responses are cached for 600 seconds.
  • Security headers: CloudFront response headers policy enforces Strict-Transport-Security (max-age 31536000, includeSubDomains), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy (restricts script sources to the CDN and Stripe.js domain), and Referrer-Policy: strict-origin-when-cross-origin.
  • Lambda function URLs: Not used — all invocations go through API Gateway to maintain the authorizer and throttling layer.
  • SQL injection: All database queries use parameterized statements via the pg driver. No string interpolation in SQL.
  • Rate limiting: API Gateway usage plans apply per-IP throttling (100 requests per minute per IP on auth endpoints, 500 per minute on all other endpoints).

Error Handling Policy

Internal error details (stack traces, SQL errors, Stripe error codes) are logged to CloudWatch but never returned to the client. Client-facing error responses contain only a human-readable message and an error code. Error codes are documented so the frontend can render appropriate UI.

Deployment Architecture

Environments

Three environments: dev, staging, production. Each environment has its own:

  • API Gateway stage
  • Lambda function set (deployed from the same codebase with ENV parameter)
  • RDS instance (dev: db.t3.micro, staging: db.t3.micro, production: db.t3.medium)
  • RDS Proxy (one per environment, IAM auth enabled)
  • S3 buckets (photos, exports)
  • SSM parameter path prefix (/rentify/dev/, /rentify/staging/, /rentify/prod/)
  • CloudFront distribution

Production RDS has Multi-AZ enabled. Dev and staging are single-AZ.

Infrastructure as Code

All AWS resources are defined in AWS SAM (Serverless Application Model) templates. SAM is chosen over CDK for its direct alignment with Lambda + API Gateway patterns and its native support for local Lambda invocation (used during Cursor-driven development). The SAM template is the single source of truth for all infrastructure. Environment-specific parameter overrides are passed at deploy time via SAM config files (samconfig.toml).

CI/CD Pipeline (GitHub Actions)

Three workflows:

PR workflow: Runs on every pull request to main. Steps: install dependencies, run unit tests, run integration tests against a local PostgreSQL instance (via Docker in the Actions runner), lint, and SAM build validation. PRs cannot be merged if this workflow fails.

Staging deploy: Runs on every merge to main. Steps: SAM build → SAM deploy to staging environment → run smoke tests against the staging API Gateway endpoint → notify Slack on success or failure.

Production deploy: Manual trigger only (GitHub Actions workflow_dispatch). Steps: SAM build → SAM deploy to production → run smoke tests → notify Slack. Requires a GitHub environment protection rule with at least one required reviewer approval before the deploy step executes.

Lambda Configuration

  • Runtime: Node.js 22.x
  • Memory: 512 MB default (Auth, Users, Admin), 1024 MB (Bookings, Payments webhook), 256 MB (Search, scheduled Lambdas)
  • Timeout: 29 seconds (API Gateway maximum) for request-response functions; 5 minutes for scheduled Lambdas
  • Concurrency: Reserved concurrency of 50 on the Bookings Lambda to prevent RDS Proxy connection exhaustion. All other functions use unreserved concurrency with a per-account limit set at 200 to cap costs.
  • Dead Letter Queue: SQS DLQ configured on all scheduled Lambdas. Failures are captured and alert via CloudWatch.

Scaling

At the target scale (hundreds of users), the RDS Proxy pool with a db.t3.medium instance is sufficient. The Proxy is configured with a maximum of 100 connections to the RDS instance, distributed across Lambda concurrency. If traffic grows, the RDS instance class is the first scaling lever — horizontal Lambda scaling is automatic. CloudFront handles all static asset scaling independently of the Lambda tier.

Technology Stack

Component Technology Version Rationale
Frontend framework React 18.3 Mature ecosystem, Cursor tooling support
Frontend hosting AWS S3 + CloudFront N/A Zero-server static hosting, cost-optimal
API layer AWS API Gateway REST API (v1) Native Lambda integration, JWT authorizer support, throttling
Compute AWS Lambda N/A (Node.js 22.x runtime) Serverless constraint; Node.js chosen for JS/TS full-stack consistency
Language TypeScript 5.5 Type safety across Lambda handlers and shared layer; Cursor benefits from strong types
Database PostgreSQL 15 ACID transactions required for atomic booking lock; RDS Proxy compatibility
Database hosting AWS RDS db.t3.medium (prod) Managed Postgres; RDS Proxy required by constraint
Connection pooling AWS RDS Proxy N/A Prevents Lambda connection exhaustion; IAM auth eliminates credential exposure
Validation library Zod 3.23 Runtime schema validation with TypeScript inference; deployed in Lambda Layer
Database client node-postgres (pg) 8.12 Lightweight, well-maintained, supports parameterized queries
Payments Stripe Connect API version 2024-06-20 Constraint; handles PCI scope, escrow, and host payouts
Email AWS SES v2 API Native AWS, cost-optimal at small scale
Object storage AWS S3 N/A Photo storage + CCPA exports; native CloudFront OAC
Secrets AWS SSM Parameter Store N/A SecureString for credentials; IAM-scoped Lambda access
IaC AWS SAM 1.120 Native Lambda/API Gateway support; local invoke for SDD/Cursor workflow
CI/CD GitHub Actions N/A Constraint implied by GitHub; native SAM deploy action available
Logging AWS CloudWatch Logs N/A Native Lambda destination; metric filters for alerting
Alerting CloudWatch Alarms + SNS → SES + Slack webhook N/A Email and Slack alerts per constraint
Scheduling AWS EventBridge Scheduler N/A Triggers payout and CCPA scrubber Lambdas on cron

Key Architectural Decisions

ADR-1: Single Lambda per logical domain, not per route Decided to deploy one Lambda function per domain module (Bookings, Listings, Search, etc.) rather than one per API route. Alternatives: monolithic single Lambda (too large, poor isolation), per-route Lambdas (cold start multiplication, operational overhead). Single-per-domain balances isolation with operational simplicity at small scale. Routing within the Lambda uses a lightweight internal router (path + method matching).

ADR-2: PostgreSQL GiST Exclusion Constraint for booking locks Decided to use a PostgreSQL GiST Exclusion Constraint using the && overlap operator on a tsrange column to mathematically prevent double bookings. Alternatives: application-level mutex (not safe across Lambda instances), pessimistic locking with SELECT FOR UPDATE (vulnerable to external scripts, manual operations, and developer error). A GiST exclusion constraint is the only mechanism that provides an immutable database-level invariant against double bookings at the schema level.

ADR-3: Stripe PaymentIntent captured at booking, Transfer created post check-in Decided to capture the guest payment immediately at booking and hold funds in the platform Stripe account, transferring to the host only after check-in. Alternatives: authorize-only until check-in (authorization windows expire, card may decline at capture, complex retry logic). Immediate capture with delayed transfer is the standard escrow pattern for short-term rental marketplaces and is directly supported by Stripe Connect's model.

ADR-4: AWS SAM for IaC over CDK or Terraform Decided to use AWS SAM. Alternatives: CDK (more powerful but higher learning curve, less aligned with the Cursor/SDD quick-iteration workflow), Terraform (excellent for multi-cloud, unnecessary here, requires separate state management). SAM's sam local invoke capability is directly useful for the SDD development loop in Cursor without additional tooling.

ADR-5: No geospatial search — text ILIKE on location field Decided to implement search as a text match against a freeform location string. Alternatives: PostGIS with lat/lng coordinates and radius search (correct for a mature product, disproportionate for hundreds of users at launch). ILIKE search covers city/neighborhood-level discovery at launch scale. PostGIS can be added as a targeted migration when user volume demands it.

ADR-6: CCPA deletion as PII scrub, not row deletion Decided to retain booking rows indefinitely with PII scrubbed from the user record rather than cascading-delete booking rows on user deletion. Alternatives: hard delete all user data (breaks booking history integrity, violates 3-year financial retention requirement). PII scrub satisfies the CCPA deletion right while preserving the financial records the platform is legally required to retain.

ADR-7: RDS Proxy with IAM authentication Decided to use RDS Proxy with IAM-based authentication from Lambda rather than storing database credentials in environment variables or SSM. Alternatives: direct Lambda-to-RDS with credentials in SSM (still requires rotating secrets, direct connections exhaust PostgreSQL's connection limit). IAM auth via RDS Proxy eliminates credential rotation risk and solves the connection exhaustion problem simultaneously.

Error Handling & Failure Modes

Auth Lambda

  • Invalid credentials: Return a generic "invalid email or password" message. Do not distinguish between "user not found" and "wrong password" to prevent user enumeration.
  • Expired refresh token: Return a "session expired, please log in again" error. Client must redirect to login.
  • Brute force: API Gateway rate limiting (100 requests/minute per IP on auth endpoints) is the first defense. After 10 consecutive failed login attempts for the same email within 15 minutes, the account is soft-locked for 15 minutes (tracked in a login_attempts table).

Bookings Lambda — Availability Lock

Q: What happens if there is lock contention because another booking is in-flight? A: The PostgreSQL GiST exclusion constraint blocks the insert. The transaction is rolled back and the guest receives a "this listing is no longer available for these dates — please try again" message. No partial state is written.

Q: What happens if Stripe PaymentIntent creation fails? A: The database transaction has not yet committed. Roll back. Guest receives a payment failure message with the Stripe decline reason (mapped to a user-friendly string). No booking record exists.

Q: How does Rentify recover if a Stripe PaymentIntent capture succeeds but the database commit fails? A: This is a critical partial-state scenario. The PaymentIntent is captured but no booking row exists. The Payments Lambda webhook handler receives payment_intent.succeeded and looks up the booking by PaymentIntent ID — if no booking row exists, it creates one in confirmed status. This is the recovery path. A CloudWatch alert fires if this scenario is detected (no booking row for a succeeded PaymentIntent after 60 seconds).

Q: What happens if a manual booking approval times out after 24 hours? A: The Approval Timeout scheduled Lambda (EventBridge, every 15 minutes) queries pending_approval bookings older than 24 hours. For each, it cancels the Stripe PaymentIntent and moves the booking to declined. Guest receives an automated email. The availability lock is released.

Payments Lambda (Webhook)

  • Invalid webhook signature: Reject immediately, log to CloudWatch, return a success HTTP response to Stripe (to prevent Stripe from retrying a genuinely bad request from a non-Stripe source).
  • Duplicate event: The stripe_webhook_events idempotency table catches this. Return success without reprocessing.
  • Event processing failure (database error during webhook handling): Return a failure response to Stripe. Stripe will retry the event up to 3 days. The failure is logged to CloudWatch and triggers an alert if it persists across retries.

Payout Scheduler Lambda

  • Stripe Transfer failure: Mark payout_status = failed on the booking row. Log the Stripe error. Send an operator alert via Slack/email. The Admin Lambda exposes a manual retry endpoint. The scheduled Lambda will not re-attempt automatically — operator must review and retry.
  • Lambda invocation failure (cold start crash, timeout): The SQS DLQ captures the failed invocation. CloudWatch alarm on DLQ message count fires immediately. The payout is delayed until the operator resolves the issue and the next hourly run succeeds.

CCPA Scrubber Lambda

  • Scrubbing job failure mid-run: The scrubber processes users one at a time in a loop. Each user scrub is committed individually. A failure on user N does not roll back users 1 through N-1. The failed user's deletion_status remains pending and is retried on the next nightly run. This is intentional — partial scrubs are safe because each field is independently overwritten.
  • S3 deletion failure (profile photo): Logged to CloudWatch, does not block PII scrubbing of the database record. A separate S3 lifecycle rule ensures orphaned objects are eventually deleted.

Search Lambda

  • No results found: Return an empty result set with a 200 response. The frontend renders a "no listings found" state.
  • Invalid date inputs (check-out before check-in, past dates): Rejected at the validation layer before the database is queried.
  • Database timeout on search query: If the query exceeds 10 seconds (set as a statement_timeout on the search connection), it is cancelled and the user receives a "search is temporarily unavailable" error. A CloudWatch metric tracks query duration.

RDS Proxy / Database

  • RDS Proxy connection pool exhaustion: Lambda invocations that cannot acquire a connection fail with a connection timeout. The Bookings Lambda reserved concurrency limit (50) is sized to stay within the Proxy's pool capacity. If pool exhaustion occurs despite this, the error is logged and the user receives a "service temporarily unavailable" message.
  • RDS failover (production Multi-AZ): RDS Proxy handles the failover transparently. Connections in-flight during failover will error and must be retried by the application. Lambda handlers retry database operations up to 3 times with 500ms backoff before returning an error to the caller. Target recovery time is under 60 seconds, within the 1-hour unplanned downtime SLA.

Pre-Launch Verification

  • Stripe integration: Test using Stripe's test mode with test card numbers. Verify webhook delivery using the Stripe Dashboard's webhook event simulator. Verify idempotency by replaying events via the simulator.
  • SES integration: Test using SES sandbox mode (verified email addresses only) in dev and staging. Verify domain in production before launch.
  • CCPA flow: Execute an end-to-end deletion request in staging and verify PII fields are scrubbed and booking rows are retained.
  • Atomic lock: Write an integration test that fires two concurrent booking requests for the same listing/dates and asserts that exactly one succeeds.

Risks and Mitigations

Risk 1: PaymentIntent captured but booking row commit fails (partial payment state) Component: Bookings Lambda, Stripe integration boundary. Impact: Guest is charged but has no confirmed booking. High-severity customer experience failure. Mitigation: The webhook-based recovery path (ADR-3, Payments Lambda section) handles this by creating the booking row on receipt of payment_intent.succeeded if no row exists. A CloudWatch alarm fires within 60 seconds of the anomaly so an operator can verify recovery occurred. This scenario is tested pre-launch via a simulated database commit failure in staging.

Risk 2: RDS connection exhaustion during traffic spikes Component: RDS Proxy, Lambda concurrency configuration. Impact: All Lambda functions fail to connect to the database, causing a complete service outage. Mitigation: Reserved concurrency on the Bookings Lambda (50) caps the most connection-intensive function. A per-account Lambda concurrency cap (200) bounds total connections. The RDS Proxy's max connections (100) is sized above the expected peak. CloudWatch alarms on DatabaseConnections metric alert before saturation.

Risk 3: Stripe Connect host onboarding abandonment leaves listings unpublishable Component: Listings Lambda, Stripe Connect onboarding flow. Impact: Hosts begin onboarding, abandon midway, and cannot publish listings. Operator has no visibility into incomplete onboarding without manual Stripe Dashboard inspection. Mitigation: The account.updated webhook updates the host's verification status in the database. The Admin Lambda exposes a view of hosts by verification status. A follow-up email is sent via SES 24 hours after onboarding initiation if verification is still incomplete (triggered by the nightly CCPA Scrubber Lambda, which also checks this status).

Risk 4: Cold start latency on the Bookings Lambda degrades booking UX Component: Bookings Lambda. Impact: First request after a cold start may take 2–4 seconds, which is noticeable during booking — the most conversion-critical flow. Mitigation: The Bookings Lambda is configured with 1024 MB memory (higher memory reduces cold start duration). Provisioned Concurrency is left off by default (cost optimization) but is documented as the first lever to pull if cold starts become a user-reported issue. The Lambda Layer (shared utilities, Zod, pg client) is pre-compiled to minimize initialization time.

Risk 5: CCPA scrubber running during peak hours causes database contention Component: CCPA Scrubber Lambda, RDS. Impact: Nightly bulk UPDATE operations on the users table cause lock contention for simultaneous user logins or profile reads. Mitigation: The scrubber runs at 02:00 UTC (low-traffic window). It processes users in batches of 50 with a 100ms sleep between batches to avoid sustained lock pressure. A statement_timeout of 30 seconds is set on the scrubber's connection, preventing a runaway scrub query from blocking other operations.

Implementation Plan

Implementation Plan

This document outlines the global configuration, environment setup, and prerequisite steps for the architecture. Provide this implementation plan to your coding agent as foundational context before beginning work on the specific execution tickets.

Environment Setup

What you'll need

Before any code gets written, a few accounts and configuration steps need to be in place. This section covers everything you need to set up once so the coding agent has a working foundation to build on.

Accounts to create

  • AWS — aws.amazon.com — hosts everything: the API, database, file storage, email, and the frontend. Nearly every piece of this project lives here.
  • Stripe — dashboard.stripe.com/register — handles all payments, host payouts, and the Connect onboarding flow for hosts. You'll need to enable Connect (covered below).
  • GitHub — github.com/signup — stores the code and runs the automated deployment pipelines.

Before you start

AWS setup

  • Create an IAM user or role for deployments with programmatic access. Attach the AdministratorAccess policy for initial setup — you can tighten this after the infrastructure is stable.
  • In the AWS Console, navigate to SES and verify your sending domain (the domain you'll send booking confirmation emails from). While in sandbox mode, also verify at least one recipient email address for testing.
  • In SES, request production access when you're ready to send to real users — this requires a brief AWS support request explaining your use case.
  • In the AWS Console, confirm that your chosen region (e.g. us-east-1) has RDS Proxy available — it's supported in all major regions but worth confirming.
  • In Parameter Store (under AWS Systems Manager), create the following SecureString parameters under the /rentify/dev/ path prefix to start. You'll duplicate these under /rentify/staging/ and /rentify/prod/ later. See the environment variables list below for the full set.

Stripe setup

  • In the Stripe Dashboard, go to Settings → Connect Settings and enable Connect. Choose "Platform or marketplace" as the account type.
  • Under Developers → Webhooks, create a webhook endpoint pointing to your API Gateway webhook URL (you'll have this after Phase 1 infrastructure is deployed). Subscribe to these events: payment_intent.succeeded, payment_intent.payment_failed, transfer.failed, account.updated.
  • Copy the webhook signing secret shown after creating the endpoint — you'll need it for SSM.
  • Toggle Stripe into Test Mode for development. All keys and webhook secrets have separate test and live versions — make sure you're using test keys in dev and staging.

GitHub setup

  • Create a new repository for the project.
  • Under Settings → Secrets and Variables → Actions, add your AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION) as repository secrets so the deployment workflows can authenticate.
  • Under Settings → Environments, create three environments named dev, staging, and production. On the production environment, enable "Required reviewers" and add at least one reviewer. This gates production deploys behind a human approval step.

Environment variables

These are the values the coding agent will read from SSM Parameter Store at runtime. Add them all under /rentify/dev/ to start. All sensitive values should be created as SecureString; non-sensitive config values can be String.

  • DATABASE_URL — The full PostgreSQL connection string for your RDS instance, found in the RDS Console → your database → Connectivity & security. Use the RDS Proxy endpoint, not the direct RDS endpoint.
  • STRIPE_SECRET_KEY — Stripe Dashboard → Developers → API Keys → Secret key (use the test key for dev/staging)
  • STRIPE_WEBHOOK_SECRET — Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret
  • STRIPE_CONNECT_CLIENT_ID — Stripe Dashboard → Connect Settings → Your platform's client ID
  • JWT_SECRET — A long random string you generate (at least 64 characters). Used to sign and verify login tokens. Generate one with a password manager or a random string generator.
  • SLACK_WEBHOOK_URL — Create an incoming webhook in your Slack workspace under Apps → Incoming Webhooks, then copy the URL
  • SES_FROM_ADDRESS — The verified email address or domain identity you set up in SES (e.g. no-reply@yourdomain.com)
  • SERVICE_FEE_PERCENTAGE — Set to 12 to start (this is the guest-side service fee percentage)
  • ALLOWED_CORS_ORIGINS — The URL of your frontend (e.g. http://localhost:3000 for dev, your CloudFront URL for staging/prod)
  • THROTTLE_BURST_LIMIT — Set to 1000 for production, lower for dev (e.g. 100)
  • THROTTLE_STEADY_STATE — Set to 500 for production, lower for dev (e.g. 50)

Phases

Phase 1: Infrastructure Foundation and Shared Layer

This phase establishes the complete AWS infrastructure and the shared Lambda Layer that every subsequent phase depends on. It delivers a deployable SAM template, a working RDS PostgreSQL database reachable through RDS Proxy, all S3 buckets, SSM parameter paths, and the shared TypeScript utilities package (database client, logger, Zod schemas, error types) that all Lambda handlers import. Nothing in later phases works without this foundation.

Contracts

SAM Template (template.yaml)

Defines all AWS resources. Later phases add Lambda function resources to this file — they never create separate templates. The template is parameterized by Environment (dev | staging | production) and reads all secrets from SSM at deploy time.

Resources declared in this phase:

  • RdsInstance — PostgreSQL 15, db.t3.micro for dev/staging, db.t3.medium for production, Multi-AZ enabled only when Environment=production
  • RdsProxy — IAM authentication enabled, connected to RdsInstance, max connections 100
  • PhotosBucket — private S3 bucket, versioning enabled, S3 lifecycle rule: objects under pending/ prefix deleted after 24 hours
  • ExportsBucket — private S3 bucket for CCPA data exports
  • SharedLambdaLayer — bundles the compiled shared/ package; referenced by ARN in every Lambda function resource added in later phases
  • RentifyApi — API Gateway REST API with a StageName matching Environment; CORS configured with AllowedOrigins from SSM, preflight cache 600 seconds; throttle burst and steady-state from SSM
  • CloudFront distribution for the frontend S3 bucket (added as FrontendBucket) with OAC, HTTPS-only, and the security response headers policy (Strict-Transport-Security max-age 31536000 includeSubDomains, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy restricting scripts to the CDN origin and https://js.stripe.com, Referrer-Policy: strict-origin-when-cross-origin)

Database schema (infrastructure/migrations/)

Migration files run in sequence. File naming convention: 001_initial_schema.sql, 002_...sql. All tables and indexes for the full application are defined here so later phases find the schema in place.

Tables created:

users

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
email           VARCHAR(255) UNIQUE NOT NULL
password_hash   VARCHAR(255) NOT NULL
display_name    VARCHAR(255)
phone           VARCHAR(50)
profile_photo_key VARCHAR(500)
roles           TEXT[] NOT NULL DEFAULT '{guest}'
stripe_account_id VARCHAR(255)
stripe_account_verified BOOLEAN NOT NULL DEFAULT false
deletion_requested_at TIMESTAMPTZ
deletion_status VARCHAR(50) NOT NULL DEFAULT 'none'
  -- values: 'none' | 'pending' | 'completed'
do_not_sell     BOOLEAN NOT NULL DEFAULT false
login_attempt_count INT NOT NULL DEFAULT 0
login_locked_until TIMESTAMPTZ
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()

refresh_tokens

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id         UUID NOT NULL REFERENCES users(id)
token_hash      VARCHAR(255) NOT NULL
expires_at      TIMESTAMPTZ NOT NULL
revoked         BOOLEAN NOT NULL DEFAULT false
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()

listings

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
host_user_id    UUID NOT NULL REFERENCES users(id)
title           VARCHAR(255) NOT NULL
description     TEXT
location        VARCHAR(500) NOT NULL
property_type   VARCHAR(50) NOT NULL
  -- values: 'entire_home' | 'room'
max_guests      INT NOT NULL
base_price      NUMERIC(10,2) NOT NULL
instant_book    BOOLEAN NOT NULL DEFAULT false
cancellation_policy VARCHAR(50) NOT NULL DEFAULT 'flexible'
  -- values: 'flexible' | 'moderate' | 'strict'
status          VARCHAR(50) NOT NULL DEFAULT 'draft'
  -- values: 'draft' | 'active' | 'inactive'
house_rules     TEXT
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()

listing_photos

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id      UUID NOT NULL REFERENCES listings(id)
s3_key          VARCHAR(500) NOT NULL
display_order   INT NOT NULL
alt_text        VARCHAR(500)
confirmed       BOOLEAN NOT NULL DEFAULT false
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()

listing_amenities

listing_id      UUID NOT NULL REFERENCES listings(id)
amenity         VARCHAR(100) NOT NULL
PRIMARY KEY (listing_id, amenity)

availability_rules

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id      UUID NOT NULL REFERENCES listings(id)
rule_type       VARCHAR(50) NOT NULL
  -- values: 'blocked_range' | 'min_stay' | 'checkin_days'
start_date      DATE
end_date        DATE
value           INT
bitmask         BIT(7)
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()

pricing_overrides

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id      UUID NOT NULL REFERENCES listings(id)
override_type   VARCHAR(50) NOT NULL
  -- values: 'weekend' | 'seasonal'
start_date      DATE
end_date        DATE
price           NUMERIC(10,2) NOT NULL
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()

bookings

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
listing_id      UUID NOT NULL REFERENCES listings(id)
guest_user_id   UUID NOT NULL REFERENCES users(id)
stay_range      tsrange NOT NULL
check_in_date   DATE NOT NULL
check_out_date  DATE NOT NULL
status          VARCHAR(50) NOT NULL DEFAULT 'pending'
  -- values: 'pending' | 'pending_approval' | 'confirmed'
  --         | 'declined' | 'cancelled_by_host' | 'cancelled_by_guest'
  --         | 'payment_failed'
total_amount    NUMERIC(10,2) NOT NULL
host_payout_amount NUMERIC(10,2) NOT NULL
service_fee     NUMERIC(10,2) NOT NULL
stripe_pi_id    VARCHAR(255)
payout_status   VARCHAR(50) NOT NULL DEFAULT 'pending'
  -- values: 'pending' | 'completed' | 'failed' | 'not_applicable'
stripe_transfer_id VARCHAR(255)
approval_expires_at TIMESTAMPTZ
nights          INT NOT NULL
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()

GiST exclusion constraint on bookings:

CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings
  EXCLUDE USING gist (listing_id WITH =, stay_range WITH &&)
  WHERE (status IN ('pending', 'pending_approval', 'confirmed'));

refunds

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
booking_id      UUID NOT NULL REFERENCES bookings(id)
stripe_refund_id VARCHAR(255) NOT NULL
amount          NUMERIC(10,2) NOT NULL
reason          VARCHAR(100) NOT NULL
  -- values: 'host_cancellation' | 'guest_cancellation' | 'operator'
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()

stripe_webhook_events

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
stripe_event_id VARCHAR(255) NOT NULL
status          VARCHAR(50) NOT NULL DEFAULT 'processed'
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()

Unique index: CREATE UNIQUE INDEX ON stripe_webhook_events(stripe_event_id);

ccpa_requests

id              UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id         UUID NOT NULL REFERENCES users(id)
request_type    VARCHAR(50) NOT NULL
  -- values: 'deletion' | 'export' | 'opt_out'
status          VARCHAR(50) NOT NULL DEFAULT 'pending'
  -- values: 'pending' | 'completed' | 'failed'
created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()

All indexes from the ADD's indexing strategy are created in the migration:

CREATE INDEX ON bookings(listing_id, check_in_date, check_out_date);
CREATE INDEX ON bookings(guest_user_id);
CREATE INDEX ON listings(location, max_guests, base_price);
CREATE INDEX ON availability_rules(listing_id);
CREATE INDEX ON pricing_overrides(listing_id);

Shared Lambda Layer (packages/shared/)

packages/shared/src/db.ts

Exports a single database client factory. All Lambda handlers call this at cold start and cache the result.

getDbClient(): pg.Pool
  • Reads DATABASE_URL from the SSM parameter cache (see config.ts below)
  • Returns a pg.Pool configured for the Lambda execution context (small pool size — 1 to 5 connections per instance)
  • On connection error: logs via the shared logger, throws DatabaseConnectionError

packages/shared/src/config.ts

Exports a config loader that retrieves all SSM parameters at cold start and caches them for the lifetime of the warm instance.

getConfig(): Promise<AppConfig>

AppConfig shape:

{
  databaseUrl: string
  stripeSecretKey: string
  stripeWebhookSecret: string
  stripeConnectClientId: string
  jwtSecret: string
  slackWebhookUrl: string
  sesFromAddress: string
  serviceFeePercentage: number   // parsed from string SSM value
  allowedCorsOrigins: string[]   // parsed from comma-separated SSM value
}
  • Reads all values from /rentify/{env}/ path prefix using ssm:GetParametersByPath
  • Throws ConfigurationError if any required parameter is absent

packages/shared/src/errors.ts

Exports the canonical error types used across all Lambda handlers. Later phases never define new error classes — they throw these:

class RentifyError extends Error {
  constructor(public code: string, message: string, public httpStatus: number)
}
class ValidationError extends RentifyError   // code: 'VALIDATION_ERROR', status 400
class AuthenticationError extends RentifyError // code: 'AUTHENTICATION_ERROR', status 401
class AuthorizationError extends RentifyError  // code: 'AUTHORIZATION_ERROR', status 403
class NotFoundError extends RentifyError       // code: 'NOT_FOUND', status 404
class ConflictError extends RentifyError       // code: 'CONFLICT', status 409
  // used for GiST constraint violations and duplicate bookings
class PaymentError extends RentifyError        // code: 'PAYMENT_ERROR', status 402
class DatabaseConnectionError extends RentifyError // code: 'DB_CONNECTION_ERROR', status 503
class ConfigurationError extends RentifyError  // code: 'CONFIG_ERROR', status 500
class ExternalServiceError extends RentifyError // code: 'EXTERNAL_SERVICE_ERROR', status 502

packages/shared/src/logger.ts

Thin wrapper around @aws-lambda-powertools/logger that pre-configures structured JSON output and correlation ID injection. Exported as a singleton logger instance. All Lambda handlers import this — they never construct their own Logger instances.

const logger: Logger  // pre-configured Powertools Logger

packages/shared/src/response.ts

Exports two functions used by every Lambda handler to build API Gateway proxy responses:

successResponse(body: unknown, statusCode?: number): APIGatewayProxyResult
  // statusCode defaults to 200
  // body is JSON.stringify'd; Content-Type: application/json

errorResponse(error: RentifyError | Error): APIGatewayProxyResult
  // RentifyError: uses error.httpStatus and error.code
  // Unknown Error: status 500, code 'INTERNAL_ERROR'
  // Response body: { error: { code: string, message: string } }
  // Never includes stack traces or internal details

packages/shared/src/validation.ts

Exports a validate helper that wraps Zod parsing and throws ValidationError on failure:

validate<T>(schema: z.ZodSchema<T>, input: unknown): T
  // On Zod parse failure: throws ValidationError with human-readable message
  // On success: returns the parsed, typed value

Also exports all Zod schemas used by multiple Lambda handlers. Per-handler schemas that are never shared are defined within that handler's module, not here.

Shared schemas:

uuidSchema: z.string().uuid()
dateSchema: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)  // YYYY-MM-DD
paginationSchema: z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20) })

packages/shared/src/email.ts

Exports a single email-sending function used by all Lambda handlers that send transactional email. SES errors are logged but do not throw — they are treated as non-critical:

sendEmail(params: {
  to: string
  subject: string
  htmlBody: string
  textBody: string
}): Promise<void>
  // Logs SES errors via logger; does not re-throw

packages/shared/src/index.ts

Re-exports everything from the above modules as named exports. Lambda handlers import from @rentify/shared.

samconfig.toml

Three environment stacks (dev, staging, production) with environment-specific parameter overrides. No environment-specific logic lives in code — only in this config file and SSM.

Deliverables

  • SAM template deploys successfully to dev with sam deploy
  • RDS instance and RDS Proxy are reachable; all migration SQL runs without error
  • S3 buckets exist with correct policies and lifecycle rules
  • CloudFront distribution serves a placeholder page from the frontend S3 bucket with security headers visible in browser dev tools
  • packages/shared/ compiles without TypeScript errors
  • getConfig() returns a populated AppConfig when called from a test Lambda invocation with SSM parameters populated
  • getDbClient() returns a connected pool that can execute SELECT 1

Phase 2: Authentication

This phase delivers the Auth Lambda and the Lambda Authorizer — the security gate for every subsequent phase. No other Lambda handler can be protected until the Authorizer is deployed and the JWT contract is established. This phase also defines the exact JWT payload shape that every later handler reads from the authorizer-injected event context.

Contracts

JWT payload shape

This shape is the contract between the Auth Lambda (which signs tokens) and the Lambda Authorizer (which verifies and forwards them). Every later phase reads user identity from this injected context — never from the database at request time.

type JwtPayload = {
  sub: string        // user UUID
  email: string
  roles: string[]    // e.g. ['guest'] or ['guest', 'host']
  iat: number
  exp: number
}

The authorizer injects the decoded payload into the Lambda event's requestContext.authorizer under the key claims. Handler modules access identity as:

const userId = event.requestContext.authorizer.claims.sub as string
const roles  = JSON.parse(event.requestContext.authorizer.claims.roles) as string[]

Note: API Gateway serializes all authorizer context values to strings. The roles array is serialized with JSON.stringify before injection and must be parsed with JSON.parse in every handler. This is a fixed contract — no phase changes this serialization behavior.

Auth Lambda (functions/auth/)

functions/auth/src/handlers/register.ts

register(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Input (validated with validate()):

{ email: string, password: string, displayName: string }

Steps in order:

  1. Validate input. password must be at least 8 characters.
  2. Check for existing user with that email — if found, throw ConflictError('EMAIL_IN_USE', 'An account with this email already exists', 409).
  3. Hash password with bcrypt, work factor 12.
  4. Insert user row with roles = ['guest'], deletion_status = 'none'.
  5. Issue access token (15-minute expiry) and refresh token (30-day expiry). Store hashed refresh token in refresh_tokens table.
  6. Return { accessToken, refreshToken, user: { id, email, displayName, roles } }.

Error handling: database errors throw DatabaseConnectionError; unexpected errors return errorResponse with status 500.

functions/auth/src/handlers/login.ts

login(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Input:

{ email: string, password: string }

Steps in order:

  1. Validate input.
  2. Look up user by email. Regardless of whether the user exists, run the same code path to prevent timing-based enumeration — always call bcrypt.compare, even against a dummy hash if the user is not found.
  3. Check login_locked_until — if in the future, return AuthenticationError with a generic "invalid email or password" message (do not reveal the lock).
  4. If password does not match: increment login_attempt_count. If count reaches 10, set login_locked_until = now() + 15 minutes. Return generic AuthenticationError.
  5. On success: reset login_attempt_count to 0, clear login_locked_until.
  6. Issue and return tokens identically to register.

functions/auth/src/handlers/refresh.ts

refreshToken(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Input:

{ refreshToken: string }

Steps in order:

  1. Hash the incoming token and look up a matching, non-revoked row in refresh_tokens where expires_at > now().
  2. If not found or revoked: throw AuthenticationError('SESSION_EXPIRED', 'Session expired, please log in again', 401).
  3. Revoke the old refresh token (set revoked = true).
  4. Issue a new access token and refresh token. Store new refresh token hash. Return both.

functions/auth/src/handlers/logout.ts

logout(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Protected route (passes through authorizer). Input:

{ refreshToken: string }

Steps: hash the token, set revoked = true on the matching row. Return { success: true }.

functions/auth/src/router.ts

Routes incoming API Gateway events by httpMethod and path to the correct handler. Exports a single Lambda handler entry point:

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Route table:

  • POST /auth/register → register
  • POST /auth/login → login
  • POST /auth/refresh → refreshToken
  • POST /auth/logout → logout (protected)

Unmatched routes return NotFoundError.

Lambda Authorizer (functions/authorizer/)

functions/authorizer/src/index.ts

handler(event: APIGatewayAuthorizerEvent): Promise<APIGatewayAuthorizerResult>

Steps in order:

  1. Extract the Authorization: Bearer <token> header.
  2. Verify the JWT signature using JWT_SECRET from config. Check expiry.
  3. On valid token: return an Allow policy for the resource ARN, with the JwtPayload fields stringified into the authorizer context (sub, email, roles as JSON.stringify(roles)).
  4. On invalid or missing token: return a Deny policy. The calling Lambda never executes.

Authorizer result cache TTL: 300 seconds (5 minutes), as specified in the SAM template's AuthorizerResultTtlInSeconds property.

Deliverables

  • POST /auth/register creates a user row and returns tokens
  • POST /auth/login returns tokens; brute-force lock engages after 10 failed attempts within 15 minutes
  • POST /auth/refresh issues new tokens and revokes the old refresh token
  • A protected test route returns 401 when called without a token and 200 when called with a valid token
  • Two concurrent registration requests with the same email result in exactly one user row (ConflictError on the second)

Phase 3: Listing Management

This phase delivers the Listings Lambda — all host-side operations for creating and managing listings, including availability rules, pricing overrides, photo management, amenities, and Stripe Connect host onboarding. The Search Lambda in Phase 4 reads from the same tables this phase writes, so the full listing data shape is finalized here.

Contracts

Listing response shape

Every endpoint that returns a listing uses this canonical shape. The Search Lambda in Phase 4 returns ListingSummary (a subset). Both are defined here so Phase 4 imports them.

type Listing = {
  id: string
  hostUserId: string
  title: string
  description: string | null
  location: string
  propertyType: 'entire_home' | 'room'
  maxGuests: number
  basePrice: number
  instantBook: boolean
  cancellationPolicy: 'flexible' | 'moderate' | 'strict'
  status: 'draft' | 'active' | 'inactive'
  houseRules: string | null
  photos: ListingPhoto[]
  amenities: string[]
  availabilityRules: AvailabilityRule[]
  pricingOverrides: PricingOverride[]
  createdAt: string   // ISO 8601
  updatedAt: string
}

type ListingPhoto = {
  id: string
  s3Key: string
  displayOrder: number
  altText: string | null
}

type AvailabilityRule = {
  id: string
  ruleType: 'blocked_range' | 'min_stay' | 'checkin_days'
  startDate: string | null   // YYYY-MM-DD
  endDate: string | null
  value: number | null
  bitmask: string | null     // 7-character bit string e.g. '0110000'
}

type PricingOverride = {
  id: string
  overrideType: 'weekend' | 'seasonal'
  startDate: string | null
  endDate: string | null
  price: number
}

type ListingSummary = Pick<Listing, 'id' | 'title' | 'location' | 'propertyType' | 'maxGuests' | 'basePrice' | 'instantBook' | 'cancellationPolicy' | 'status'> & {
  coverPhotoKey: string | null   // s3Key of the photo with display_order = 1
}

Availability rule evaluation (functions/listings/src/lib/availability.ts)

This module is also imported by the Bookings Lambda in Phase 5. It must not import anything from functions/listings/ that is specific to listing management — only shared types and the db client.

evaluateAvailabilityRules(params: {
  listingId: string
  checkInDate: string    // YYYY-MM-DD
  checkOutDate: string   // YYYY-MM-DD
  db: pg.Pool
}): Promise<void>
  • Fetches all availability_rules rows for listingId
  • Applies rules in order:
    1. Blocked ranges: all nights in the requested stay must fall outside every blocked_range rule's date range. If any night overlaps, throws ConflictError('DATES_BLOCKED', 'These dates are not available', 409).
    2. Minimum stay: the number of nights must be ≥ value on any min_stay rule. If not, throws ConflictError('MIN_STAY', 'Minimum stay requirement not met', 409).
    3. Check-in days: the check-in weekday bit (0 = Sunday … 6 = Saturday) must be set in the bitmask of any checkin_days rule. If not, throws ConflictError('CHECKIN_DAY', 'Check-in on this day is not permitted', 409).
  • Returns void on success.

Pricing calculation (functions/listings/src/lib/pricing.ts)

Also imported by the Bookings Lambda in Phase 5.

calculateStayPrice(params: {
  listingId: string
  checkInDate: string   // YYYY-MM-DD
  checkOutDate: string  // YYYY-MM-DD
  db: pg.Pool
  serviceFeePercentage: number
}): Promise<PriceBreakdown>

type PriceBreakdown = {
  nightlyRates: Array<{ date: string, rate: number }>
  subtotal: number          // sum of nightly rates
  serviceFee: number        // subtotal * (serviceFeePercentage / 100), rounded to 2 decimal places
  total: number             // subtotal + serviceFee
  hostPayoutAmount: number  // subtotal (service fee stays on platform)
}
  • Iterates each calendar night from checkInDate up to but not including checkOutDate
  • For each night, resolves price in priority order: (1) seasonal override if the night falls within a seasonal override's date range, (2) weekend override if the night is Friday or Saturday and a weekend override exists, (3) base_price from the listing row
  • All monetary values rounded to 2 decimal places

Listings Lambda (functions/listings/)

functions/listings/src/router.ts

Single Lambda entry point:

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Route table (all routes protected unless noted):

  • POST /listings → createListing
  • GET /listings/:id → getListing (public)
  • PUT /listings/:id → updateListing
  • DELETE /listings/:id → deleteListing
  • POST /listings/:id/photos/upload-url → getPhotoUploadUrl
  • POST /listings/:id/photos/confirm → confirmPhotoUpload
  • DELETE /listings/:id/photos/:photoId → deletePhoto
  • PUT /listings/:id/photos/order → updatePhotoOrder
  • POST /listings/:id/amenities → setAmenities
  • POST /listings/:id/availability-rules → setAvailabilityRules
  • POST /listings/:id/pricing-overrides → setPricingOverrides
  • GET /connect/onboarding-url → getStripeOnboardingUrl
  • GET /connect/callback → handleStripeCallback (public — Stripe redirects here)

functions/listings/src/handlers/listings.ts

createListing(event): Promise<APIGatewayProxyResult>
  • Requires host role; throws AuthorizationError if caller lacks it
  • Input: { title, description?, location, propertyType, maxGuests, basePrice, instantBook?, cancellationPolicy?, houseRules? }
  • Inserts listing row with status = 'draft'
  • Returns Listing
getListing(event): Promise<APIGatewayProxyResult>
  • Public route; no role check
  • Fetches listing with photos, amenities, availability rules, pricing overrides
  • Returns Listing; throws NotFoundError if not found
updateListing(event): Promise<APIGatewayProxyResult>
  • Requires host role; validates host_user_id = callerId
  • Input: any subset of listing fields (partial update)
  • Returns updated Listing
deleteListing(event): Promise<APIGatewayProxyResult>
  • Requires host role; validates ownership
  • Sets status = 'inactive' (never hard-deletes)
  • Returns { success: true }

functions/listings/src/handlers/photos.ts

getPhotoUploadUrl(event): Promise<APIGatewayProxyResult>
  • Requires host role; validates listing ownership
  • Input: { filename: string, contentType: string }
  • Generates S3 pre-signed PUT URL for key pending/{listingId}/{uuid}.{ext}, expires in 15 minutes
  • Inserts unconfirmed listing_photos row (confirmed = false) with the pending/ key
  • Returns { uploadUrl: string, photoId: string, s3Key: string }
confirmPhotoUpload(event): Promise<APIGatewayProxyResult>
  • Input: { photoId: string }
  • Verifies that the S3 object exists at the pending/ key (calls S3 HeadObject)
  • Copies the object to photos/{listingId}/{uuid}.{ext}, deletes the pending/ object
  • Updates listing_photos row: sets s3_key to the photos/ key, confirmed = true
  • Returns { photo: ListingPhoto }
deletePhoto(event): Promise<APIGatewayProxyResult>
  • Validates ownership; deletes S3 object and listing_photos row
  • Returns { success: true }
updatePhotoOrder(event): Promise<APIGatewayProxyResult>
  • Input: { orderedPhotoIds: string[] } — full ordered list of photo IDs for the listing
  • Updates display_order on each photo in a single transaction
  • Returns updated photos: ListingPhoto[]

functions/listings/src/handlers/attributes.ts

setAmenities(event): Promise<APIGatewayProxyResult>
  • Input: { amenities: string[] }
  • Replaces all listing_amenities rows for the listing in a transaction (delete then insert)
  • Returns { amenities: string[] }
setAvailabilityRules(event): Promise<APIGatewayProxyResult>
  • Input: { rules: Array<{ ruleType, startDate?, endDate?, value?, bitmask? }> }
  • Validates that blocked_range rules have startDate and endDate; min_stay rules have value; checkin_days rules have a 7-character bitmask
  • Replaces all availability_rules for the listing in a transaction
  • Returns { rules: AvailabilityRule[] }
setPricingOverrides(event): Promise<APIGatewayProxyResult>
  • Input: { overrides: Array<{ overrideType, startDate?, endDate?, price }> }
  • Validates that seasonal overrides have startDate and endDate
  • Replaces all pricing_overrides for the listing in a transaction
  • Returns { overrides: PricingOverride[] }

functions/listings/src/handlers/connect.ts

getStripeOnboardingUrl(event): Promise<APIGatewayProxyResult>
  • Requires authentication; user must have guest or host role (any authenticated user can start host onboarding)
  • Creates a Stripe Connect account (if stripe_account_id is null on the user) or uses the existing one
  • Generates a Stripe Connect account link (onboarding type) with return_url and refresh_url pointing to the frontend
  • Returns { onboardingUrl: string }
handleStripeCallback(event): Promise<APIGatewayProxyResult>
  • Public route; called by Stripe redirect after onboarding
  • No action needed here — the actual verification update arrives via the account.updated webhook in Phase 6. This handler simply redirects the user to the frontend with a query param indicating they should wait for verification.
  • Returns a 302 redirect to the frontend onboarding-status page

Error handling for all Listings handlers: database errors use the same error handling contract as Phase 1's db.ts. Ownership violations throw AuthorizationError. Missing resources throw NotFoundError. Stripe API errors throw ExternalServiceError, logged via logger.

Deliverables

  • Host can create, read, update, and soft-delete a listing
  • Host can upload a photo via pre-signed URL, confirm it, reorder photos, and delete a photo
  • Host can set amenities, availability rules, and pricing overrides (full replace semantics)
  • evaluateAvailabilityRules rejects a blocked date, a stay shorter than the minimum, and a disallowed check-in day in unit tests
  • calculateStayPrice returns correct per-night breakdown applying seasonal, weekend, and base-rate priority in unit tests
  • Stripe Connect onboarding URL is generated and returned for an authenticated user

Phase 4: Search

This phase delivers the Search Lambda — the guest-facing listing discovery endpoint. It reads from tables established in Phase 1 and Phase 3, and imports ListingSummary from Phase 3's contracts. No writes occur in this phase.

Contracts

Search Lambda (functions/search/)

functions/search/src/index.ts

Single Lambda entry point:

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

One public route: GET /search

functions/search/src/handlers/search.ts

searchListings(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Query parameters (all optional):

{
  location?: string        // matched with ILIKE on listings.location
  checkIn?: string         // YYYY-MM-DD
  checkOut?: string        // YYYY-MM-DD
  guests?: string          // parsed to int
  minPrice?: string        // parsed to number
  maxPrice?: string        // parsed to number
  page?: string            // parsed to int, default 1
  limit?: string           // parsed to int, default 20, max 100
}

Validation (applied before any database query):

  • If checkIn is provided, checkOut must also be provided and must be after checkIn
  • guests, minPrice, maxPrice, page, limit must be positive numbers when provided
  • Past checkIn dates are rejected with ValidationError

Query logic:

  1. Build a parameterized SQL query against listings where status = 'active'
  2. Apply filters in order: ILIKE '%' || $location || '%' on location if provided; max_guests >= $guests if provided; base_price >= $minPrice AND base_price <= $maxPrice if provided
  3. If checkIn and checkOut are provided, add a subquery that excludes listing_id values where a confirmed booking's stay_range overlaps the requested range:
    AND id NOT IN (
      SELECT listing_id FROM bookings
      WHERE status IN ('confirmed', 'pending', 'pending_approval')
      AND stay_range && tsrange($checkIn::date, $checkOut::date)
    )
    
  4. Apply statement_timeout of 10 seconds on the search connection before executing
  5. Apply LIMIT $limit OFFSET ($page - 1) * $limit
  6. Return results as ListingSummary[] (cover photo is the listing_photos row with display_order = 1 and confirmed = true)

Response shape:

{
  listings: ListingSummary[]
  pagination: {
    page: number
    limit: number
    total: number    // COUNT(*) of the unfiltered query for this filter set
    hasMore: boolean
  }
}

On database timeout (query cancelled by statement_timeout): return ExternalServiceError('SEARCH_UNAVAILABLE', 'Search is temporarily unavailable', 503). On no results: return empty listings: [] with pagination, status 200.

Deliverables

  • GET /search with no filters returns paginated active listings
  • GET /search?location=Austin returns only listings with "Austin" in the location field
  • GET /search?checkIn=2025-08-01&checkOut=2025-08-05 excludes listings with a confirmed booking overlapping those dates
  • GET /search?checkIn=2025-08-05&checkOut=2025-08-01 returns a 400 ValidationError
  • Integration test: create two listings, create a confirmed booking on listing A for a date range, search that range — only listing B is returned

Phase 5: Booking and Payments Core

This phase delivers the Bookings Lambda — the most complex component in the system. It implements the full reservation lifecycle: instant-book and manual-approval creation, host acceptance/decline, guest and host cancellation, and the approval timeout path. It imports evaluateAvailabilityRules and calculateStayPrice from Phase 3. The Stripe integration for transfers and the Payout Scheduler are in Phase 7.

Contracts

Booking response shape

type Booking = {
  id: string
  listingId: string
  guestUserId: string
  checkInDate: string      // YYYY-MM-DD
  checkOutDate: string
  status: 'pending' | 'pending_approval' | 'confirmed' | 'declined'
         | 'cancelled_by_host' | 'cancelled_by_guest' | 'payment_failed'
  nights: number
  totalAmount: number
  serviceFee: number
  hostPayoutAmount: number
  stripePaymentIntentId: string | null
  payoutStatus: 'pending' | 'completed' | 'failed' | 'not_applicable'
  createdAt: string
  updatedAt: string
}

Bookings Lambda (functions/bookings/)

functions/bookings/src/router.ts

Single Lambda entry point (all routes protected):

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Route table:

  • POST /bookings → createBooking
  • GET /bookings/:id → getBooking
  • GET /bookings → listUserBookings (guest's own bookings)
  • GET /host/bookings → listHostBookings (bookings for host's listings)
  • POST /bookings/:id/approve → approveBooking
  • POST /bookings/:id/decline → declineBooking
  • POST /bookings/:id/cancel → cancelBooking

functions/bookings/src/handlers/create.ts

createBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Input:

{
  listingId: string
  checkInDate: string    // YYYY-MM-DD
  checkOutDate: string
  guestCount: number
}

Validation:

  • checkOut must be after checkIn; stay must be between 1 and 365 nights
  • guestCount must be a positive integer

Steps in order:

  1. Fetch the listing row. Throw NotFoundError if not found or status !== 'active'.
  2. Validate guestCount <= listing.max_guests; throw ValidationError if not.
  3. Call evaluateAvailabilityRules({ listingId, checkInDate, checkOutDate, db }). Throws ConflictError on rule violation.
  4. Call calculateStayPrice({ listingId, checkInDate, checkOutDate, db, serviceFeePercentage }) to get PriceBreakdown.
  5. Open a PostgreSQL serializable transaction.
  6. Insert the bookings row with status = 'pending' (instant-book) or status = 'pending_approval' (manual-approval). If the GiST exclusion constraint fires, PostgreSQL raises a constraint violation error — catch it, roll back, and throw ConflictError('DATES_UNAVAILABLE', 'This listing is no longer available for these dates — please try again', 409).
  7. Set approval_expires_at = now() + 24 hours if manual-approval.

Instant-book path only — steps after insert, still within the transaction: 8. Call Stripe to create and immediately capture a PaymentIntent for priceBreakdown.total (in cents) with application_fee_amount = priceBreakdown.serviceFee in cents, transfer_data.destination = listing host's stripe_account_id. If Stripe returns a payment failure, roll back the transaction. Throw PaymentError with a user-friendly message (map Stripe decline codes to human-readable strings — do not forward Stripe's raw error). 9. If Stripe succeeds: update the booking row status = 'confirmed', stripe_pi_id = paymentIntent.id. 10. Commit the transaction. 11. Send confirmation email via sendEmail to the guest. SES error does not roll back. 12. Return Booking.

Manual-approval path — steps after insert: 8. Call Stripe to create a PaymentIntent in requires_capture state (authorize only, do not capture). Store stripe_pi_id on the booking row. Commit the transaction. 9. Send a notification email to the host that a booking request is pending approval. 10. Return Booking with status = 'pending_approval'.

functions/bookings/src/handlers/approval.ts

approveBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
  • Caller must have host role; must own the listing referenced by the booking
  • Booking must be in pending_approval status and approval_expires_at must be in the future; otherwise throw ConflictError
  • Capture the Stripe PaymentIntent. On Stripe failure: update booking to payment_failed, throw PaymentError
  • On success: update booking to confirmed. Send confirmation email to guest.
  • Return Booking
declineBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
  • Caller must have host role and own the listing
  • Booking must be in pending_approval status
  • Cancel the Stripe PaymentIntent (releases the authorization)
  • Update booking to declined
  • Send decline notification email to guest
  • Return Booking

functions/bookings/src/handlers/cancel.ts

cancelBooking(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>
  • Caller is either the guest who made the booking or the host of the listing
  • Booking must be in confirmed or pending_approval status; otherwise throw ConflictError

Host-initiated cancellation:

  • Update booking to cancelled_by_host
  • Call Stripe Refund API for the full total_amount against the PaymentIntent
  • Insert row in refunds table with reason = 'host_cancellation'
  • Send email to guest confirming full refund
  • Return Booking

Guest-initiated cancellation:

  • Read cancellation_policy from the listing (flexible | moderate | strict)
  • Calculate refund amount:
    • flexible: full refund if check-in is more than 24 hours away; no refund otherwise
    • moderate: full refund if check-in is more than 5 days away; 50% refund if 1–5 days; no refund if less than 1 day
    • strict: 50% refund if check-in is more than 7 days away; no refund otherwise
    • All refund amounts are calculated against total_amount (including service fee for full refunds; service fee retained by platform for partial refunds — calculate refund against host_payout_amount + service_fee_if_applicable)
  • Call Stripe Refund API for the calculated amount (0 = no Stripe call needed)
  • Insert row in refunds table with reason = 'guest_cancellation'
  • Update booking to cancelled_by_guest
  • Send cancellation confirmation email to guest
  • Return Booking

functions/bookings/src/handlers/read.ts

getBooking(event): Promise<APIGatewayProxyResult>
  • Caller must be the guest on the booking, the host of the listing, or have admin or support role
  • Returns Booking
listUserBookings(event): Promise<APIGatewayProxyResult>
  • Returns all bookings where guest_user_id = callerId, paginated
  • Response: { bookings: Booking[], pagination: { page, limit, total, hasMore } }
listHostBookings(event): Promise<APIGatewayProxyResult>
  • Requires host role
  • Returns all bookings for listings where host_user_id = callerId, paginated
  • Response: { bookings: Booking[], pagination: { page, limit, total, hasMore } }

functions/bookings/src/lib/approvalTimeout.ts

This module is called by the Approval Timeout Scheduler in Phase 7 and by the Bookings Lambda's internal logic. It is defined here because its behavior is part of the booking state machine.

expireStaleApprovals(db: pg.Pool, stripe: Stripe): Promise<{ expired: number }>
  • Queries bookings where status = 'pending_approval' and approval_expires_at < now()
  • For each: cancel the Stripe PaymentIntent; update booking to declined; send decline email to guest
  • Processes bookings individually; a failure on one booking is logged and does not stop processing of the rest
  • Returns { expired: number } — count of bookings expired

Error handling for all Bookings handlers: same database error handling contract as Phase 1's db.ts. Stripe errors on capture or refund throw PaymentError with user-safe messages; raw Stripe error details are logged via logger but not returned to the client.

Deliverables

  • POST /bookings with an instant-book listing creates a confirmed booking and charges Stripe; concurrent requests for the same dates result in exactly one success and one ConflictError('DATES_UNAVAILABLE')
  • POST /bookings with a manual-approval listing creates a pending_approval booking with a Stripe authorization hold
  • Host can approve (captures payment → confirmed) or decline (cancels authorization → declined)
  • Host cancellation issues a full Stripe refund; guest cancellation applies the correct policy amount
  • expireStaleApprovals correctly identifies and declines bookings past their approval window in an integration test

Phase 6: Payments Webhook Handler and Users Lambda

This phase delivers two independent Lambda functions. The Payments Lambda (webhook handler) handles Stripe event callbacks and the recovery path for the partial-payment scenario. The Users Lambda handles profile management and the CCPA request flows. These are grouped into one phase because neither depends on the other and both close out the core request-response surface area before the scheduled Lambda work in Phase 7.

Contracts

Payments Lambda — Webhook Handler (functions/payments/)

functions/payments/src/index.ts

Single Lambda entry point (unauthenticated at API Gateway — no JWT authorizer on this route):

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Route: POST /webhooks/stripe

functions/payments/src/handlers/webhook.ts

handleStripeWebhook(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Steps in order:

  1. Extract the raw request body (as a string — must not be parsed before signature verification).
  2. Validate the Stripe webhook signature using STRIPE_WEBHOOK_SECRET from config. On invalid signature: log the attempt via logger, return a 200 response (to prevent Stripe from retrying a request from a non-Stripe source — this behavior departs from a straightforward reject because returning a non-2xx would cause Stripe to retry indefinitely).
  3. Check the stripe_webhook_events table for the event ID. If found, return 200 immediately (idempotency short-circuit).
  4. Insert the event ID into stripe_webhook_events with status = 'processed'.
  5. Route to the correct handler by event.type:

payment_intent.succeeded:

  • Look up booking by stripe_pi_id
  • If booking exists and status !== 'confirmed': update to confirmed (recovery path for partial-payment scenario)
  • If no booking exists: log a WARN-level structured log entry ({ event: 'orphaned_payment_intent', stripePaymentIntentId, timestamp }) and publish a CloudWatch custom metric OrphanedPaymentIntent — this is the alertable anomaly described in the ADD's Risk 1. The CloudWatch alarm for this metric is defined in the SAM template.
  • Return 200

payment_intent.payment_failed:

  • Look up booking by stripe_pi_id
  • If found: update status = 'payment_failed'
  • Return 200

transfer.failed:

  • Look up booking by stripe_transfer_id
  • If found: update payout_status = 'failed'
  • Log error via logger with full Stripe error details
  • Send operator alert email via sendEmail and post to Slack webhook URL from config
  • Return 200

account.updated:

  • Extract the connected account ID and verification status from the Stripe event payload
  • Update stripe_account_verified on the matching user row
  • If verified: add host to the user's roles array if not already present
  • Return 200

On database error during event processing: log the error, return a non-200 response so Stripe retries. Do not insert into stripe_webhook_events if the processing failed — the idempotency row should only exist for successfully processed events. This means the insert in step 4 must be part of the same transaction as the event handling, committed only on success.

Users Lambda (functions/users/)

functions/users/src/router.ts

Single Lambda entry point:

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

Route table (all protected):

  • GET /users/me → getProfile
  • PUT /users/me → updateProfile
  • POST /users/me/ccpa/deletion → requestDeletion
  • POST /users/me/ccpa/export → requestExport
  • POST /users/me/ccpa/opt-out → optOut

functions/users/src/handlers/profile.ts

getProfile(event): Promise<APIGatewayProxyResult>

Returns:

{
  id: string
  email: string
  displayName: string | null
  phone: string | null
  profilePhotoKey: string | null
  roles: string[]
  stripeAccountId: string | null
  stripeAccountVerified: boolean
  doNotSell: boolean
  createdAt: string
}
updateProfile(event): Promise<APIGatewayProxyResult>

Input (all optional):

{ displayName?: string, phone?: string, profilePhotoKey?: string }
  • profilePhotoKey must be a valid S3 key in the photos/ prefix (validated by checking the key format, not by calling S3)
  • Updates user row; returns updated profile shape from getProfile

functions/users/src/handlers/ccpa.ts

requestDeletion(event): Promise<APIGatewayProxyResult>
  • Sets deletion_requested_at = now(), deletion_status = 'pending' on the user row
  • Inserts a ccpa_requests row with request_type = 'deletion', status = 'pending'
  • Returns { message: 'Deletion request received. Your data will be removed within 30 days.', requestId: string }
requestExport(event): Promise<APIGatewayProxyResult>
  • Inserts a ccpa_requests row with request_type = 'export', status = 'pending'
  • The actual export assembly is handled by the CCPA Scrubber Lambda in Phase 7; this endpoint only queues the request
  • Returns { message: 'Export request received. You will receive an email with your data within 24 hours.', requestId: string }
optOut(event): Promise<APIGatewayProxyResult>
  • Sets do_not_sell = true on the user row
  • Inserts a ccpa_requests row with request_type = 'opt_out', status = 'completed' (immediate)
  • Returns { message: 'You have been opted out of data sharing.', doNotSell: true }

Error handling for Users and Payments handlers: same database error contract as Phase 1's db.ts.

Deliverables

  • Stripe webhook for payment_intent.succeeded with no matching booking row creates a CloudWatch metric event and does not throw an unhandled error
  • Webhook handler returns 200 on a duplicate event ID without reprocessing
  • Webhook handler returns 200 (not 4xx) on an invalid signature
  • requestDeletion sets deletion_status = 'pending' and inserts a CCPA request row
  • optOut sets do_not_sell = true
  • getProfile and updateProfile return and modify the correct fields

Phase 7: Scheduled Lambdas

This phase delivers the three EventBridge-triggered Lambda functions: the Payout Scheduler (hourly), the Approval Timeout Scheduler (every 15 minutes), and the CCPA Scrubber (nightly at 02:00 UTC). It also delivers the data archive job (yearly). Each scheduled Lambda is independently deployable and idempotent — re-running it produces the same outcome as running it once.

Contracts

Payout Scheduler Lambda (functions/payoutScheduler/)

functions/payoutScheduler/src/index.ts

EventBridge-triggered entry point:

handler(event: ScheduledEvent): Promise<void>

functions/payoutScheduler/src/payout.ts

runPayoutSchedule(db: pg.Pool, stripe: Stripe): Promise<{ attempted: number, succeeded: number, failed: number }>

Steps in order:

  1. Query bookings where status = 'confirmed' and check_in_date < now() and payout_status = 'pending'
  2. For each booking: a. Fetch the host's stripe_account_id from the users table b. Verify stripe_account_verified = true; if not, skip and log a warning c. Call Stripe to create a Transfer for host_payout_amount (in cents) to the host's connected Stripe account, with the booking_id as the transfer's metadata key for traceability d. On Transfer success: update payout_status = 'completed', stripe_transfer_id = transfer.id e. On Transfer failure: update payout_status = 'failed'; log full Stripe error via logger; send operator alert email via sendEmail and post to Slack webhook
  3. Each booking is processed in its own try/catch — a failure on one does not stop the rest
  4. Return { attempted, succeeded, failed }

Idempotency guarantee: the payout_status = 'pending' check in step 1 means a booking is never processed twice. If the Lambda re-runs within the same hour (e.g. due to EventBridge duplicate invocation), already-completed payouts are not in the query results.

functions/payoutScheduler/src/approvalTimeout.ts

handler(event: ScheduledEvent): Promise<void>

Calls expireStaleApprovals(db, stripe) from Phase 5's functions/bookings/src/lib/approvalTimeout.ts. The Approval Timeout Scheduler Lambda imports and invokes that function directly — no logic is duplicated.

SAM template adds two EventBridge rules:

  • Payout Scheduler: every hour on the hour
  • Approval Timeout Scheduler: every 15 minutes

Both scheduled Lambdas have an SQS DLQ configured. A CloudWatch alarm on DLQ message count fires immediately on any failed invocation.

CCPA Scrubber Lambda (functions/ccpaScrubber/)

functions/ccpaScrubber/src/index.ts

EventBridge-triggered entry point (cron: 0 2 * * ? * — 02:00 UTC nightly):

handler(event: ScheduledEvent): Promise<void>

functions/ccpaScrubber/src/scrub.ts

runScrubJob(db: pg.Pool, s3: S3Client): Promise<{ scrubbed: number, failed: number }>

Steps in order:

  1. Query users where deletion_status = 'pending' and deletion_requested_at < now() - interval '30 days'
  2. Process in batches of 50. Between each batch, pause 100ms.
  3. For each user, in a transaction: a. Set email = null, display_name = null, phone = null, profile_photo_key = null, password_hash = 'SCRUBBED' on the user row b. Set deletion_status = 'completed' c. Update the corresponding ccpa_requests row to status = 'completed' d. Commit the transaction e. Attempt to delete the profile photo S3 object if profile_photo_key was set. On S3 failure: log and continue — the database scrub is not rolled back.
  4. Return { scrubbed, failed }

Each user is processed in its own try/catch. A failure on one user leaves deletion_status = 'pending' and it will be retried on the next nightly run.

runExportJob(db: pg.Pool, s3: S3Client, ses: SESClient): Promise<{ exported: number, failed: number }>

Steps in order:

  1. Query ccpa_requests where request_type = 'export' and status = 'pending'
  2. For each request: a. Fetch the user's profile, bookings, and CCPA request history from the database b. Assemble export payload as JSON: { profile: {...}, bookings: Booking[], ccpaRequests: [...] } c. Write the JSON to S3 at key exports/{userId}/{requestId}.json d. Generate a pre-signed GET URL for the S3 object, expiring in 48 hours e. Send email to the user's current email address (or skip if email is null — user already scrubbed) with the pre-signed URL f. Update ccpa_requests row to status = 'completed'
  3. Return { exported, failed }

functions/ccpaScrubber/src/hostFollowUp.ts

Also runs as part of the nightly job:

sendHostOnboardingFollowUps(db: pg.Pool): Promise<void>
  • Queries users where stripe_account_id IS NOT NULL and stripe_account_verified = false and the onboarding link was requested more than 24 hours ago (use updated_at on the user row as the proxy — the updated_at timestamp is set when stripe_account_id is first written)
  • Sends a follow-up email via sendEmail reminding the host to complete onboarding
  • Sends at most one follow-up per user per day (check updated_at on the user row — if it was updated within the last 24 hours by this function, skip)

functions/ccpaScrubber/src/archiveOldBookings.ts

Runs yearly (separate EventBridge rule: first day of the year at 03:00 UTC):

archiveOldBookings(db: pg.Pool, s3: S3Client): Promise<{ archived: number }>
  • Queries bookings where check_out_date < now() - interval '3 years'
  • Exports matching rows as a JSON file to S3 at key archives/bookings/{year}.json
  • Deletes the exported rows from the bookings table in batches of 500
  • Returns { archived: number }

Deliverables

  • Payout Scheduler processes a confirmed, post-check-in booking with payout_status = 'pending'; after one run, payout_status = 'completed' and stripe_transfer_id is populated; a second run does not attempt a duplicate transfer
  • Approval Timeout Scheduler expires a pending_approval booking past its 24-hour window: booking moves to declined, Stripe authorization is cancelled
  • CCPA Scrubber scrubs PII fields on a user past the 30-day grace period; booking rows for that user are retained; user's deletion_status = 'completed'
  • CCPA export job assembles and emails a pre-signed S3 URL to a user with a pending export request
  • Host follow-up email is sent to an unverified host 24 hours after onboarding initiation
  • All three scheduled Lambdas have SQS DLQs configured in the SAM template and visible in the AWS Console

Phase 8: Admin Lambda

This phase delivers the Admin Lambda — internal-only endpoints for platform operators and support agents. It reads from all tables established in earlier phases and writes only via narrowly scoped operations (listing deactivation, payout retry). Support agents are explicitly read-only.

Contracts

Admin Lambda (functions/admin/)

functions/admin/src/router.ts

Single Lambda entry point:

handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>

All routes require admin or support role. Write operations additionally check for admin role and throw AuthorizationError if only support is present.

Route table:

  • GET /admin/users → listUsers
  • GET /admin/users/:id → getUser
  • GET /admin/listings → listListings
  • GET /admin/listings/:id → getListing
  • PUT /admin/listings/:id/deactivate → deactivateListing (admin only)
  • GET /admin/bookings → listBookings
  • GET /admin/bookings/:id → getBooking
  • POST /admin/bookings/:id/retry-payout → retryPayout (admin only)
  • GET /admin/users/:id/impersonate → impersonateUser

functions/admin/src/handlers/users.ts

listUsers(event): Promise<APIGatewayProxyResult>

Query parameters: page, limit, deletionStatus?, roles? (comma-separated filter) Returns paginated array of user profiles (same shape as getProfile from Phase 6, plus loginAttemptCount and loginLockedUntil for operator visibility).

getUser(event): Promise<APIGatewayProxyResult>

Returns the full user profile shape plus loginAttemptCount and loginLockedUntil.

functions/admin/src/handlers/listings.ts

listListings(event): Promise<APIGatewayProxyResult>

Query parameters: page, limit, status?, hostUserId? Returns paginated ListingSummary[].

getListing(event): Promise<APIGatewayProxyResult>

Returns the full Listing shape from Phase 3.

deactivateListing(event): Promise<APIGatewayProxyResult>
  • Admin only
  • Sets status = 'inactive' on the listing
  • Returns { success: true, listingId: string }

functions/admin/src/handlers/bookings.ts

listBookings(event): Promise<APIGatewayProxyResult>

Query parameters: page, limit, status?, listingId?, guestUserId? Returns paginated Booking[].

getBooking(event): Promise<APIGatewayProxyResult>

Returns Booking.

retryPayout(event): Promise<APIGatewayProxyResult>
  • Admin only
  • Booking must have payout_status = 'failed'; throws ConflictError if not
  • Resets payout_status = 'pending' on the booking row
  • Calls runPayoutSchedule from Phase 7's payout module, scoped to just this booking ID (the function accepts an optional bookingIds: string[] filter added for this purpose — the SAM template's payout scheduler always calls it without the filter, this endpoint calls it with [bookingId])
  • Returns { success: true, booking: Booking }

functions/admin/src/handlers/impersonate.ts

impersonateUser(event): Promise<APIGatewayProxyResult>
  • Available to both admin and support roles
  • Fetches the target user's profile and their last 10 bookings
  • Returns a read-only context payload — does not issue a token for the target user, does not allow any write on their behalf
  • Response: { user: UserProfile, recentBookings: Booking[], note: 'Read-only support context' }

Error handling: same database error contract as Phase 1's db.ts. AuthorizationError is thrown for any write operation attempted by a support role caller.

Deliverables

  • GET /admin/users returns paginated users when called with an admin token
  • GET /admin/users returns 403 when called with a guest or host token
  • PUT /admin/listings/:id/deactivate sets the listing inactive when called with an admin token; returns 403 when called with a support token
  • POST /admin/bookings/:id/retry-payout resets a failed payout to pending and triggers the transfer
  • GET /admin/users/:id/impersonate returns a read-only context for both admin and support callers

Phase 9: Frontend

This phase delivers the React 18 SPA — the browser-facing client that consumes the API built in Phases 2–8. The frontend calls API Gateway over HTTPS and uses Stripe.js for card collection. No direct AWS service calls from the browser.

Contracts

Project structure (frontend/)

frontend/
  src/
    api/          # API client modules (one per Lambda domain)
    components/   # Shared UI components
    pages/        # Route-level page components
    hooks/        # Custom React hooks
    store/        # Auth state (token storage and refresh logic)
    types/        # Shared TypeScript types (imported from backend contracts)
    utils/        # Date formatting, currency formatting, error display
  public/
  index.html

API client layer (frontend/src/api/)

One module per backend domain. All modules share a single base fetchApi function that:

  • Attaches Authorization: Bearer <accessToken> on every request
  • Intercepts 401 responses: attempts a token refresh via POST /auth/refresh, retries the original request once with the new token, redirects to login if refresh fails
  • Returns typed response data or throws a structured ApiError
// frontend/src/api/client.ts
fetchApi<T>(path: string, options?: RequestInit): Promise<T>

class ApiError extends Error {
  constructor(public code: string, message: string, public status: number)
}

Domain modules and their exported functions:

frontend/src/api/auth.ts

register(body: { email, password, displayName }): Promise<AuthResponse>
login(body: { email, password }): Promise<AuthResponse>
refresh(body: { refreshToken }): Promise<AuthResponse>
logout(body: { refreshToken }): Promise<void>

type AuthResponse = { accessToken: string, refreshToken: string, user: UserProfile }

frontend/src/api/listings.ts

createListing(body: CreateListingInput): Promise<Listing>
getListing(id: string): Promise<Listing>
updateListing(id: string, body: Partial<CreateListingInput>): Promise<Listing>
deleteListing(id: string): Promise<void>
getPhotoUploadUrl(listingId: string, body: { filename, contentType }): Promise<{ uploadUrl, photoId, s3Key }>
confirmPhotoUpload(listingId: string, body: { photoId }): Promise<{ photo: ListingPhoto }>
deletePhoto(listingId: string, photoId: string): Promise<void>
updatePhotoOrder(listingId: string, body: { orderedPhotoIds: string[] }): Promise<{ photos: ListingPhoto[] }>
setAmenities(listingId: string, body: { amenities: string[] }): Promise<{ amenities: string[] }>
setAvailabilityRules(listingId: string, body: { rules: AvailabilityRule[] }): Promise<{ rules: AvailabilityRule[] }>
setPricingOverrides(listingId: string, body: { overrides: PricingOverride[] }): Promise<{ overrides: PricingOverride[] }>
getStripeOnboardingUrl(): Promise<{ onboardingUrl: string }>

frontend/src/api/search.ts

searchListings(params: SearchParams): Promise<SearchResponse>

type SearchParams = { location?: string, checkIn?: string, checkOut?: string, guests?: number, minPrice?: number, maxPrice?: number, page?: number, limit?: number }
type SearchResponse = { listings: ListingSummary[], pagination: { page, limit, total, hasMore } }

frontend/src/api/bookings.ts

createBooking(body: { listingId, checkInDate, checkOutDate, guestCount }): Promise<Booking>
getBooking(id: string): Promise<Booking>
listUserBookings(params?: { page?, limit? }): Promise<{ bookings: Booking[], pagination: PaginationMeta }>
listHostBookings(params?: { page?, limit? }): Promise<{ bookings: Booking[], pagination: PaginationMeta }>
approveBooking(id: string): Promise<Booking>
declineBooking(id: string): Promise<Booking>
cancelBooking(id: string): Promise<Booking>

frontend/src/api/users.ts

getProfile(): Promise<UserProfile>
updateProfile(body: { displayName?, phone?, profilePhotoKey? }): Promise<UserProfile>
requestDeletion(): Promise<{ message: string, requestId: string }>
requestExport(): Promise<{ message: string, requestId: string }>
optOut(): Promise<{ message: string, doNotSell: boolean }>

Auth store (frontend/src/store/authStore.ts)

Manages access token, refresh token, and user profile in memory (access token) and localStorage (refresh token only — access token is never persisted to storage to minimize XSS exposure surface).

type AuthStore = {
  accessToken: string | null
  user: UserProfile | null
  isAuthenticated: boolean
  login(accessToken: string, refreshToken: string, user: UserProfile): void
  logout(): void
  setAccessToken(token: string): void  // called by the refresh interceptor
}

Pages (frontend/src/pages/)

Page components correspond to user-facing flows. Each page is a route component. Routing is handled by React Router v6.

Page list and their API calls:

  • HomePage — Search bar; calls searchListings on submit; renders ListingCard grid
  • SearchResultsPage — URL-parameterized search; calls searchListings; pagination controls
  • ListingDetailPage — calls getListing; renders photos, amenities, pricing, availability; booking form
  • BookingConfirmPage — Stripe.js payment element (hosted iframe); calls createBooking on submit
  • GuestBookingsPage — calls listUserBookings; renders booking status cards
  • HostDashboardPage — calls listHostBookings; links to listing management
  • HostListingFormPage — creates or edits a listing; calls createListing / updateListing; photo upload flow with progress indicator
  • HostOnboardingPage — calls getStripeOnboardingUrl; redirects browser to Stripe-hosted onboarding
  • ProfilePage — calls getProfile / updateProfile; CCPA controls (requestDeletion, requestExport, optOut)
  • LoginPage — calls login; on success stores tokens via auth store
  • RegisterPage — calls register
  • AdminPage — visible only to admin or support role; calls admin API endpoints

Photo upload flow (frontend/src/hooks/usePhotoUpload.ts)

usePhotoUpload(listingId: string): {
  upload(file: File): Promise<ListingPhoto>
  uploading: boolean
  error: string | null
}

Steps within upload:

  1. Call getPhotoUploadUrl with file.name and file.type
  2. PUT the file directly to the returned

Tickets

Tickets Document

Here is your step-by-step ticket backlog to build Rentify. Whether you're pasting these sequentially into Claude Code or importing them into Linear for your team, these tickets provide the exact implementation steps to bring this architecture to life.

Phase 1: Infrastructure Foundation and Shared Layer

Ticket 1.1: Foundational AWS Resources (S3, CloudFront, API Gateway)

Objective: Define the core non-database AWS resources in the SAM template, including S3 buckets for storage, a CloudFront distribution for the frontend, and the API Gateway endpoint.

Context: This ticket establishes the initial infrastructure skeleton as defined in the Architecture Design Document. Creating the S3 buckets, CloudFront distribution, and API Gateway first provides the basic storage and request-handling layers upon which all other services will depend. This work is part of the template.yaml contract.

Technical Directives:

  • All resources must be defined within a single AWS SAM template.yaml file.
  • The CloudFront distribution must be configured with an Origin Access Control (OAC) to privately access the frontend S3 bucket.
  • The CloudFront response headers policy must be configured to enforce Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, and Referrer-Policy as specified in the implementation plan.
  • The Content-Security-Policy must restrict script sources to the CDN origin and the Stripe.js domain.
  • The API Gateway must be configured to read CORS allowed origins, throttle burst limits, and throttle steady-state rates from SSM Parameter Store.
  • The PhotosBucket S3 resource must include a lifecycle rule to delete objects under the pending/ prefix after 24 hours.

Scope Boundaries:

  • AWS SAM template (template.yaml)
  • S3 resources
  • CloudFront resources
  • API Gateway resources

Acceptance Criteria:

  • The SAM template can be successfully deployed to an AWS account.
  • Three S3 buckets (PhotosBucket, ExportsBucket, FrontendBucket) are created.
  • The PhotosBucket has a lifecycle rule configured for the pending/ prefix.
  • A CloudFront distribution is created and points to the FrontendBucket.
  • When accessing the CloudFront URL, the browser receives the specified security headers in the response.
  • An API Gateway REST API is created with CORS and throttling configurations placeholder-ready for SSM parameters.

Dependencies: None


Ticket 1.2: Database Infrastructure (RDS, RDS Proxy)

Objective: Add the PostgreSQL RDS instance and RDS Proxy resources to the SAM template.

Context: This ticket provisions the stateful data layer as specified in the Architecture Design Document. The RDS Proxy is a critical component that enables stateless Lambda functions to communicate with the PostgreSQL database without exhausting connections. This completes the core data infrastructure required by all application logic.

Technical Directives:

  • The RDS instance must be PostgreSQL version 15.
  • The SAM template must conditionally enable Multi-AZ for the RDS instance only when the deployment environment is production.
  • The RDS instance size must be parameterized for different environments (db.t3.micro for non-production, db.t3.medium for production).
  • The RDS Proxy must be configured to use IAM authentication, connecting Lambda functions to the database without stored credentials.
  • The RDS Proxy must be connected to the created RDS instance and configured with a maximum of 100 connections.

Scope Boundaries:

  • AWS SAM template (template.yaml)
  • RDS resources
  • RDS Proxy resources

Acceptance Criteria:

  • The SAM template successfully deploys the new RDS and RDS Proxy resources.
  • An RDS instance running PostgreSQL 15 is active.
  • An RDS Proxy is created and shows as connected to the RDS instance in the AWS console.
  • The RDS Proxy is configured for IAM database authentication.

Dependencies:

  • Ticket 1.1

Ticket 1.3: Database Schema and Migrations

Objective: Create the complete set of SQL migration files to define the entire application database schema, including all tables, indexes, and constraints.

Context: This ticket implements the full data architecture specified in the ADD. By defining the entire schema upfront, subsequent development phases can build features against a stable and complete data model. The GiST exclusion constraint on the bookings table is the most critical piece, as it mathematically prevents double-bookings.

Technical Directives:

  • Create SQL migration files under an infrastructure/migrations/ directory.
  • The schema must exactly match all tables, columns, types, and constraints specified in the Target Phase documentation.
  • The btree_gist extension must be enabled before creating the GiST exclusion constraint.
  • The GiST exclusion constraint on the bookings table must prevent overlapping stay_range values for the same listing_id for bookings in pending, pending_approval, or confirmed status.
  • All indexes specified in the Indexing Strategy section of the ADD must be created.
  • A unique index must be created on the stripe_event_id column of the stripe_webhook_events table.

Scope Boundaries:

  • Database schema (SQL migration files)

Acceptance Criteria:

  • The SQL migration scripts execute successfully against the provisioned PostgreSQL database from start to finish.
  • All specified tables (users, bookings, listings, etc.) are created with the correct columns and types.
  • The no_overlapping_bookings exclusion constraint exists on the bookings table.
  • All specified indexes are present on their respective tables.
  • An attempt to insert two identical, overlapping bookings for the same listing fails with a database constraint violation.

Dependencies:

  • Ticket 1.2

Ticket 1.4: Shared Utilities and Error Handling (packages/shared)

Objective: Implement the core, non-I/O modules of the shared Lambda Layer package, including custom errors, structured logger, API response helpers, and validation logic.

Context: This ticket creates the foundational code utilities that all Lambda functions will use for consistency in error handling, logging, and response formatting, as outlined in the ADD. Establishing these patterns in a shared layer prevents code duplication and enforces architectural standards across all microservices.

Technical Directives:

  • Create a new TypeScript package at packages/shared/.
  • Implement all custom error classes (RentifyError, ValidationError, etc.) in errors.ts exactly as defined in the phase contract.
  • Implement the logger.ts module as a thin wrapper around @aws-lambda-powertools/logger that exports a pre-configured singleton instance for structured JSON logging.
  • Implement response.ts with successResponse and errorResponse functions that build valid API Gateway proxy results. The errorResponse function must not expose internal error details in the response body.
  • Implement validation.ts with a validate helper that wraps Zod parsing and throws the custom ValidationError on failure.
  • Export all shared Zod schemas (uuidSchema, dateSchema, paginationSchema) from validation.ts.

Scope Boundaries:

  • packages/shared/ TypeScript module

Acceptance Criteria:

  • The packages/shared/ package compiles without TypeScript errors.
  • Unit tests confirm that errorResponse correctly formats RentifyError instances and generic Error instances into the specified JSON structure.
  • Unit tests confirm that the validate function returns the parsed value on success and throws a ValidationError on failure.

Dependencies: None


Ticket 1.5: Shared I/O and Configuration Utilities (packages/shared)

Objective: Implement the I/O-dependent modules of the shared Lambda Layer package for configuration, database access, and email.

Context: This ticket builds on the core utilities to provide standardized ways for Lambda functions to interact with external services like SSM, RDS, and SES. Centralizing this logic in the shared layer, as per the ADD, ensures all functions use the same caching, connection, and error handling strategies for these critical dependencies.

Technical Directives:

  • Implement the config.ts module with a getConfig function that fetches parameters from an SSM path and caches the result. It must throw a ConfigurationError if required parameters are missing.
  • Implement the db.ts module with a getDbClient function that returns a configured pg.Pool instance. It must throw a DatabaseConnectionError on connection failure.
  • Implement the email.ts module with a sendEmail function that sends email via the AWS SES API. SES errors must be logged but not re-thrown.
  • The index.ts file must re-export all public functions and types from all modules in the packages/shared/ directory.

Scope Boundaries:

  • packages/shared/ TypeScript module

Acceptance Criteria:

  • The packages/shared/ package compiles without TypeScript errors.
  • In an integration test, getConfig successfully fetches and parses parameters from SSM.
  • In an integration test, getDbClient successfully connects to the RDS Proxy endpoint and executes a simple query.
  • In an integration test, sendEmail successfully sends a test email using the SES sandbox.

Dependencies:

  • Ticket 1.4

Ticket 1.6: Lambda Layer Definition and Build Process

Objective: Define the SharedLambdaLayer resource in the SAM template and configure the build process to package the shared TypeScript code into it.

Context: This ticket connects the shared code from the previous tickets to the AWS infrastructure. The Lambda Layer is a key part of the architecture (ADR-1), allowing for code reuse and smaller Lambda function deployment packages. This step makes the shared utilities available to all future Lambda functions.

Technical Directives:

  • Add a SharedLambdaLayer resource of type AWS::Serverless::LayerVersion to template.yaml.
  • The layer resource's content URI must point to a build artifact containing the compiled JavaScript and node_modules for the packages/shared directory.
  • Configure the SAM build process (e.g., via Metadata in template.yaml or a build script) to automatically transpile the TypeScript from packages/shared/ to JavaScript before deployment.

Scope Boundaries:

  • AWS SAM template (template.yaml)
  • Build configuration (e.g., package.json scripts, SAM metadata)

Acceptance Criteria:

  • Running sam build successfully transpiles the packages/shared TypeScript code and prepares the layer artifact.
  • Running sam deploy successfully creates or updates the SharedLambdaLayer in AWS Lambda.
  • The created Lambda Layer contains the compiled JavaScript files corresponding to the shared package modules.

Dependencies:

  • Ticket 1.1
  • Ticket 1.5

Ticket 1.7: Environment Configuration and Deployment Verification

Objective: Create the samconfig.toml file to manage environment-specific deployment parameters and perform a full deployment of the entire infrastructure stack.

Context: This final ticket ties all previous work together by defining the environment-specific configurations required for deployment. It ensures that the entire foundation is deployable and functional, providing a stable base for the application-level features that will be built in subsequent phases.

Technical Directives:

  • Create a samconfig.toml file at the root of the project.
  • Define deployment configurations for dev, staging, and production environments.
  • Specify parameter overrides for all environment-specific values in the SAM template, such as RDS instance size, Multi-AZ setting, and references to SSM parameter paths.
  • The configuration must successfully deploy the entire stack defined in the preceding tickets.

Scope Boundaries:

  • samconfig.toml file
  • Deployment process

Acceptance Criteria:

  • The command sam deploy --stack-name rentify-dev --config-env dev completes successfully.
  • All AWS resources defined in previous tickets (S3 buckets, CloudFront, API Gateway, RDS, RDS Proxy, Lambda Layer) are present and correctly configured in the dev environment.
  • The RDS instance is accessible via the RDS Proxy.
  • The CloudFront distribution serves a placeholder file from its S3 origin.
  • The created SharedLambdaLayer is available to be attached to Lambda functions in the Following Phase.

Dependencies:

  • Ticket 1.2
  • Ticket 1.3
  • Ticket 1.6

Phase 2: Authentication

Ticket 2.1: Implement JWT Signing and Verification Utility

Objective: This ticket creates a shared utility for generating and validating JSON Web Tokens (JWTs) according to the project's defined payload structure.

Context: This utility is a core security component required by both the Auth Lambda and the Lambda Authorizer. The Auth Lambda will use it to sign tokens upon successful registration or login, and the Lambda Authorizer will use it to verify the signature of incoming tokens. It implements the standard JWT payload shape defined in the Authentication phase contract.

Technical Directives:

  • Create a utility function to sign a JWT payload, which must include sub, email, roles, iat, and exp claims.
  • Create a utility function to verify a JWT's signature and expiry.
  • The signing and verification process must use the JWT_SECRET retrieved from the shared configuration module.
  • The utility should handle access tokens with a 15-minute expiry and refresh tokens with a 30-day expiry.

Scope Boundaries:

  • Shared Lambda Layer (packages/shared/)

Acceptance Criteria:

  • The signing function, when given a user ID, email, and roles, produces a valid JWT string.
  • The verification function, when given a valid token, returns the decoded payload.
  • The verification function rejects a token with an invalid signature.
  • The verification function rejects a token that is expired.
  • The verification function rejects a token with a missing required claim.

Dependencies: None


Ticket 2.2: Implement User Registration Handler

Objective: This ticket creates the POST /auth/register endpoint to allow new users to create an account.

Objective: This ticket creates the POST /auth/register endpoint, enabling new users to create an account by providing an email, password, and display name.

Context: The registration handler is the primary entry point for users into the Rentify platform. As described in the ADD, it creates the user record with default guest permissions and issues the initial set of authentication tokens. This handler is part of the Auth Lambda.

Technical Directives:

  • The handler must validate that the input email is a valid format and the password is at least 8 characters long.
  • Use the shared validate helper for input validation and throw a ValidationError for invalid data.
  • Before creating a user, query the database to ensure an account with the provided email does not already exist. If it does, throw the ConflictError as specified in the phase contract.
  • Hash the user's password using bcrypt with a work factor of 12 before storing it in the users table.
  • On successful user creation, insert a new row into the users table with the default role of guest.
  • Generate a new access token and a new refresh token using the JWT utility.
  • Hash the refresh token and store it in the refresh_tokens table, associated with the new user ID.
  • The response body must conform to the contract: { accessToken, refreshToken, user: { id, email, displayName, roles } }.

Scope Boundaries:

  • Auth Lambda (functions/auth/)

Acceptance Criteria:

  • A POST request to /auth/register with a unique email, valid password, and display name results in a new row in the users table.
  • The password_hash column in the new users row contains a valid bcrypt hash.
  • A new row is created in the refresh_tokens table linked to the new user.
  • The API returns a valid access token, refresh token, and user object.
  • A POST request with an email that already exists returns a 409 Conflict response with the code EMAIL_IN_USE.
  • A POST request with a password shorter than 8 characters returns a 400 Bad Request response.

Dependencies: Ticket 2.1


Ticket 2.3: Implement User Login Handler with Brute-Force Protection

Objective: This ticket creates the POST /auth/login endpoint to authenticate existing users and implements an account lockout mechanism to prevent brute-force attacks.

Context: This handler provides the primary authentication mechanism for the system. Following the security requirements in the ADD, it includes logic to track failed login attempts and temporarily lock an account after repeated failures, mitigating the risk of password guessing attacks.

Technical Directives:

  • The handler must validate the email and password fields in the request body.
  • It must look up the user by email. To prevent timing-based user enumeration attacks, the password comparison logic must execute whether the user is found or not.
  • Compare the provided password with the stored hash using bcrypt.
  • If the password is correct and the account is not locked, reset login_attempt_count to 0 and clear the login_locked_until timestamp.
  • If the password is incorrect, increment login_attempt_count. If the count reaches 10, set login_locked_until to 15 minutes in the future.
  • If a login attempt is made for an account where login_locked_until is in the future, reject the attempt immediately without checking the password.
  • In all failure cases (user not found, password incorrect, account locked), return the generic AuthenticationError to avoid revealing account status.
  • On successful authentication, issue and return a new set of access and refresh tokens, identical to the registration flow.

Scope Boundaries:

  • Auth Lambda (functions/auth/)

Acceptance Criteria:

  • A POST request to /auth/login with correct credentials for an existing user returns a new access token and refresh token.
  • The login_attempt_count is reset to 0 in the database upon successful login.
  • A POST request with an incorrect password returns a 401 Unauthorized response.
  • The login_attempt_count for the user increments after each failed login attempt.
  • After the 10th consecutive failed login attempt, login_locked_until is set to a future timestamp, and subsequent login attempts fail until it expires.
  • A POST request with a non-existent email address returns a 401 Unauthorized response.

Dependencies: Ticket 2.2


Ticket 2.4: Implement Token Refresh Handler

Objective: This ticket creates the POST /auth/refresh endpoint to allow clients to exchange a valid refresh token for a new set of access and refresh tokens.

Context: This handler supports the long-lived session management described in the ADD. By allowing clients to use a long-lived refresh token to obtain a new short-lived access token, it improves security by minimizing the exposure of the access token while providing a seamless user experience.

Technical Directives:

  • The handler must accept a refreshToken in the request body.
  • Hash the received token to find a matching, non-revoked, and unexpired token in the refresh_tokens table.
  • If no valid token is found, throw the AuthenticationError as specified in the phase contract.
  • Upon finding a valid token, mark it as revoked by setting revoked = true in the database.
  • Issue a new access token (15-minute expiry) and a new refresh token (30-day expiry).
  • Store the hash of the new refresh token in the refresh_tokens table.
  • Return the new accessToken and refreshToken.

Scope Boundaries:

  • Auth Lambda (functions/auth/)

Acceptance Criteria:

  • A POST request to /auth/refresh with a valid, unrevoked refresh token returns a new access token and a new refresh token.
  • The old refresh token's row in the refresh_tokens table has its revoked column set to true.
  • A new row for the new refresh token is created in the refresh_tokens table.
  • A POST request with an invalid, expired, or already-revoked refresh token returns a 401 Unauthorized response.

Dependencies: Ticket 2.2


Ticket 2.5: Implement Lambda Authorizer

Objective: This ticket creates the Lambda Authorizer function responsible for validating JWTs and controlling access to protected API Gateway routes.

Context: As the central security gate for the API, the Lambda Authorizer enforces authentication for all protected endpoints as defined in the ADD. It decodes valid tokens and injects user identity information into the Lambda event context for downstream handlers to use for authorization checks.

Technical Directives:

  • The authorizer must extract the bearer token from the Authorization header of the incoming request.
  • Use the JWT verification utility to validate the token's signature and expiry.
  • If the token is valid, generate and return an Allow IAM policy for the requested API endpoint ARN.
  • The Allow policy's context object must contain the sub, email, and roles claims from the decoded JWT payload. The roles array must be stringified using JSON.stringify.
  • If the token is missing, malformed, or invalid, generate and return a Deny IAM policy.
  • The authorizer must not connect to the database.

Scope Boundaries:

  • Lambda Authorizer (functions/authorizer/)

Acceptance Criteria:

  • An API Gateway request with a valid Authorization: Bearer <token> header results in the authorizer returning an Allow policy.
  • The Allow policy's context object contains the correct sub, email, and a stringified roles array.
  • A request with a missing Authorization header results in a Deny policy.
  • A request with an invalid or expired token results in a Deny policy.

Dependencies: Ticket 2.1


Ticket 2.6: Implement Auth Lambda Router and Logout Handler

Objective: This ticket creates the internal router for the Auth Lambda and implements the protected POST /auth/logout endpoint.

Context: This ticket completes the Auth Lambda by adding the routing logic to direct API Gateway requests to the correct handler (register, login, etc.) and implementing the logout functionality. The logout handler is the first endpoint in the system to be protected by the new Lambda Authorizer, serving as a key integration test.

Technical Directives:

  • Create a router that maps incoming API Gateway events to the correct handler function based on the HTTP method and resource path (POST /auth/register, POST /auth/login, etc.).
  • The router must be the single entry point for the Auth Lambda.
  • The logout handler must be a protected route.
  • The logout handler must accept a refreshToken in the request body.
  • It must hash the received token and find the matching record in the refresh_tokens table.
  • It must set the revoked column to true for the matching refresh token, invalidating it for future use.

Scope Boundaries:

  • Auth Lambda (functions/auth/)

Acceptance Criteria:

  • The Auth Lambda's main handler correctly routes a POST request to /auth/register to the registration logic.
  • The Auth Lambda's main handler correctly routes a POST request to /auth/login to the login logic.
  • A POST request to /auth/logout without a valid access token is rejected by the authorizer with a 401/403 status code.
  • A POST request to /auth/logout with a valid access token and a valid refresh token finds the corresponding row in refresh_tokens and sets revoked to true.
  • The API returns { success: true } upon successful logout.
  • Attempting to use a revoked refresh token at the /auth/refresh endpoint results in a 401 Unauthorized error.

Dependencies: Ticket 2.3, Ticket 2.4, Ticket 2.5


Ticket 2.7: Update SAM Template and API Gateway Configuration

Objective: This ticket updates the template.yaml file to define the Auth Lambda and Lambda Authorizer as new AWS resources and configures the API Gateway routes.

Context: This final integration step makes the authentication system deployable and operational. It translates the logical components built in previous tickets into concrete cloud resources using Infrastructure as Code, ensuring the API Gateway is correctly configured to use the new authorizer for protected routes.

Technical Directives:

  • Add a new AWS::Serverless::Function resource to template.yaml for the Lambda Authorizer. Configure its handler path and set the cache TTL to 300 seconds.
  • Add a new AWS::Serverless::Function resource for the Auth Lambda, defining its handler path and API events.
  • Define the API Gateway routes for /auth/register, /auth/login, and /auth/refresh as public endpoints that integrate with the Auth Lambda.
  • Define the API Gateway route for /auth/logout, and configure it to use the new Lambda Authorizer.
  • Ensure both Lambda functions reference the SharedLambdaLayer created in Phase 1.
  • Ensure the execution roles for both functions have the necessary permissions (e.g., CloudWatch Logs, SSM Parameter access, and RDS Proxy access for the Auth Lambda).

Scope Boundaries:

  • Infrastructure as Code (template.yaml)

Acceptance Criteria:

  • After running sam deploy, the new AuthLambda and LambdaAuthorizer functions are visible in the AWS Lambda console.
  • In the API Gateway console, the /auth/logout route shows that it is protected by the new authorizer.
  • The /auth/register and /auth/login routes are public and successfully invoke the Auth Lambda.
  • A deployment to the dev environment is successful and end-to-end tests for all authentication flows pass.

Dependencies: Ticket 2.6

Phase 3: Listing Management

Ticket 3.1: Database Schema for Listing Management

Objective: This ticket creates the PostgreSQL tables required to store all data related to listings, including photos, amenities, availability rules, and pricing overrides.

Context: This schema is the foundation for the entire listing management domain. It implements the data models described in the Architecture Design Document, enabling the Listings Lambda to store and retrieve host-provided information. These tables will be written to by this phase and read from by the Search and Bookings lambdas in subsequent phases.

Technical Directives:

  • Create a listings table to store core listing attributes, including a status column to support the soft-deletion requirement from the ADD.
  • Create a listing_photos table with a foreign key to listings, including columns for the S3 object key and display order.
  • Create listing_amenities, availability_rules, and pricing_overrides tables, each with a foreign key to the listings table.
  • All table and column names must align with the data contracts specified for the Listing, ListingPhoto, AvailabilityRule, and PricingOverride types.

Scope Boundaries:

  • Database schema (migrations)

Acceptance Criteria:

  • A database migration script is created that, when run, successfully creates the listings, listing_photos, listing_amenities, availability_rules, and pricing_overrides tables.
  • The created tables contain all necessary columns with appropriate data types to store the data defined in the Listing response shape and its nested types.
  • Foreign key constraints are established between the listings table and all related tables.

Dependencies: None


Ticket 3.2: Implement Shared Pricing and Availability Logic Libraries

Objective: This ticket implements and unit tests the shared evaluateAvailabilityRules and calculateStayPrice business logic functions.

Context: These two functions contain complex, critical business logic that must be consistent across the application. Per the Architecture Design Document, they are built as shared library modules within the Listings service but will be imported and used by the Bookings Lambda in a later phase to validate and price a potential stay.

Technical Directives:

  • Implement evaluateAvailabilityRules to check a given date range against all of a listing's availability rules in the correct order: blocked dates, minimum stay, and allowed check-in days.
  • Implement calculateStayPrice to calculate the total cost of a stay, correctly applying pricing overrides based on their priority (seasonal > weekend > base).
  • The function signatures, input parameters, and return shapes must exactly match the evaluateAvailabilityRules and calculateStayPrice contracts.
  • Thrown errors for availability violations must be of the specified ConflictError type.

Scope Boundaries:

  • functions/listings/src/lib/availability.ts module
  • functions/listings/src/lib/pricing.ts module

Acceptance Criteria:

  • Unit tests for evaluateAvailabilityRules confirm that it correctly throws a ConflictError when a requested stay overlaps a blocked_range.
  • Unit tests for evaluateAvailabilityRules confirm that it correctly throws a ConflictError when a stay duration is less than a min_stay rule.
  • Unit tests for evaluateAvailabilityRules confirm that it correctly throws a ConflictError when the check-in day is not allowed by a checkin_days rule.
  • Unit tests for calculateStayPrice confirm that the returned PriceBreakdown correctly calculates subtotal, service fee, and total, applying seasonal and weekend overrides with the correct priority.
  • The modules are self-contained and can be imported without pulling in handler-specific dependencies.

Dependencies: Ticket 3.1


Ticket 3.3: Implement Core Listing CRUD Endpoints

Objective: This ticket implements the API endpoints for creating, reading, updating, and soft-deleting a listing.

Context: These endpoints provide the fundamental host-side functionality for managing a listing's lifecycle. This work implements the core of the Listings Lambda as described in the ADD, building upon the database schema from Ticket 3.1 and adhering to the authentication contracts from the preceding phase.

Technical Directives:

  • The createListing, updateListing, and deleteListing handlers must be protected routes that verify the caller has the host role using the JWT context provided by the Lambda Authorizer.
  • The updateListing and deleteListing handlers must validate that the authenticated user is the owner of the listing record being modified.
  • Deleting a listing must be a soft delete, achieved by setting the status column to inactive, not by removing the row.
  • The getListing handler must be a public, unauthenticated route.
  • The response for getListing must conform to the Listing response shape, including all related photos, amenities, rules, and overrides.

Scope Boundaries:

  • functions/listings/src/handlers/listings.ts module

Acceptance Criteria:

  • A POST /listings request from an authenticated host creates a listing row with status = 'draft' and returns the new listing object.
  • A GET /listings/:id request returns the full listing details and returns a not found error for an invalid ID.
  • An PUT /listings/:id request from the listing's owner successfully updates the specified fields.
  • A DELETE /listings/:id request from the listing's owner changes the listing's status to inactive.
  • Any attempt to create, update, or delete a listing by a user without the host role, or who is not the owner, results in an authorization error.

Dependencies: Ticket 3.1


Ticket 3.4: Implement Stripe Connect Onboarding Endpoints

Objective: This ticket implements the endpoints to generate a Stripe Connect onboarding URL for a user and to handle the subsequent redirect callback from Stripe.

Context: To become a host, a user must complete Stripe's onboarding process. This ticket provides the necessary API endpoints to initiate that flow, as outlined in the Integration Architecture section of the ADD. This is a critical step for enabling the platform's two-sided marketplace functionality.

Technical Directives:

  • Implement the getStripeOnboardingUrl handler, which must be a protected route.
  • This handler must call the Stripe API to create a Connect account for the user if one does not already exist, and then generate an account link for onboarding.
  • Implement the handleStripeCallback handler, which must be a public route.
  • The callback handler must perform a 302 redirect to a static frontend URL, as its only purpose is to return the user to the application after they complete the flow on Stripe's website.

Scope Boundaries:

  • functions/listings/src/handlers/connect.ts module

Acceptance Criteria:

  • An authenticated user can make a request to GET /connect/onboarding-url and receive a valid Stripe Connect onboarding URL in the response.
  • A request to GET /connect/callback results in an HTTP 302 redirect response to the configured frontend page.

Dependencies: None


Ticket 3.5: Implement Listing Photo Management Endpoints

Objective: This ticket implements all endpoints for managing listing photos, including generating an upload URL, confirming the upload, deleting a photo, and reordering photos.

Context: As specified in the ADD, listing photos are uploaded directly from the client to S3 using a pre-signed URL to improve performance and reduce Lambda execution time. This ticket implements the backend orchestration for that flow, from generating the secure URL to finalizing the photo record in the database.

Technical Directives:

  • The getPhotoUploadUrl handler must generate a time-limited S3 pre-signed PUT URL for a key in a pending/ path and create an associated listing_photos database row marked as unconfirmed.
  • The confirmPhotoUpload handler must verify the object exists in S3, copy it to a permanent photos/ path, delete the original pending/ object, and update the database row to be confirmed.
  • The deletePhoto handler must delete both the S3 object and the corresponding listing_photos database row.
  • The updatePhotoOrder handler must update the display_order for a set of photos within a single database transaction.
  • All handlers must be protected and must validate that the caller owns the parent listing.

Scope Boundaries:

  • functions/listings/src/handlers/photos.ts module

Acceptance Criteria:

  • A call to POST /listings/:id/photos/upload-url returns a valid S3 pre-signed URL and a photo ID.
  • After a file is uploaded to the URL, a call to POST /listings/:id/photos/confirm with the photo ID results in the S3 object being moved and the database record being marked as confirmed.
  • A call to DELETE /listings/:id/photos/:photoId removes the record from the database and the object from S3.
  • A call to PUT /listings/:id/photos/order with an array of photo IDs correctly updates the display_order column for each photo in the database.

Dependencies: Ticket 3.3


Ticket 3.6: Implement Listing Attributes Management Endpoints

Objective: This ticket implements the API endpoints for setting a listing's amenities, availability rules, and pricing overrides.

Context: These endpoints allow hosts to manage the detailed configuration of their listings. Per the phase contract, all three handlers use "replace" semantics (deleting all existing records and inserting the new set) to simplify client-side state management and ensure data consistency.

Technical Directives:

  • Implement setAmenities, setAvailabilityRules, and setPricingOverrides handlers.
  • Each handler must perform a full replacement of the associated records for the given listing within a single database transaction.
  • Input must be validated to ensure required fields for each rule and override type are present (e.g., startDate for seasonal overrides).
  • All handlers must be protected and must validate that the caller is the owner of the listing.

Scope Boundaries:

  • functions/listings/src/handlers/attributes.ts module

Acceptance Criteria:

  • A POST /listings/:id/amenities request with a list of amenities replaces all existing amenities for that listing with the new set.
  • A POST /listings/:id/availability-rules request with a list of rules replaces all existing availability rules for that listing with the new set.
  • A POST /listings/:id/pricing-overrides request with a list of overrides replaces all existing pricing overrides for that listing with the new set.
  • Subsequent calls with different or empty lists correctly reflect the "full replace" behavior.

Dependencies: Ticket 3.3


Ticket 3.7: Create and Configure Listings Lambda Router

Objective: This ticket creates the main Lambda entry point that routes API Gateway requests to the appropriate handler functions for the listing management domain.

Context: In accordance with ADR-1 ("Single Lambda per logical domain"), this ticket assembles all the previously implemented handlers into a single, deployable Lambda function. The router acts as the front controller for the entire Listings service, mapping incoming requests to the correct business logic.

Technical Directives:

  • Create a router that maps HTTP methods and path patterns to the handler functions implemented in the previous tickets.
  • The route table must exactly match the one specified in the Phase 3: Listing Management contract.
  • The module must export a single handler function that serves as the entry point for the Lambda function.
  • A NotFoundError must be returned for any route that is not explicitly defined.
  • Ensure the TypeScript types for Listing and ListingSummary are exported from a shared types file so they can be imported by the Search Lambda in the next phase.

Scope Boundaries:

  • functions/listings/src/router.ts module
  • Shared types definition file

Acceptance Criteria:

  • Requests to POST /listings, GET /listings/:id, and all other defined listing-related routes are correctly dispatched to their respective handler functions.
  • A request to an undefined route, such as GET /listings/invalid/path, returns a not found error response.
  • The ListingSummary type is successfully exported and available for import by other services. This fulfills the handoff to the Following Phase: Phase 4: Search.

Dependencies: Ticket 3.3, Ticket 3.4, Ticket 3.5, Ticket 3.6

Phase 4: Search

Phase 5: Booking and Payments Core

Phase 6: Payments Webhook Handler and Users Lambda

Phase 7: Scheduled Lambdas

Phase 8: Admin Lambda

Phase 9: Frontend