Skip to content

feat: ember recipient (parent) flow + parent-reflect AI + handoff v2#10

Open
ahmedpanju wants to merge 1 commit into
mainfrom
ember/recipient-flow-v1
Open

feat: ember recipient (parent) flow + parent-reflect AI + handoff v2#10
ahmedpanju wants to merge 1 commit into
mainfrom
ember/recipient-flow-v1

Conversation

@ahmedpanju
Copy link
Copy Markdown

Summary

  • Full parent-side experience for the recipient of an Ember gift, mounted under /r/[token]/*. Welcome → letter → how-it-works → account → home (Today) with bottom-tab nav (Today / Journal / Prompts / Share). All token-scoped, all persists per token to localStorage, resumes mid-flow.
  • New AI surface for the parent: /r/[token]/ai is a Claude chat with a third topic parent-reflect (warm, low-pressure, ok with silence, never simulates anyone in the parent's life). "Save as journal entry" distills the conversation into first-person prose for direct insertion.
  • Sharing is one-time — once tapped, the journal becomes archived (read-only). Every write surface gates on sharing.sharedAt.
  • Comprehensive backend handoff at BACKEND_HANDOFF_V2.md — every page, every endpoint, full data model, file storage, email, sharing/archive flow, migration plan from localStorage. Original BACKEND_HANDOFF.md left in place.

Routes

  • /r/[token] — "Take your time" landing
  • /r/[token]/letter — personal letter (serif; renders generic if personalMessage not yet collected)
  • /r/[token]/start — "Three things to know"
  • /r/[token]/account — "Save your space" (email + password, no social login)
  • /r/[token]/home — Today: composer + 3 starting points
  • /r/[token]/journal — List / Calendar (interactive) / Media + FAB
  • /r/[token]/journal/new — composer (also takes ?promptId=)
  • /r/[token]/prompts — All / Used filter
  • /r/[token]/ai — Claude chat with "Save as entry"
  • /r/[token]/voice — record → Whisper → editable transcript → save
  • /r/[token]/share — sharing settings + share-now confirm modal
  • /r/[token]/share/when — when-ready / legacy / date / milestone (date required for date + milestone)
  • /r/[token]/share/done — "She has it." success

Reusable bits

  • EntryComposer — text + photo + voice (Whisper). Used by Today, /journal/new, /prompts (via /journal/new).
  • BottomTabs — 4-tab nav.
  • AvatarMenu — avatar → dropdown → Sign out (clears all ember:parent:* + ember:onboarding:v1 + cookie, routes to /login).

Server delta

  • server/src/routes/ai.ts — adds topic: "parent-reflect" to /ai/converse and /ai/summarize. Two new system prompts (converse + summarize) tuned for the parent surface.

Out of scope (intentional, called out in BACKEND_HANDOFF_V2.md §10 + §13)

  • Parent flow is localStorage-only — the recipient endpoints that landed on main aren't wired yet. §10 has the migration plan.
  • /login was intentionally not touched — main's existing email/password login (with real backend wiring) stands. The handoff doc notes that a unified login was prototyped on the recipient side and can be merged later.
  • Photos: inline data URLs (4MB cap) pending S3.
  • Audio: parent voice notes persist transcript + duration only. Raw audio storage is on the backend handoff list (§7) — voice is the soul of the input experience and needs to be kept.
  • No personal-letter authoring UI on the giver side yet — parent renders a generic letter when null. §13 open question.
  • No giver pronoun collection — parent UI defaults to "she/her" matching the mock. §13 open question.

Test plan

  • Set OPENAI_API_KEY and ANTHROPIC_API_KEY in root .env; run bun dev.
  • Walk the parent flow with any token, e.g. /r/abc → tap-continue through letter / how-it-works / account → land on /home.
  • On /home, type something + Save → entry appears in /journal. Try the mic — Whisper transcribes and appends.
  • On /journal, switch to Calendar → tap a day with entries → see them below; switch to Media if you've added a photo.
  • On /prompts, tap an unused prompt → opens composer with prompt context. Save → returns to /journal, prompt now in "Used".
  • On /ai, multi-turn with Ember → "Save as entry" → conversation distilled into a first-person journal entry.
  • On /voice, record → review transcript (editable) → save → "Voice note" entry in /journal.
  • On /share, tap "Change how this is shared" → pick a date / milestone (date required) → Continue → setting persists.
  • On /share, tap "Share what I've written so far" → confirm modal → "She has it." success → /journal now shows Archived banner, FAB hidden, prompts disabled, composer replaced with archived card.
  • Click avatar circle on any tab → dropdown → Sign out → routes to /login, all parent state cleared.
  • Reload mid-flow at any point → resume on the right step.

🤖 Generated with Claude Code

Builds the full parent-side experience for the recipient of an Ember gift,
plus a third Claude topic for the in-journal "talk it through" surface.

Frontend (web/app/r/[token]/*) — all token-scoped, all persists to
localStorage under ember:parent:{token}:v1, resumes mid-flow:
- welcome → letter → how-it-works → account ("Save your space")
- /home (Today) — composer + 3 starting points (Pick a prompt /
  Talk it through with Ember / Record your voice). Bottom tabs:
  Today / Journal / Prompts / Share
- /journal — List (grouped by month), Calendar (interactive — tap day),
  Media. "+ New entry" FAB
- /journal/new — dedicated composer screen (FAB target + prompt-tap target,
  takes ?promptId=)
- /prompts — All / Used filter; tap unused prompt → composer with prompt
- /ai — Claude chat (parent-reflect tone). "Save as journal entry" distills
  the conversation into prose in the parent's first-person voice
- /voice — dedicated voice note (record → Whisper → editable transcript →
  save with durationSeconds)
- /share — sharing settings + "Share what I've written so far" with
  confirm modal. Sharing is one-time → archives the journal
- /share/when — How and when picker (when-ready / legacy / date /
  milestone). Date and milestone require a specific calendar date
- /share/done — "She has it." success ack

Reusable components:
- EntryComposer — text + photo + voice (Whisper) used on Today,
  /journal/new, and indirectly via /prompts
- BottomTabs — 4-tab nav with Today / Journal / Prompts / Share
- AvatarMenu — avatar circle → dropdown → Sign out (clears all
  ember:parent:* + ember:onboarding:v1 + cookie, routes to /login)

Archived state — once sharing.sharedAt is set:
- /home shows "Your journal is shared." instead of composer
- /journal shows Archived banner, FAB hidden
- /prompts shows read-only banner, taps disabled
- /journal/new + /voice redirect to /journal
- /ai save button disabled
- /share replaces options with the Archived snapshot card

Server (server/src/routes/ai.ts):
- Adds 3rd topic "parent-reflect" to /ai/converse and /ai/summarize.
- Different Claude system prompt: warm, low-pressure, ok with silence,
  never simulates anyone in the parent's life. Summarizer writes in
  first-person from the parent's voice for direct insertion as an entry.

Docs:
- BACKEND_HANDOFF_V2.md — comprehensive page-by-page spec covering every
  child + parent route, every API endpoint to build, full data model,
  file storage, email, sharing/archive flow, migration plan from
  localStorage. Supersedes BACKEND_HANDOFF.md (which only covered child
  onboarding); the original is left in place for context.

Notes:
- Parent flow is currently localStorage-only — the recipientRoutes that
  landed on main aren't wired yet. BACKEND_HANDOFF_V2 §10 documents the
  migration plan.
- /login was intentionally not touched — main's existing login (with
  real backend wiring) stands.
- Photos: inline data URLs (4MB cap) pending S3.
- Audio: transcript only on the parent side; raw audio storage is on
  the backend handoff list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@slopless-scanner
Copy link
Copy Markdown

⚠️ Slopless Review

Confidence: 🟡 4/5 · Verdict: REQUEST_CHANGES · Risk: HIGH · Findings: 18

Reviewed the PR diff without prior scan context (no architecture artifacts found). Produced 18 finding(s) across 4 vulnerability class(es) (architecture, best_practice, code_quality (+1 more)) in 61.7s.

PR review complete: 18 findings, verdict: request_changes

Scope & coverage
  • Lines: 4122
  • Checks performed: architecture · best_practice · code_quality · security

Findings

🛑 CRITICAL — Missing Token-Based Authorization on All Parent Routes

BACKEND_HANDOFF_V2.md:1-50 · confidence: high · CWE-639

The parent recipient flow (/r/[token]/*) uses a token-based access model, but there is no documented authorization mechanism to verify that a user accessing /r/[token] actually owns or is authorized to access that token. The backend handoff mentions tokens but doesn't specify how to validate them. This is a classic BOLA (Broken Object Level Authorization) vulnerability - any user could potentially access any token by guessing or brute-forcing.

Suggested fix: 1. Implement token validation middleware that verifies the token is valid and not expired
2. Store token-to-recipient mappings in the database
3. Implement rate limiting on token validation to prevent brute force attacks
4. Consider using cryptographically secure tokens (e.g., 32+ random bytes, base64-encoded)
5. Add token expiration logic
6. Document the exact authorization flow in the backend handoff

Code context
Routes under `/r/[token]/*` — recipient's journal state, scoped per token

No authorization validation documented for token access.

🛑 CRITICAL — No Backend Implementation for AI Endpoints - Security Implications

web/app/r/[token]/ai/page.tsx:54-67 · confidence: high · CWE-434

The PR adds frontend calls to /api/ai/converse and /api/ai/summarize endpoints (web/app/r/[token]/ai/page.tsx lines 54-67, 88-104), but the backend implementation is incomplete. The BACKEND_HANDOFF_V2.md references these endpoints but doesn't provide full specifications. Without proper backend validation, the frontend could send arbitrary requests. Additionally, there's no documented rate limiting or cost control for Claude API calls, which could lead to abuse.

Suggested fix: 1. Implement complete backend endpoint specifications with input validation
2. Add rate limiting per token (e.g., max 10 requests per hour)
3. Validate message history length to prevent token exhaustion attacks
4. Implement cost tracking for Claude API calls
5. Add request signing or CSRF tokens to prevent cross-origin abuse
6. Document the exact request/response schema in the backend handoff

Code context
const res = await fetch(`/api/ai/converse`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    topic: "parent-reflect",
    messages: history,
    intent: "n/a",
  }),
})

🛑 CRITICAL — Audio File Handling - No Validation or Size Limits Documented

web/app/r/[token]/_components/EntryComposer.tsx:130-160 · confidence: high · CWE-400

The EntryComposer component (web/app/r/[token]/_components/EntryComposer.tsx) handles audio recording and sends it to /api/transcribe endpoint. While the frontend has a 4MB photo limit, there's no documented limit for audio files. The backend transcribe endpoint (server/src/routes/transcribe.ts) is not shown in full, but audio files could be arbitrarily large, leading to DoS attacks or excessive API costs.

Suggested fix: 1. Add frontend audio file size validation (recommend max 25MB for 10-minute recordings)
2. Implement backend file size limits on /api/transcribe
3. Add rate limiting per token for transcription requests
4. Validate audio MIME types
5. Implement timeout for transcription requests
6. Document OpenAI Whisper API cost implications and add cost tracking

Code context
const blob = new Blob(chunksRef.current, {
  type: mr.mimeType || "audio/webm",
})
// No size validation before sending to backend
await transcribeAudio(blob)

🔴 HIGH — localStorage-Based State Persistence - Data Exposure Risk

web/app/r/[token]/_lib/state.ts:30-50 · confidence: high · CWE-522

The entire parent journal state is persisted to localStorage (web/app/r/[token]/_lib/state.ts), including journal entries, sharing settings, and email addresses. localStorage is vulnerable to XSS attacks and is not encrypted. If an attacker gains XSS access, they can read all journal entries. Additionally, localStorage is not cleared on logout in some browsers, and the data persists across browser sessions.

Suggested fix: 1. Migrate to server-side session storage after account creation
2. Use secure, httpOnly cookies for session tokens
3. Implement CSRF protection
4. Clear localStorage on logout (already done in AvatarMenu.tsx, but verify completeness)
5. Consider encrypting sensitive data in localStorage as a fallback
6. Add Content Security Policy (CSP) headers to prevent XSS
7. Implement Subresource Integrity (SRI) for external scripts

Code context
const raw = localStorage.getItem(storageKey(token))
if (raw) setState({ ...DEFAULT_STATE, ...JSON.parse(raw) })
localStorage.setItem(storageKey(token), JSON.stringify(next))

🔴 HIGH — No CSRF Protection on State-Modifying Endpoints

web/app/r/[token]/ai/page.tsx:54-67 · confidence: high · CWE-352

The frontend makes POST requests to /api/ai/converse, /api/ai/summarize, and /api/transcribe without any CSRF tokens. While these are API endpoints, they modify state (creating journal entries, sharing journals). A malicious website could trick a parent into making these requests.

Suggested fix: 1. Implement CSRF token generation and validation
2. Include CSRF token in all POST/PUT/DELETE requests
3. Use SameSite cookie attribute (SameSite=Strict for sensitive operations)
4. Validate Origin and Referer headers on the backend
5. Consider using double-submit cookie pattern as fallback

Code context
const res = await fetch(`/api/ai/converse`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    topic: "parent-reflect",
    messages: history,
    intent: "n/a",
  }),
})

🔴 HIGH — Sharing State Modification Without Backend Validation

web/app/r/[token]/share/page.tsx:40-50 · confidence: high · CWE-434

The share flow (web/app/r/[token]/share/page.tsx) allows users to set sharedAt timestamp in localStorage without backend verification. A malicious user could modify localStorage to mark their journal as shared without actually sending it. The backend must validate and enforce the sharing state.

Suggested fix: 1. Implement backend endpoint for sharing that validates and persists the sharing state
2. Backend should generate the sharedAt timestamp, not the client
3. Implement idempotency checks to prevent double-sharing
4. Log all sharing events for audit purposes
5. Validate that the journal has entries before allowing sharing
6. Implement backend-side archive enforcement (read-only after sharing)

Code context
const now = new Date().toISOString()
update({
  data: {
    sharing: {
      ...sharing,
      sharedAt: now,
      lastSharedSnapshotCount: entries.length,
    },
  },
})

🔴 HIGH — No Input Validation on Journal Entry Text

web/app/r/[token]/_components/EntryComposer.tsx:1-10 · confidence: medium · CWE-79

The EntryComposer accepts up to 4000 characters of text (TEXT_MAX = 4000) but there's no validation for malicious content like XSS payloads, SQL injection patterns, or other attacks. The text is stored in localStorage and later displayed without sanitization.

Suggested fix: 1. Implement input sanitization on the backend for all text fields
2. Use a library like DOMPurify on the frontend for display
3. Validate text encoding (UTF-8)
4. Reject entries with suspicious patterns (e.g., script tags, SQL keywords)
5. Implement rate limiting on entry creation (e.g., max 10 entries per hour)
6. Store raw text on backend, sanitize on display

Code context
const TEXT_MAX = 4000

export type EntryDraft = {
  text?: string
  photoDataUrl?: string
  source: JournalEntrySource
  promptId?: string
  promptText?: string
}

🔴 HIGH — Photo Data URLs Stored in localStorage - Memory and Security Risk

web/app/r/[token]/_components/EntryComposer.tsx:75-90 · confidence: high · CWE-400

Photos are converted to base64 data URLs and stored in localStorage (EntryComposer.tsx line 82). This is inefficient (base64 is 33% larger than binary) and creates a security risk. localStorage has a typical 5-10MB limit, and large photos could exhaust it. Additionally, base64-encoded photos in localStorage are vulnerable to XSS.

Suggested fix: 1. Upload photos directly to backend/cloud storage (S3, Supabase Storage, etc.)
2. Store only the photo URL/ID in localStorage
3. Implement server-side photo validation (MIME type, dimensions, file size)
4. Use signed URLs for secure photo access
5. Implement photo deletion when journal is archived
6. Consider image optimization (compression, resizing) on the backend

Code context
const reader = new FileReader()
reader.onload = () => {
  if (typeof reader.result === "string") setPhotoDataUrl(reader.result)
}
reader.readAsDataURL(file)

🟠 MEDIUM — Account Creation Without Email Verification

web/app/r/[token]/account/page.tsx:40-50 · confidence: high · CWE-640

The account page (web/app/r/[token]/account/page.tsx) accepts an email and password but doesn't implement email verification. A user could enter any email address, and the backend would have no way to verify ownership. This could lead to account takeover or impersonation.

Suggested fix: 1. Implement email verification flow (send verification link to email)
2. Require email verification before account is fully created
3. Implement password reset flow with email verification
4. Store email hash on backend to prevent enumeration attacks
5. Implement rate limiting on account creation (e.g., 5 per IP per hour)
6. Validate email format more strictly (RFC 5322)

Code context
const submit = (e: FormEvent) => {
  e.preventDefault()
  setTouched(true)
  if (!canSubmit) return
  // Don't persist password — backend hashes server-side. Just save email.
  update({ step: "home", data: { email, accountCreated: true } })
  router.push(`/r/${token}/home`)

🟠 MEDIUM — Password Transmitted in Plain Text Over HTTPS (Minor Issue)

web/app/r/[token]/account/page.tsx:40-50 · confidence: low · CWE-256

While the comment says 'backend hashes server-side', the password is sent in the JSON body over HTTPS. This is acceptable, but the frontend should not store or log the password. The current implementation appears correct, but this should be explicitly documented.

Suggested fix: 1. Ensure password is never logged or stored in localStorage
2. Clear password from memory after submission
3. Implement password strength requirements (min 12 characters, complexity)
4. Consider using bcrypt or Argon2 for password hashing on backend
5. Implement rate limiting on login attempts (e.g., 5 per IP per 15 minutes)

Code context
// Don't persist password — backend hashes server-side. Just save email.
update({ step: "home", data: { email, accountCreated: true } })

🔴 HIGH — Incomplete Backend Specification - Missing Critical Endpoints

BACKEND_HANDOFF_V2.md:1-100 · confidence: high

The BACKEND_HANDOFF_V2.md is comprehensive but incomplete. Several critical endpoints are referenced but not fully specified: GET /r/:token (fetch gift metadata), POST /sharing/:token (persist sharing state), GET /entries/:token (fetch entries), POST /entries/:token (create entry). Without these, the frontend cannot persist data to the backend.

Suggested fix: 1. Complete the endpoint specifications with full request/response schemas
2. Add authentication/authorization requirements for each endpoint
3. Specify error handling and status codes
4. Add rate limiting specifications
5. Document data validation rules
6. Add examples of request/response payloads
7. Specify database schema for recipients, tokens, entries, sharing state

Code context
Comprehensive, page-by-page backend spec covering everything the frontend currently collects, every route, every API call, and every state field.

🟠 MEDIUM — Hardcoded Mock Data in Production Code

web/app/r/[token]/account/page.tsx:8-10 · confidence: high

Multiple pages have hardcoded mock data that will be used in production until the backend is implemented: MOCK_PREFILLED_EMAIL, MOCK_GIVER, MOCK_RECIPIENT, MOCK. This could lead to confusion and security issues if not replaced before deployment.

Suggested fix: 1. Replace all mock data with actual backend API calls
2. Implement proper error handling for missing data
3. Add loading states while fetching data
4. Use environment variables to distinguish between mock and real data
5. Add a feature flag to enable/disable mock mode
6. Remove mock data before production deployment

Code context
// Backend will pre-fill this from the recipient row keyed by the token.
// Hardcoded for now so the design renders.
const MOCK_PREFILLED_EMAIL = "your.email@example.com"

🟠 MEDIUM — Missing Error Handling in AI Chat

web/app/r/[token]/ai/page.tsx:54-75 · confidence: medium

The AI chat page (web/app/r/[token]/ai/page.tsx) has basic error handling, but doesn't handle network timeouts, partial responses, or API rate limiting. If the Claude API is rate-limited, users will see a generic error message.

Suggested fix: 1. Implement specific error handling for different HTTP status codes (429, 500, 503)
2. Add exponential backoff for retries
3. Implement timeout handling (e.g., 30-second timeout)
4. Show user-friendly error messages for rate limiting
5. Log errors for debugging
6. Implement circuit breaker pattern for API calls

Code context
try {
  const res = await fetch(`/api/ai/converse`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      topic: "parent-reflect",
      messages: history,
      intent: "n/a",
    }),
  })
  const data = (await res.json()) as { message?: string; error?: string }
  if (!res.ok || !data.message) {
    setError(data.error || "Couldn't reach Ember.")
    return
  }
} catch {
  setError("Couldn't reach Ember.")
}

🟡 LOW — Missing Accessibility Attributes

web/app/r/[token]/_components/BottomTabs.tsx:85-110 · confidence: low

While the code has some ARIA attributes (aria-haspopup, aria-expanded, aria-label), several interactive elements are missing proper accessibility attributes. For example, the BottomTabs component uses aria-current but doesn't have proper role attributes.

Suggested fix: 1. Add role="tablist" to the tabs container
2. Add role="tab" to individual tab buttons
3. Add aria-selected to indicate active tab
4. Add aria-controls to link tabs to their content
5. Implement keyboard navigation (arrow keys)
6. Test with screen readers (NVDA, JAWS, VoiceOver)

Code context
<div
  className="fixed bottom-0 left-0 right-0 border-t border-neutral-300/60"
  style={{ backgroundColor: "#F1ECE2" }}
>
  <div className="mx-auto w-full max-w-md px-3 py-3 sm:px-5">
    <div className="grid grid-cols-4 gap-2">

🟠 MEDIUM — No Rate Limiting on Entry Creation

web/app/r/[token]/home/page.tsx:50-60 · confidence: medium · CWE-770

Users can create unlimited journal entries without any rate limiting. A malicious user could spam the system with thousands of entries, exhausting storage and API quotas.

Suggested fix: 1. Implement frontend rate limiting (e.g., max 10 entries per hour)
2. Implement backend rate limiting per token
3. Add cooldown between entries (e.g., 5-minute minimum)
4. Monitor for abuse patterns
5. Implement quota system (e.g., max 1000 entries per journal)

Code context
const handleSave = (draft: EntryDraft) => {
  const entry: JournalEntry = {
    id: newEntryId(),
    createdAt: new Date().toISOString(),
    ...draft,
  }
  update({ data: { entries: [entry, ...entries] } })
  setComposerKey((k) => k + 1)
  setSavedToast(true)
  setTimeout(() => setSavedToast(false), 2000)
}

…and 3 more findings elided for length.


Reviewed by Slopless · Install on your repo · comment @slopless to re-run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant