diff --git a/docs/superpowers/specs/2026-05-18-lld-sample.html b/docs/superpowers/specs/2026-05-18-lld-sample.html new file mode 100644 index 00000000..7682cb20 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-lld-sample.html @@ -0,0 +1,704 @@ + + +
+ +| Feature | user-signup-20260512 |
|---|---|
| Owner | @ashwinimanoj |
| Status | active |
| Linked PRD | ../prd/1-user-signup/prd.md |
| Linked plans | ../plan/1-user-signup/, ../plan/2-email-verification/ |
| Version | 0.4.0 |
| Last updated | 2026-05-18 |
+ Bytebite is a food-delivery marketplace. This LLD covers the user-signup slice: a new customer
+ creates an account with email + password, receives a one-time email verification link, and on
+ click is marked active. The slice owns three endpoints (POST /users,
+ POST /users/verify, GET /users/me) and writes to one Postgres table
+ (users) and one Redis namespace (signup:*).
+
+ This LLD is the design contract for stories in epics E1 Account Creation, + E2 Email Verification, and E3 Anti-abuse. It mirrors the + PRD's Onboarding M1 milestone. +
+ +Files touched in this LLD. + new + mod + unchanged +
++src/ +├── main.py unchanged +├── db.py mod # add User repository +├── cache.py new # Redis client + namespaced keys +├── errors.py mod # add SignupError, OTPError +├── models/ +│ ├── user.py new # User entity, signup request/response +│ └── verification.py new # EmailVerification entity +├── services/ +│ ├── signup_service.py new # orchestration: validate → hash → write → enqueue mail +│ └── verification_service.py new # token issue + consume +├── routes/ +│ └── users.py new # POST /users, POST /users/verify, GET /users/me +└── workers/ + └── email_dispatcher.py new # async send via SendGrid + +migrations/ +├── 20260512_create_users.sql new +└── 20260513_create_email_verif.sql new + +tests/ +├── test_user_model.py new +├── test_signup_service.py new +├── test_verification_service.py new +└── test_users_routes.py new ++ +
Two tables in Postgres; one Redis namespace for ephemeral state.
+ +users| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default gen_random_uuid() | Public-facing identifier |
email | citext | NOT NULL, UNIQUE | Case-insensitive; index on lower(email) |
phone | text | NULL | E.164 format when present |
full_name | text | NOT NULL, length 1..120 | Display name |
password_hash | text | NOT NULL | bcrypt $2b$12$... |
status | enum | pending | active | disabled | Starts pending, flips to active on verify |
dietary_prefs | text[] | default '{}' | Tags: vegetarian, vegan, halal, … |
marketing_opt_in | boolean | NOT NULL, default false | Explicit consent only |
created_at | timestamptz | NOT NULL, default now() | — |
last_login_at | timestamptz | NULL | Updated on successful login |
email_verifications| Column | Type | Constraints | Notes |
|---|---|---|---|
token | text | PK | 256-bit random, base64url-encoded |
user_id | uuid | FK → users.id, NOT NULL | — |
expires_at | timestamptz | NOT NULL | Now + OTP_TTL_SECONDS |
consumed_at | timestamptz | NULL | Set on first successful verify |
created_at | timestamptz | NOT NULL, default now() | — |
| Key pattern | Type | TTL | Purpose |
|---|---|---|---|
signup:ratelimit:ip:{ip} | INCR counter | 3600s | Cap signup attempts per IP |
signup:ratelimit:email:{email_hash} | INCR counter | 3600s | Cap signups per email (defense against burning addresses) |
verify:attempts:{user_id} | INCR counter | 3600s | Cap bad-token submissions before lockout |
session:{token_jti} | HASH {user_id, expires_at} | JWT_TTL_SECONDS | Server-side revocation lookup for JWTs |
POST /users — create user #api-create-userRequest
+
+POST /users
+Content-Type: application/json
+
+{
+ "email": "alex@example.com",
+ "full_name": "Alex Patel",
+ "phone": "+919876543210",
+ "password": "hunter2!solid-rosebud",
+ "dietary_prefs": ["vegetarian"],
+ "marketing_opt_in": false
+}
+
+Response — 201 Created
+
+{
+ "id": "01HQK4G3PE7XYAS2RJ8M5VFW9P",
+ "email": "alex@example.com",
+ "full_name": "Alex Patel",
+ "phone": "+919876543210",
+ "status": "pending",
+ "dietary_prefs": ["vegetarian"],
+ "created_at": "2026-05-18T14:23:45Z",
+ "verification_required": true
+}
+
+Response — 200 OK (email-enumeration defense)
+When the email is already in use, the service still returns 200 with the response above
+ (but id is omitted) and silently triggers a password-reset email instead of a
+ verification email. This prevents enumeration via response-time or response-code differences.
Response — 422 Unprocessable Entity
+
+{
+ "detail": [
+ { "loc": ["body", "password"], "msg": "must be at least 10 characters", "type": "value_error" },
+ { "loc": ["body", "phone"], "msg": "must be E.164 format", "type": "value_error" }
+ ]
+}
+
+Response — 429 Too Many Requests
+
+{ "detail": "Too many signup attempts. Try again in 23 minutes." }
+
+
+POST /users/verify — confirm email #api-verify-userRequest
+
+POST /users/verify
+Content-Type: application/json
+
+{ "token": "h7v9_T2nQq1k8nUOaJ-WnH8sCxAa3gqcW-A-pT5gqz0" }
+
+Response — 200 OK
+
+{
+ "id": "01HQK4G3PE7XYAS2RJ8M5VFW9P",
+ "email": "alex@example.com",
+ "status": "active",
+ "verified_at": "2026-05-18T14:25:10Z"
+}
+
+Response — 410 Gone (token expired or already consumed)
+
+{ "detail": "Verification link has expired. Request a new one." }
+
+
+GET /users/me — current user #api-get-meRequires Authorization: Bearer <jwt>. Returns the same shape as 5.1's 201
+ response (minus verification_required) plus last_login_at.
| Error | Status | Body | Retryable |
|---|---|---|---|
RequestValidationError | 422 | FastAPI envelope | No — fix input |
RateLimitExceeded | 429 | {detail, retry_after_seconds} | Yes — after backoff |
VerificationExpired | 410 | {detail: "..."} | No — request new link |
TooManyVerifyAttempts | 429 | {detail, retry_after_seconds} | Yes — after backoff |
EmailDispatchFailed | — | Logged only; user signup succeeds | Yes — retried by worker |
| (unhandled) | 500 | {detail: "Internal server error"} | Maybe |
Two clients send POST /users for alex@example.com within ~10ms. Both
+ pass validation. Both bcrypt-hash. Both try to INSERT.
Defense: CREATE UNIQUE INDEX users_lower_email ON users (lower(email)).
+ Postgres serializes the writes; the loser gets IntegrityError. The service catches
+ it and switches to the password-reset email path (see §6.1 step 6). Net effect: exactly one user
+ row exists; the second caller learns nothing about duplication.
+# signup_service.py +try: + user_id = db.insert_user(...) + enqueue_verification_email(user_id) +except IntegrityError as e: + if "users_lower_email" in str(e): + enqueue_password_reset_email(email) + else: + raise ++ +
User double-clicks the verification link, or the worker retries. Both requests arrive almost + simultaneously with the same token.
+Defense: single-statement atomic consume — only one UPDATE
+ returns a row.
+UPDATE email_verifications + SET consumed_at = now() + WHERE token = $1 + AND consumed_at IS NULL + AND expires_at > now() +RETURNING user_id; ++
Second caller's UPDATE matches zero rows → 410 Gone.
+ Redis was considered for an email-level mutex on signup. Rejected because the cache layer can + fail open under partition; the DB unique index is the authoritative defense and is cheaper. + Redis is kept for rate limiting only — where eventual loss of state is acceptable. ++ +
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL | — (required) | +Postgres connection string; pool min 5, max 20. | +
REDIS_URL | redis://localhost:6379/0 |
+ Rate limiting + session lookups. Service degrades to "no rate limit" if Redis is unreachable. | +
BCRYPT_COST | 12 |
+ OWASP guidance; raise by 1 every ~2 years. Each step ~doubles per-request CPU. Capacity-plan accordingly. | +
OTP_TTL_SECONDS | 600 |
+ Verification-link lifetime. Shorter = tighter security, longer = better UX in cold inboxes. | +
OTP_MAX_ATTEMPTS | 5 |
+ Per-user bad-token submissions before 1-hour lockout. | +
SIGNUP_RATE_LIMIT_PER_IP_HOUR | 5 |
+ Hard cap; tune up for shared-NAT corporate networks via allowlist override. | +
JWT_SECRET | — (required) | +HS256 HMAC secret; rotated quarterly. Old secret kept in JWT_SECRET_PREVIOUS for grace-period verification. |
+
JWT_TTL_SECONDS | 86400 |
+ Access token lifetime; refresh token is 30d. | +
EMAIL_PROVIDER | sendgrid |
+ Allowed: sendgrid, ses. Plug-in resolver in workers/email_dispatcher.py. |
+
EMAIL_FROM_ADDRESS | no-reply@bytebite.io |
+ Must match DKIM-signed domain. | +
PASSWORD_MIN_LENGTH | 10 |
+ Server-enforced. Client widget should mirror this to avoid post-submit surprises. | +
event=signup_started — fields: request_id, ip_hashevent=signup_completed — fields: request_id, user_id (no email/phone)event=signup_duplicate — fields: request_id, email_hash (note: not raw email)event=verification_sent — fields: request_id, user_id, providerevent=verification_consumed — fields: request_id, user_idevent=verification_failed — fields: request_id, reason (expired | invalid | locked)users_signup_total{result} — counter; result ∈ {success, duplicate, validation_error, rate_limited}users_verify_total{result} — counter; result ∈ {success, expired, invalid, locked}signup_bcrypt_seconds — histogramverification_emails_sent_total{provider, result} — counterredis_cache_hit_ratio{kind} — gauge; kind ∈ {ratelimit, session}One span per request named signup.create. Sub-spans: signup.validate,
+ signup.bcrypt, signup.db_insert, signup.enqueue_email.
Optional section — include when the feature handles PII, introduces new auth/access surface, or changes the threat model. Skip otherwise.
+JWT_SECRET, includes jti for server-side revocation via RedisPOST /users always returns 200/201 (never 409) regardless of duplicateuser_id only| Threat | Mitigation |
|---|---|
| Credential stuffing | Rate limit + bcrypt cost + email-enumeration defense |
| Mass-signup spam | Per-IP and per-email-hash rate limits in Redis |
| Verification-link replay | Single-use tokens with TTL; atomic consume (see §8) |
| JWT theft | Short TTL + server-side revocation list in Redis |
| Timing attacks on duplicate-email check | Constant-time response padding (see §11 enumeration defense) |
Each subsection below must have a concrete answer. "n/a — <reason>" is the only allowed escape — vague TBDs are not.
+ +Steady: 500 signups/min. Peak: 5,000/min (post-marketing burst).
+ +p95 < 600 ms · p99 < 1.2 s, measured end-to-end at the load balancer.
+ +bcrypt hash (cost 12) — ~250 ms, CPU-bound. Dominates the hot path; everything else is < 10 ms.
+ +Every step on the happy path and its expected duration. The total must fit under §12.2's SLO with headroom. Estimates at design time; replaced by observed values once the feature ships.
+| Step | Expected latency | Notes |
|---|---|---|
| Pydantic validation | < 1 ms | — |
| Redis INCR (rate limit) | ~1 ms | Same-AZ Redis |
| bcrypt hash | ~250 ms | Cost 12 — see §12.3 |
INSERT users | ~5 ms | Unique-index lookup + insert |
INSERT email_verifications | ~3 ms | — |
| Enqueue email | ~2 ms | Push to Redis queue; worker sends async |
| Total (p95) | ~280 ms | Comfortably under the 600 ms SLO |
One pod (4 vCPU) sustains ~16 signups/sec. Throughput is gated by bcrypt CPU, so capacity scales linearly with vCPU count.
+ +| Key | Store | TTL | Why |
|---|---|---|---|
signup:ratelimit:* | Redis | 1 h | Spam / enumeration defense; degrade-open on Redis failure (acceptable) |
session:{jti} | Redis | JWT_TTL_SECONDS | Server-side JWT revocation lookup; hit-ratio target > 99% |
| User profile rows | — | — | Not cached. Read QPS too low to justify the invalidation complexity. Revisit at > 500 read QPS. |
tests/load/signup.js).event=redis_unavailable. Mitigation: alarm + manual fallback to nginx-level limit.retry_after_seconds.| # | Question | Options | Owner | Resolve by |
|---|---|---|---|---|
| Q1 | +Should verification-link clicks log the user in automatically? | +A) yes, return JWT; B) no, redirect to login | +@productlead | +before E2-S2 starts | +
| Q2 | +Is column-level pgcrypto enough for PII, or do we need full-disk + column encryption? | +A) column-only (current); B) both (CIS recommendation) | +@security-team | +before pre-launch security review | +
| Q3 | +Should we permit signup from disposable-email domains? | +A) allow; B) reject via maintained blocklist; C) tag-and-allow for fraud-scoring | +@trust-and-safety | +before public launch | +
| Date | Story | Sections touched | Change |
|---|---|---|---|
| 2026-05-08 | +— | +#overview, #scope |
+ Initial LLD scaffold from PRD | +
| 2026-05-10 | +E1-S1 | +#data-model, #api-create-user |
+ Locked users schema and POST /users contract |
+
| 2026-05-12 | +E1-S2 | +#concurrency, #security-privacy |
+ Added email-enumeration defense; documented duplicate-email race | +
| 2026-05-15 | +E2-S1 | +#data-model, #api-verify-user, #flow-verify |
+ Added email_verifications table and atomic consume statement | +
| 2026-05-17 | +E3-S1 | +#configuration, #performance |
+ Added Redis rate-limit keys and per-IP/per-email caps | +
| 2026-05-18 | +E3-S2 | +#security-privacy, #observability |
+ Hashed-email logging; added users_signup_total metric |
+