Building a Multi-Tenant Charter Management System: Event Creation, Guest Pages, and CloudFront Path Rewriting
This post details the technical architecture and implementation decisions behind automating charter bookings across three separate domains: a guest-facing booking confirmation page, an internal crew management system, and calendar synchronization. The challenge involved coordinating S3 uploads, CloudFront distribution management, Lambda function authentication, and cross-domain routing.
Architecture Overview
The system spans three distinct web properties, each with different purposes:
- sailjada.com: Internal operations and captain/crew coordination
- ShipCaptainCrew (SCC) platform: Real-time crew event management with automatic notifications
- queenofsandiego.com: Public-facing guest confirmation and charter details
Each domain uses separate CloudFront distributions pointing to S3 buckets, with Lambda functions handling authentication, event creation, and business logic.
Guest Page Deployment: Multi-Domain S3 Strategy
Guest pages needed to live at /g/{BOOKING_ID} on queenofsandiego.com. The infrastructure decision was critical: should we use subdirectories or flat files?
The queenofsandiego.com CloudFront distribution includes a Function (CloudFront's lightweight Lambda alternative) that rewrites paths. Reading the live function code revealed:
// Path rewriting logic
if (request.uri.startsWith('/g/')) {
request.uri = '/g/' + id + '.html'
}
This meant guest pages needed to be uploaded as flat .html files to the S3 bucket, not as directory structures. The original upload to sailjada.com used directory conventions (/g/XHQGMDH/index.html), which wouldn't work on queenofsandiego.com without CloudFront function changes.
Decision: Upload guest HTML as /g/XHQGMDH.html directly to the queenofsandiego.com S3 bucket, then invalidate the CloudFront cache with a targeted invalidation pattern.
Lambda Authentication and Service Key Hashing
The ShipCaptainCrew Lambda function requires authentication via the X-Service-Key header. However, the function validates this by hashing the incoming key and comparing it to SERVICE_KEY_HASH in the Lambda environment:
def hash_password(key):
return hashlib.sha256(key.encode()).hexdigest()
incoming_hash = hash_password(request.headers.get('X-Service-Key'))
if incoming_hash != os.environ['SERVICE_KEY_HASH']:
return error_response(401)
The initial deployment was missing the environment variable. The fix involved:
- Download the Lambda source from the deployment URL
- Extract the service key from secrets management
- Hash the key using the same SHA256 function
- Add
SERVICE_KEY_HASHto the Lambda environment variables - Redeploy
This pattern (hashing on Lambda rather than transmitting plaintext comparison) is more secure but requires careful synchronization between the service key source-of-truth and the Lambda environment.
CloudFront Header Stripping: API Gateway Bypass
Initial event creation calls went through the CloudFront distribution URL, but CloudFront strips custom headers by default. The X-Service-Key header wasn't reaching Lambda, causing authentication failures.
Solution: Bypass CloudFront by calling the API Gateway URL directly. While CloudFront handles caching and distribution, the Lambda backend is also accessible via the regional API Gateway endpoint. Event creation endpoints are write operations that shouldn't be cached anyway, so bypassing CloudFront for API calls is acceptable.
The pattern: use the distribution URL (https://sailjada.com/api/...) for read-heavy endpoints with CloudFront caching, but call the API Gateway URL directly (https://{api-id}.execute-api.{region}.amazonaws.com/prod/...) for authenticated writes.
Dashboard Lambda Authentication
The dashboard Lambda (separate from SCC) uses a different auth mechanism: X-Dashboard-Token header. This token is sourced from secrets management and used by internal scripts like update_dashboard.py to trigger calendar updates.
The calendar entry creation followed this pattern:
import requests
DASHBOARD_TOKEN = get_secret('DASHBOARD_TOKEN')
headers = {
'X-Dashboard-Token': DASHBOARD_TOKEN,
'Content-Type': 'application/json'
}
response = requests.post(
'https://dashboard-lambda-endpoint/calendar',
json={
'date': '2024-05-30',
'event_type': 'charter',
'booking_id': 'XHQGMDH'
},
headers=headers
)
Two separate Lambda functions, two authentication schemes — this is intentional separation of concerns. Dashboard updates are internal operations (calendar, kanban, email), while SCC events directly notify crew with magic links.
DynamoDB Direct Updates: Removing Sensitive Data
After creating an SCC event, the notes field contained revenue information ("Revenue: $840.75", "Captain Fee: $50/hr"). This data was visible to crew members, which wasn't desired.
Rather than add a field-filtering layer to the SCC Lambda event update endpoint, we performed a direct DynamoDB update to the events table:
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('scc-events')
table.update_item(
Key={'event_id': 'XHQGMDH'},
UpdateExpression='SET notes = :notes',
ExpressionAttributeValues={
':notes': 'Charter details for crew — see admin panel for revenue'
}
)
Why bypass the API? The SCC Lambda's event update endpoint performs business logic validation and transformations. For sensitive data filtering, direct DynamoDB access is simpler and faster — no round trip through Lambda, no validation overhead.
Trade-off: Direct table updates skip Lambda validation. This is safe for field-level updates but risky for complex operations. In this case, we're confident because the Lambda doesn't own the "revenue visibility" logic — that's a presentation layer concern, not a state machine.
S3 Bucket Structure and CloudFront Invalidation
Files were organized in S3 as:
s3://queenofsandiego.com/
└── g/
└── xhqgmdh.html (guest page)
└── xhqgmdh-crew.html (crew checklist page)
After upload, CloudFront invalidation was critical:
aws cloudfront create-invalidation \
--distribution-id {DIST_ID} \
--paths "/g/*"
This clears the cache for all guest pages, ensuring live URLs reflect the latest HTML immediately. Without invalidation, cached content can serve stale pages for up to 24 hours.
Friendly URL Redirect Strategy
Initial guest page was at /g/XHQGMDH (the booking