Instruction file imported from vrwmiller/outdoorsportsclub (
.github/instructions/backend.instructions.md). Copyright stays with the author.
Backend Standards — Outdoor Sports Club
AWS Services in Use
| Service | Purpose |
|---|---|
| AWS Lambda (Python 3.12) | All backend logic — one function per endpoint |
| AWS API Gateway | REST API — routes map 1-to-1 to Lambda functions |
| Amazon Aurora Serverless v2 | PostgreSQL database — accessed via RDS Data API only |
| AWS Cognito | Member JWT auth; Social Login (Google/Facebook) |
| AWS Secrets Manager | All runtime secrets (DB credentials, Stripe keys, device token salt) |
| Amazon S3 + S3 Object Lock | Signed waiver storage — Compliance Mode, 7-year retention |
| Amazon SNS | SMS notifications for range closures and safety alerts |
| AWS KMS | Encryption at rest for S3, Aurora, and Secrets Manager |
| Amazon CloudWatch | Structured logging from all Lambda functions |
Handler Structure
Every Lambda handler follows this pattern:
import json
import logging
import os
from typing import Any
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event: dict, context: Any) -> dict:
try:
# 1. Authenticate
# 2. Validate input
# 3. Execute business logic
# 4. Return success response
return {"statusCode": 200, "body": json.dumps({"message": "OK"})}
except PermissionError as exc:
logger.warning("Auth failure: %s", exc)
return {"statusCode": 403, "body": json.dumps({"error": "Forbidden"})}
except ValueError as exc:
logger.warning("Validation error: %s", exc)
return {"statusCode": 400, "body": json.dumps({"error": str(exc)})}
except Exception as exc: # noqa: BLE001 — final safety net; log and sanitise
logger.exception("Unhandled error: %s", exc)
return {"statusCode": 500, "body": json.dumps({"error": "Internal server error"})}
- Return dicts must always include
statusCode(int) andbody(JSON string) - Never return raw exception messages or stack traces to the client
- Log exceptions with
logger.exception()for CloudWatch; the client receives only a sanitised message
Structured logging
Every handler must emit one structured JSON log line per request using logger.info(json.dumps({...})). Required fields for all handlers:
| Field | Value |
|---|---|
request_id |
context.aws_request_id |
member_id |
Cognito sub or device-token member ID; null for unauthenticated |
device_id |
Device ID from token; null for member requests |
action |
Short verb-noun string matching the endpoint (e.g., checkin, pair_device) |
duration_ms |
Elapsed time for the handler in milliseconds |
error |
Exception class name on failure; null on success |
Additional required fields by handler type:
- Check-in handlers: also log
training_level(value fetched from Aurora, not JWT) - Payment handlers: also log
stripe_payment_intent_id
Never log raw device token values, Stripe secret keys, or full JWT strings.
Authentication
Member endpoints (Cognito JWT)
- Extract the JWT from
event["headers"]["Authorization"](Bearer token) - Validate the token against the Cognito JWKS endpoint — use
python-joseorPyJWTwith the Cognito public keys - After validating the JWT, always query the
memberstable via the RDS Data API to get the authoritativetraining_level— do not trust thetraining_levelvalue from the token claim, as it may be stale if the level was updated since the token was issued. Use the Cognitosubclaim to look up the member record. - Enforce the minimum required level against the value fetched from the database before executing any logic
- Reject missing or invalid tokens with
403 Forbidden— never401(Cognito handles the 401 flow)
Kiosk endpoints (Device Token)
- Extract
x-device-tokenfromevent["headers"] - Compute the HMAC-SHA256 hash of the incoming token (same algorithm as generation:
hmac.new(salt.encode(), token.encode(), hashlib.sha256).hexdigest()) - Query the
devicestable by the hash:SELECT id, status FROM devices WHERE device_token = :hashed_token— never query by the raw token value - Reject rows where
status != 'Active'with403 Forbidden - Never log the raw device token value
Database — RDS Data API
- Use
boto3clientrds-data— never bundlepsycopg2or open a persistent connection inside a Lambda function - All queries must use parameterised statements — never use string interpolation for user-supplied values (SQL injection prevention)
- Reference the cluster ARN and secret ARN from environment variables:
DB_CLUSTER_ARN,DB_SECRET_ARN,DB_NAME - Transaction pattern for multi-step writes (e.g., write
activity_logsafter Stripe payment confirms):
import boto3
rds = boto3.client("rds-data")
tx = rds.begin_transaction(resourceArn=CLUSTER_ARN, secretArn=SECRET_ARN, database=DB_NAME)
try:
rds.execute_statement(..., transactionId=tx["transactionId"])
rds.commit_transaction(resourceArn=CLUSTER_ARN, secretArn=SECRET_ARN, transactionId=tx["transactionId"])
except Exception:
rds.rollback_transaction(resourceArn=CLUSTER_ARN, secretArn=SECRET_ARN, transactionId=tx["transactionId"])
raise
Row-Level Security and set_config
This project uses PostgreSQL Row-Level Security (RLS) with two transaction-scoped GUCs. The first two execute_statement calls in every transaction must set both:
rds.execute_statement(
resourceArn=CLUSTER_ARN,
secretArn=SECRET_ARN,
database=DB_NAME,
transactionId=tx["transactionId"],
sql="SELECT set_config('app.current_member_id', :member_id, true)",
parameters=[{"name": "member_id", "value": {"stringValue": str(member_id)}}],
)
rds.execute_statement(
resourceArn=CLUSTER_ARN,
secretArn=SECRET_ARN,
database=DB_NAME,
transactionId=tx["transactionId"],
sql="SELECT set_config('app.current_training_level', :level, true)",
parameters=[{"name": "level", "value": {"stringValue": str(training_level)}}],
)
is_local=true(third argument toset_config) makes the setting transaction-scoped — it resets to NULL the moment the transaction ends or if any query runs outside a transaction- Both GUCs are required:
app.current_member_idis used by self-service policies (SELECT/INSERT for the authenticated member's own rows);app.current_training_levelis used by admin policies (Level 4+ access to all rows) - If either GUC is missing, RLS will deny all rows silently on SELECT (returns empty results, not an error), and will reject INSERT/UPDATE/DELETE with an RLS violation error
- Tables under RLS:
members,activity_logs,consumable_purchases,guest_visits,guests - Tables not under RLS:
wait_list,lanes,ranges,club_settings,devices,training_level_policies - Never issue a SELECT, INSERT, UPDATE, or DELETE against an RLS-protected table outside of a transaction that started with both
set_configcalls
Secrets & Environment Variables
- All secrets are fetched at cold-start from AWS Secrets Manager — cache in a module-level variable; never re-fetch per invocation
- Environment variable names:
| Variable | Contains |
|---|---|
DB_CLUSTER_ARN |
Aurora cluster ARN |
DB_SECRET_ARN |
Secrets Manager ARN for DB credentials |
DB_NAME |
Database name |
COGNITO_USER_POOL_ID |
Cognito User Pool ID |
COGNITO_REGION |
AWS region for Cognito JWKS lookup |
STRIPE_SECRET_ARN |
Secrets Manager ARN for Stripe secret key |
S3_WAIVER_BUCKET |
S3 bucket name for signed waivers |
SNS_ALERTS_TOPIC_ARN |
SNS topic ARN for range alerts |
CORS_ALLOW_ORIGIN |
Allowed origin for CORS headers — set to the application domain; never * in production |
DEVICE_TOKEN_SALT_ARN |
Secrets Manager ARN for the device token salt (used to hash and verify device tokens) |
Device Token Generation
Used only in POST /v1/devices/pair. The raw token is generated once, returned to the tablet, and never stored — only the HMAC hash is persisted.
import hashlib
import hmac
import secrets
# Fetch salt at cold-start from Secrets Manager via DEVICE_TOKEN_SALT_ARN
# (same pattern as STRIPE_SECRET_ARN — cache in a module-level variable)
def generate_device_token(salt: str) -> tuple[str, str]:
"""Returns (raw_token, hashed_token). Store only the hash; return only the raw token."""
raw_token = secrets.token_urlsafe(32) # 256 bits of entropy
hashed = hmac.new(salt.encode(), raw_token.encode(), hashlib.sha256).hexdigest()
return raw_token, hashed
- Store
hashedindevices.device_token; returnraw_tokento the tablet in the response body - The raw token is transmitted exactly once — never log it, never store it
- To validate an incoming
x-device-token: computehmac.new(salt.encode(), token.encode(), hashlib.sha256).hexdigest()and compare against the stored hash usinghmac.compare_digest()(constant-time comparison prevents timing attacks)
Stripe Integration
- Stripe secret key is fetched at cold-start from AWS Secrets Manager via
STRIPE_SECRET_ARN - Use
stripe.PaymentIntentfor all Tap to Pay flows — never store card data - Confirm payment success via Stripe webhook or synchronous
PaymentIntentstatus check before writing to the database - On Stripe failure, return
402 Payment Requiredwith a sanitised error message - Never read monetary amounts from the client request body.
unit_price, fee amounts, or any cents value must be looked up server-side from a catalog table (e.g.consumable_items) orclub_settings— client-supplied amounts are trivially forgeable
S3 Waiver Storage
POST /v1/kiosk/waiver handles both member and guest waiver signing. Differentiate by the presence of guest_id in the request body.
Member waiver (no guest_id in request body)
- S3 key pattern:
waivers/<member_id>/<timestamp>.pdf - After successful upload, execute a single RDS transaction that:
- Updates
members.waiver_signed_atandmembers.waiver_version - Inserts a
Waiver-Signedentry intoactivity_logswithwaiver_s3_keyset to the uploaded key;guest_idisNULL
- Updates
Guest waiver (guest_id present in request body)
- S3 key pattern:
waivers/guests/<guest_id>/<timestamp>.pdf - After successful upload, execute a single RDS transaction that:
- Updates
guests.waiver_signed_atandguests.waiver_s3_key - Inserts a
Waiver-Signedentry intoactivity_logswithwaiver_s3_keyset to the uploaded key andguest_idpopulated - Does not touch
members.waiver_signed_atormembers.waiver_version
- Updates
Common rules (both paths)
- Use server-side encryption:
ServerSideEncryption='aws:kms' - S3 Object Lock is configured at the bucket level (Compliance Mode, 7 years) — do not set object-level retention in code
- Never write the
activity_logsrow before the S3 upload succeeds — rollback the transaction and return500if the upload fails
API Gateway Integration
- All Lambda functions are integrated with API Gateway using Lambda Proxy Integration — the full HTTP request is forwarded as
eventand the return dict is used as the HTTP response verbatim - CORS headers must be included in every response dict, including error responses; set the following at minimum:
CORS_HEADERS = {
"Access-Control-Allow-Origin": "https://yourdomain.com", # set via env var; never "*" in production
"Access-Control-Allow-Headers": "Content-Type,Authorization,x-device-token",
"Access-Control-Allow-Methods": "OPTIONS,GET,POST,PATCH",
}
- Configure the
Access-Control-Allow-Originvalue from an environment variable (CORS_ALLOW_ORIGIN) — never hardcode the domain - Member-facing routes use a Cognito Authorizer configured at the API Gateway level to reject requests with invalid or missing JWTs before they reach the Lambda function; the Lambda still validates
training_levelserver-side - Kiosk routes do not use the Cognito Authorizer — Device Token validation is handled entirely inside the Lambda handler
SNS Notifications
- Publish to
SNS_ALERTS_TOPIC_ARNfor range-closure or safety events - Message format: plain text, 160-character SMS limit; no PII in the message body
- Use
boto3clientsnswithMessageAttributesto distinguish alert types if needed
Coding Standards
- Follow PEP 8: 4-space indentation, max 100 characters per line
- All function parameters and return types must have type annotations
- Use f-strings for formatting — never
%or.format() - Use
os.environfor environment variable access — raise a clearRuntimeErrorat cold-start if a required variable is missing - One Lambda function per file; handler always named
handler(event, context) - No bare
except:— always catch specific exception types; use a final broadexcept Exceptiononly as a safety net with logging - Keep handlers lean — no speculative abstractions, no dead code, no defensive handling of states the schema guarantees cannot occur. See the Code Complexity & Bloat rules in
.github/instructions/linter.instructions.md.