KSvend Claude Happy commited on
Commit
02bd17c
Β·
1 Parent(s): e473fb0

docs: add user accounts & job history implementation plan

Browse files

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

docs/superpowers/plans/2026-03-29-user-accounts-job-history.md ADDED
@@ -0,0 +1,1256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # User Accounts & Job History β€” Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add magic-link authentication, associate jobs with users, and provide a history page showing past analyses.
6
+
7
+ **Architecture:** Backend-first approach. Add email column to DB, build auth middleware as a FastAPI dependency, protect API routes, then wire up frontend login/history pages with session management. Demo mode auto-fills tokens so no email service needed for development.
8
+
9
+ **Tech Stack:** FastAPI (Python), vanilla JS SPA, aiosqlite, HMAC-SHA256 tokens, sessionStorage
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-03-28-user-accounts-job-history-design.md`
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ ### Backend (modify)
18
+ - `app/database.py` β€” add `email` column migration, `get_jobs_by_email()`, update `create_job()`
19
+ - `app/api/auth.py` β€” add `get_current_user()` dependency
20
+ - `app/api/jobs.py` β€” add `GET /api/jobs` list endpoint, protect routes with auth
21
+
22
+ ### Frontend (modify)
23
+ - `frontend/index.html` β€” add login page, history page, topbar links
24
+ - `frontend/js/api.js` β€” add auth header to `apiFetch()`, add `requestMagicLink()`, `verifyToken()`, `listJobs()`
25
+ - `frontend/js/app.js` β€” add login/history page setup, session management, nav guard, email auto-fill
26
+ - `frontend/css/merlx.css` β€” login page styles, history card styles
27
+
28
+ ### Tests (create/modify)
29
+ - `tests/test_database.py` β€” add tests for email column and `get_jobs_by_email()`
30
+ - `tests/test_api_auth.py` β€” create: test `get_current_user()` dependency
31
+ - `tests/test_api_jobs.py` β€” update: test auth on existing routes, test `GET /api/jobs` list
32
+
33
+ ---
34
+
35
+ ## Task 1: Database β€” Add Email Column & Query
36
+
37
+ **Files:**
38
+ - Modify: `app/database.py`
39
+ - Modify: `tests/test_database.py`
40
+
41
+ - [ ] **Step 1: Write failing tests for email column and get_jobs_by_email**
42
+
43
+ Add to `tests/test_database.py`:
44
+
45
+ ```python
46
+ @pytest.mark.asyncio
47
+ async def test_create_job_stores_email(temp_db_path, sample_job_request):
48
+ db = Database(temp_db_path)
49
+ await db.init()
50
+ job_id = await db.create_job(sample_job_request)
51
+ # Read raw row to confirm email column
52
+ async with aiosqlite.connect(temp_db_path) as conn:
53
+ cur = await conn.execute("SELECT email FROM jobs WHERE id = ?", (job_id,))
54
+ row = await cur.fetchone()
55
+ assert row[0] == "test@example.com"
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_get_jobs_by_email(temp_db_path, sample_job_request):
60
+ db = Database(temp_db_path)
61
+ await db.init()
62
+ id1 = await db.create_job(sample_job_request)
63
+ id2 = await db.create_job(sample_job_request)
64
+ jobs = await db.get_jobs_by_email("test@example.com")
65
+ assert len(jobs) == 2
66
+ # Most recent first
67
+ assert jobs[0].id == id2
68
+ assert jobs[1].id == id1
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_get_jobs_by_email_filters(temp_db_path, sample_job_request):
73
+ db = Database(temp_db_path)
74
+ await db.init()
75
+ await db.create_job(sample_job_request)
76
+ jobs = await db.get_jobs_by_email("other@example.com")
77
+ assert len(jobs) == 0
78
+ ```
79
+
80
+ Add `import aiosqlite` to the top of the test file.
81
+
82
+ - [ ] **Step 2: Run tests to verify they fail**
83
+
84
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_database.py -v -k "email"`
85
+ Expected: FAIL β€” `email` column does not exist, `get_jobs_by_email` not defined
86
+
87
+ - [ ] **Step 3: Implement email column and get_jobs_by_email**
88
+
89
+ In `app/database.py`, update the `init()` method β€” after the existing `CREATE TABLE IF NOT EXISTS jobs` statement, add the migration:
90
+
91
+ ```python
92
+ # Migration: add email column if missing
93
+ cur = await db.execute("PRAGMA table_info(jobs)")
94
+ columns = {row[1] for row in await cur.fetchall()}
95
+ if "email" not in columns:
96
+ await db.execute(
97
+ "ALTER TABLE jobs ADD COLUMN email TEXT NOT NULL DEFAULT ''"
98
+ )
99
+ await db.commit()
100
+ ```
101
+
102
+ Update the `create_job()` method β€” in the INSERT statement, add `email` column:
103
+
104
+ Change the INSERT from:
105
+ ```python
106
+ await db.execute(
107
+ """INSERT INTO jobs (id, request_json, status, progress_json,
108
+ results_json, error, created_at, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
110
+ (job_id, request.model_dump_json(), JobStatus.QUEUED.value,
111
+ json.dumps(progress), "[]", None, now, now),
112
+ )
113
+ ```
114
+
115
+ To:
116
+ ```python
117
+ await db.execute(
118
+ """INSERT INTO jobs (id, request_json, status, progress_json,
119
+ results_json, error, created_at, updated_at,
120
+ email)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
122
+ (job_id, request.model_dump_json(), JobStatus.QUEUED.value,
123
+ json.dumps(progress), "[]", None, now, now, request.email),
124
+ )
125
+ ```
126
+
127
+ Add the new method after `get_next_queued_job()`:
128
+
129
+ ```python
130
+ async def get_jobs_by_email(self, email: str) -> list[Job]:
131
+ async with aiosqlite.connect(self._db_path) as db:
132
+ db.row_factory = aiosqlite.Row
133
+ cur = await db.execute(
134
+ "SELECT * FROM jobs WHERE email = ? ORDER BY created_at DESC",
135
+ (email,),
136
+ )
137
+ rows = await cur.fetchall()
138
+ return [self._row_to_job(row) for row in rows]
139
+ ```
140
+
141
+ - [ ] **Step 4: Run tests to verify they pass**
142
+
143
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_database.py -v`
144
+ Expected: ALL PASS (including existing tests)
145
+
146
+ - [ ] **Step 5: Commit**
147
+
148
+ ```bash
149
+ cd /Users/kmini/Github/Aperture
150
+ git add app/database.py tests/test_database.py
151
+ git commit -m "feat: add email column to jobs table and get_jobs_by_email query"
152
+ ```
153
+
154
+ ---
155
+
156
+ ## Task 2: Auth Middleware β€” get_current_user Dependency
157
+
158
+ **Files:**
159
+ - Modify: `app/api/auth.py`
160
+ - Create: `tests/test_api_auth.py`
161
+
162
+ - [ ] **Step 1: Write failing tests for get_current_user**
163
+
164
+ Create `tests/test_api_auth.py`:
165
+
166
+ ```python
167
+ """Tests for auth middleware β€” get_current_user dependency."""
168
+ import time
169
+ import hashlib
170
+ import pytest
171
+ from httpx import AsyncClient, ASGITransport
172
+ from app.main import create_app
173
+
174
+
175
+ def _make_token(email: str) -> str:
176
+ """Mirror the backend token generation for test fixtures."""
177
+ secret = "aperture-dev-secret"
178
+ hour = int(time.time() // 3600)
179
+ payload = f"{email}:{hour}:{secret}"
180
+ return hashlib.sha256(payload.encode()).hexdigest()[:32]
181
+
182
+
183
+ @pytest.fixture
184
+ async def client(tmp_path):
185
+ app = create_app(db_path=str(tmp_path / "test.db"), run_worker=False)
186
+ transport = ASGITransport(app=app)
187
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
188
+ yield c
189
+
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_auth_header_valid(client):
193
+ email = "user@example.com"
194
+ token = _make_token(email)
195
+ resp = await client.get(
196
+ "/api/jobs",
197
+ headers={"Authorization": f"Bearer {email}:{token}"},
198
+ )
199
+ assert resp.status_code == 200
200
+
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_auth_header_missing(client):
204
+ resp = await client.get("/api/jobs")
205
+ assert resp.status_code == 401
206
+ assert "missing" in resp.json()["detail"].lower() or "authorization" in resp.json()["detail"].lower()
207
+
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_auth_header_bad_token(client):
211
+ resp = await client.get(
212
+ "/api/jobs",
213
+ headers={"Authorization": "Bearer user@example.com:badtoken"},
214
+ )
215
+ assert resp.status_code == 401
216
+
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_auth_header_malformed(client):
220
+ resp = await client.get(
221
+ "/api/jobs",
222
+ headers={"Authorization": "Bearer garbage"},
223
+ )
224
+ assert resp.status_code == 401
225
+ ```
226
+
227
+ - [ ] **Step 2: Run tests to verify they fail**
228
+
229
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_api_auth.py -v`
230
+ Expected: FAIL β€” `GET /api/jobs` endpoint doesn't exist yet, no auth middleware
231
+
232
+ - [ ] **Step 3: Implement get_current_user dependency**
233
+
234
+ In `app/api/auth.py`, add after the existing imports:
235
+
236
+ ```python
237
+ from fastapi import Header, HTTPException
238
+ ```
239
+
240
+ Then add the dependency function after the existing `verify_token` endpoint:
241
+
242
+ ```python
243
+ async def get_current_user(authorization: str = Header(default=None)) -> str:
244
+ """FastAPI dependency: extract and verify email from Authorization header.
245
+
246
+ Expected format: Bearer <email>:<token>
247
+ Returns the verified email address.
248
+ """
249
+ if not authorization:
250
+ raise HTTPException(status_code=401, detail="Authorization header missing")
251
+ parts = authorization.split(" ", 1)
252
+ if len(parts) != 2 or parts[0] != "Bearer":
253
+ raise HTTPException(status_code=401, detail="Invalid authorization format")
254
+ payload = parts[1]
255
+ if ":" not in payload:
256
+ raise HTTPException(status_code=401, detail="Invalid token format")
257
+ email, token = payload.split(":", 1)
258
+ # Verify against current and previous hour (handle clock edge)
259
+ for offset in (0, -1):
260
+ expected = _generate_token_for_hour(email, offset)
261
+ if hmac.compare_digest(token, expected):
262
+ return email
263
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
264
+
265
+
266
+ def _generate_token_for_hour(email: str, hour_offset: int = 0) -> str:
267
+ """Generate token for a specific hour offset (0 = current, -1 = previous)."""
268
+ hour = int(time.time() // 3600) + hour_offset
269
+ payload = f"{email}:{hour}:{SECRET}"
270
+ return hashlib.sha256(payload.encode()).hexdigest()[:32]
271
+ ```
272
+
273
+ Also refactor the existing `_generate_token` to use the new helper:
274
+
275
+ ```python
276
+ def _generate_token(email: str) -> str:
277
+ return _generate_token_for_hour(email, 0)
278
+ ```
279
+
280
+ - [ ] **Step 4: Add GET /api/jobs list endpoint (stub for now)**
281
+
282
+ In `app/api/jobs.py`, add import and the new endpoint:
283
+
284
+ Add to imports:
285
+ ```python
286
+ from app.api.auth import get_current_user
287
+ ```
288
+
289
+ Add new endpoint before the existing `get_job` route:
290
+
291
+ ```python
292
+ @router.get("")
293
+ async def list_jobs(email: str = Depends(get_current_user)):
294
+ jobs = await _db.get_jobs_by_email(email)
295
+ return [
296
+ {
297
+ "id": j.id,
298
+ "status": j.status.value,
299
+ "aoi_name": j.request.aoi.name,
300
+ "created_at": j.created_at.isoformat(),
301
+ "indicator_count": len(j.request.indicator_ids),
302
+ }
303
+ for j in jobs
304
+ ]
305
+ ```
306
+
307
+ Add `Depends` to the FastAPI import:
308
+ ```python
309
+ from fastapi import APIRouter, HTTPException, Depends
310
+ ```
311
+
312
+ - [ ] **Step 5: Run tests to verify they pass**
313
+
314
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_api_auth.py -v`
315
+ Expected: ALL PASS
316
+
317
+ - [ ] **Step 6: Commit**
318
+
319
+ ```bash
320
+ cd /Users/kmini/Github/Aperture
321
+ git add app/api/auth.py app/api/jobs.py tests/test_api_auth.py
322
+ git commit -m "feat: add get_current_user auth dependency and GET /api/jobs list endpoint"
323
+ ```
324
+
325
+ ---
326
+
327
+ ## Task 3: Protect Existing Job Routes with Auth
328
+
329
+ **Files:**
330
+ - Modify: `app/api/jobs.py`
331
+ - Modify: `tests/test_api_jobs.py`
332
+
333
+ - [ ] **Step 1: Write failing tests for auth on existing routes**
334
+
335
+ Update `tests/test_api_jobs.py`. First add the token helper and update the fixture:
336
+
337
+ ```python
338
+ import time
339
+ import hashlib
340
+
341
+ def _make_token(email: str) -> str:
342
+ secret = "aperture-dev-secret"
343
+ hour = int(time.time() // 3600)
344
+ payload = f"{email}:{hour}:{secret}"
345
+ return hashlib.sha256(payload.encode()).hexdigest()[:32]
346
+
347
+ def _auth_headers(email: str = "test@example.com") -> dict:
348
+ token = _make_token(email)
349
+ return {"Authorization": f"Bearer {email}:{token}"}
350
+ ```
351
+
352
+ Add these new tests:
353
+
354
+ ```python
355
+ @pytest.mark.asyncio
356
+ async def test_submit_job_requires_auth(client, sample_job_request):
357
+ resp = await client.post("/api/jobs", json=sample_job_request.model_dump(mode="json"))
358
+ assert resp.status_code == 401
359
+
360
+
361
+ @pytest.mark.asyncio
362
+ async def test_get_job_requires_auth(client, sample_job_request):
363
+ # Create a job first (with auth)
364
+ resp = await client.post(
365
+ "/api/jobs",
366
+ json=sample_job_request.model_dump(mode="json"),
367
+ headers=_auth_headers(),
368
+ )
369
+ job_id = resp.json()["id"]
370
+ # Try to get without auth
371
+ resp = await client.get(f"/api/jobs/{job_id}")
372
+ assert resp.status_code == 401
373
+
374
+
375
+ @pytest.mark.asyncio
376
+ async def test_get_job_wrong_user(client, sample_job_request):
377
+ resp = await client.post(
378
+ "/api/jobs",
379
+ json=sample_job_request.model_dump(mode="json"),
380
+ headers=_auth_headers("test@example.com"),
381
+ )
382
+ job_id = resp.json()["id"]
383
+ resp = await client.get(
384
+ f"/api/jobs/{job_id}",
385
+ headers=_auth_headers("other@example.com"),
386
+ )
387
+ assert resp.status_code == 403
388
+ ```
389
+
390
+ Update the existing passing tests to include auth headers:
391
+
392
+ ```python
393
+ @pytest.mark.asyncio
394
+ async def test_submit_job(client, sample_job_request):
395
+ resp = await client.post(
396
+ "/api/jobs",
397
+ json=sample_job_request.model_dump(mode="json"),
398
+ headers=_auth_headers(),
399
+ )
400
+ assert resp.status_code == 201
401
+ data = resp.json()
402
+ assert "id" in data
403
+
404
+
405
+ @pytest.mark.asyncio
406
+ async def test_get_job(client, sample_job_request):
407
+ resp = await client.post(
408
+ "/api/jobs",
409
+ json=sample_job_request.model_dump(mode="json"),
410
+ headers=_auth_headers(),
411
+ )
412
+ job_id = resp.json()["id"]
413
+ resp = await client.get(f"/api/jobs/{job_id}", headers=_auth_headers())
414
+ assert resp.status_code == 200
415
+ assert resp.json()["id"] == job_id
416
+ ```
417
+
418
+ - [ ] **Step 2: Run tests to verify new tests fail**
419
+
420
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_api_jobs.py -v`
421
+ Expected: New auth tests FAIL (routes not yet protected)
422
+
423
+ - [ ] **Step 3: Add auth dependency to existing routes**
424
+
425
+ In `app/api/jobs.py`, update the submit and get endpoints:
426
+
427
+ ```python
428
+ @router.post("", status_code=201)
429
+ async def submit_job(request: JobRequest, email: str = Depends(get_current_user)):
430
+ job_id = await _db.create_job(request)
431
+ job = await _db.get_job(job_id)
432
+ return {"id": job.id, "status": job.status.value}
433
+
434
+
435
+ @router.get("/{job_id}")
436
+ async def get_job(job_id: str, email: str = Depends(get_current_user)):
437
+ job = await _db.get_job(job_id)
438
+ if job is None:
439
+ raise HTTPException(status_code=404, detail="Job not found")
440
+ # Verify job belongs to authenticated user
441
+ if job.request.email != email:
442
+ raise HTTPException(status_code=403, detail="Not your job")
443
+ return {
444
+ "id": job.id,
445
+ "status": job.status.value,
446
+ "progress": job.progress,
447
+ "results": [r.model_dump() for r in job.results],
448
+ "created_at": job.created_at.isoformat(),
449
+ "updated_at": job.updated_at.isoformat(),
450
+ "error": job.error,
451
+ }
452
+ ```
453
+
454
+ - [ ] **Step 4: Run all tests**
455
+
456
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v`
457
+ Expected: ALL PASS. Check that existing tests in other files still pass (worker tests use DB directly, not API).
458
+
459
+ - [ ] **Step 5: Commit**
460
+
461
+ ```bash
462
+ cd /Users/kmini/Github/Aperture
463
+ git add app/api/jobs.py tests/test_api_jobs.py
464
+ git commit -m "feat: protect job API routes with auth, verify job ownership"
465
+ ```
466
+
467
+ ---
468
+
469
+ ## Task 4: Frontend API β€” Auth Header & New Endpoints
470
+
471
+ **Files:**
472
+ - Modify: `frontend/js/api.js`
473
+
474
+ - [ ] **Step 1: Add session-aware auth header to apiFetch**
475
+
476
+ In `frontend/js/api.js`, update the `apiFetch` function. Currently it looks like:
477
+
478
+ ```javascript
479
+ async function apiFetch(path, opts = {}) {
480
+ ```
481
+
482
+ Replace with:
483
+
484
+ ```javascript
485
+ async function apiFetch(path, opts = {}) {
486
+ const session = JSON.parse(sessionStorage.getItem('aperture_session') || 'null');
487
+ if (session) {
488
+ opts.headers = {
489
+ ...opts.headers,
490
+ 'Authorization': `Bearer ${session.email}:${session.token}`,
491
+ };
492
+ }
493
+ ```
494
+
495
+ The rest of the function body stays the same.
496
+
497
+ - [ ] **Step 2: Add new API functions**
498
+
499
+ Add these exports at the bottom of `frontend/js/api.js`:
500
+
501
+ ```javascript
502
+ export async function requestMagicLink(email) {
503
+ return apiFetch('/api/auth/request', {
504
+ method: 'POST',
505
+ headers: { 'Content-Type': 'application/json' },
506
+ body: JSON.stringify({ email }),
507
+ });
508
+ }
509
+
510
+ export async function verifyToken(email, token) {
511
+ return apiFetch('/api/auth/verify', {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json' },
514
+ body: JSON.stringify({ email, token }),
515
+ });
516
+ }
517
+
518
+ export async function listJobs() {
519
+ return apiFetch('/api/jobs');
520
+ }
521
+ ```
522
+
523
+ - [ ] **Step 3: Commit**
524
+
525
+ ```bash
526
+ cd /Users/kmini/Github/Aperture
527
+ git add frontend/js/api.js
528
+ git commit -m "feat: add auth header to API calls, add login and listJobs API functions"
529
+ ```
530
+
531
+ ---
532
+
533
+ ## Task 5: Frontend β€” Login Page HTML & CSS
534
+
535
+ **Files:**
536
+ - Modify: `frontend/index.html`
537
+ - Modify: `frontend/css/merlx.css`
538
+
539
+ - [ ] **Step 1: Add login page HTML**
540
+
541
+ In `frontend/index.html`, add the login page between the `page-landing` closing `</div>` (line 111) and the `page-define-area` comment (line 114):
542
+
543
+ ```html
544
+ <!-- ═══════════════════════════════════════════════════════════
545
+ PAGE β€” LOGIN
546
+ ═══════════════════════════════════════════════════════════ -->
547
+ <div id="page-login" class="page">
548
+ <div class="login-card">
549
+
550
+ <!-- Logo -->
551
+ <div class="login-logo">
552
+ MERL<span style="color: var(--iris)">x</span>
553
+ </div>
554
+ <div style="font-family: 'DM Serif Display', serif; font-style: italic; font-size: var(--text-lg); color: var(--ink-muted); margin-bottom: var(--space-10); text-align: center;">
555
+ Aperture
556
+ </div>
557
+
558
+ <!-- State 1: Email entry -->
559
+ <div id="login-email-step">
560
+ <h2 class="login-heading">Sign in to continue</h2>
561
+ <div class="form-group" style="margin-bottom: var(--space-8);">
562
+ <label class="label" for="login-email">Email address</label>
563
+ <input id="login-email" class="input" type="email" placeholder="you@organisation.org" />
564
+ </div>
565
+ <button id="login-send-btn" class="btn btn-primary" style="width:100%;">
566
+ Send magic link
567
+ </button>
568
+ <p class="login-helper">In demo mode, the code will appear automatically.</p>
569
+ </div>
570
+
571
+ <!-- State 2: Token entry -->
572
+ <div id="login-token-step" style="display:none;">
573
+ <h2 class="login-heading" id="login-token-heading">Check your email</h2>
574
+ <div class="form-group" style="margin-bottom: var(--space-8);">
575
+ <label class="label" for="login-token">Verification code</label>
576
+ <input id="login-token" class="input" type="text" placeholder="Paste your code" />
577
+ </div>
578
+ <button id="login-verify-btn" class="btn btn-primary" style="width:100%;">
579
+ Verify
580
+ </button>
581
+ <a href="#" id="login-back-link" class="login-back-link">Use a different email</a>
582
+ </div>
583
+
584
+ </div>
585
+ </div>
586
+ ```
587
+
588
+ - [ ] **Step 2: Add login page styles**
589
+
590
+ In `frontend/css/merlx.css`, add before the `/* --- Map Page --- */` section:
591
+
592
+ ```css
593
+ /* --- Login Page --- */
594
+ #page-login {
595
+ align-items: center;
596
+ justify-content: center;
597
+ background-color: var(--shell);
598
+ }
599
+
600
+ .login-card {
601
+ background-color: var(--surface);
602
+ border: 1px solid var(--border-light);
603
+ border-radius: var(--radius-lg);
604
+ padding: var(--space-12);
605
+ width: 100%;
606
+ max-width: 380px;
607
+ }
608
+
609
+ .login-logo {
610
+ font-size: 22px;
611
+ font-weight: 700;
612
+ letter-spacing: -0.3px;
613
+ text-align: center;
614
+ margin-bottom: var(--space-2);
615
+ }
616
+
617
+ .login-heading {
618
+ font-size: var(--text-lg);
619
+ font-weight: 600;
620
+ margin-bottom: var(--space-8);
621
+ letter-spacing: -0.2px;
622
+ }
623
+
624
+ .login-helper {
625
+ font-size: var(--text-xs);
626
+ color: var(--ink-faint);
627
+ text-align: center;
628
+ margin-top: var(--space-5);
629
+ }
630
+
631
+ .login-back-link {
632
+ display: block;
633
+ text-align: center;
634
+ margin-top: var(--space-5);
635
+ font-size: var(--text-xs);
636
+ color: var(--ink-muted);
637
+ text-decoration: none;
638
+ }
639
+
640
+ .login-back-link:hover {
641
+ color: var(--iris-dark);
642
+ text-decoration: underline;
643
+ }
644
+ ```
645
+
646
+ - [ ] **Step 3: Add `#page-login` height override in index.html `<style>` block**
647
+
648
+ In the `<style>` block in `index.html`, add after the `#page-landing` rule:
649
+
650
+ ```css
651
+ #page-login {
652
+ height: 100vh;
653
+ align-items: center;
654
+ justify-content: center;
655
+ background-color: var(--shell);
656
+ }
657
+ ```
658
+
659
+ - [ ] **Step 4: Commit**
660
+
661
+ ```bash
662
+ cd /Users/kmini/Github/Aperture
663
+ git add frontend/index.html frontend/css/merlx.css
664
+ git commit -m "feat: add login page HTML and CSS"
665
+ ```
666
+
667
+ ---
668
+
669
+ ## Task 6: Frontend β€” History Page HTML & CSS
670
+
671
+ **Files:**
672
+ - Modify: `frontend/index.html`
673
+ - Modify: `frontend/css/merlx.css`
674
+
675
+ - [ ] **Step 1: Add history page HTML**
676
+
677
+ In `frontend/index.html`, add before the results page comment:
678
+
679
+ ```html
680
+ <!-- ═══════════════════════════════════════════════════════════
681
+ PAGE β€” HISTORY
682
+ ═══════════════════════════════════════════════════════════ -->
683
+ <div id="page-history" class="page">
684
+
685
+ <!-- Top bar -->
686
+ <div class="history-topbar">
687
+ <span class="logo">MERL<span class="x">x</span></span>
688
+ <h2 style="font-size: var(--text-base); font-weight: 600; color: var(--ink);">My Analyses</h2>
689
+ <div style="display:flex; align-items:center; gap: var(--space-5);">
690
+ <button id="history-new-btn" class="btn btn-primary">New analysis</button>
691
+ <a href="#" id="history-signout" class="topbar-link">Sign out</a>
692
+ </div>
693
+ </div>
694
+
695
+ <!-- Job list -->
696
+ <div id="history-list" class="history-list">
697
+ <!-- Populated by app.js -->
698
+ </div>
699
+
700
+ <!-- Empty state -->
701
+ <div id="history-empty" class="history-empty" style="display:none;">
702
+ <p>No analyses yet. Start your first one.</p>
703
+ <button id="history-empty-cta" class="btn btn-primary" style="margin-top: var(--space-8);">
704
+ New analysis
705
+ </button>
706
+ </div>
707
+
708
+ </div>
709
+ ```
710
+
711
+ - [ ] **Step 2: Add history page styles**
712
+
713
+ In `frontend/css/merlx.css`, add before the `/* --- Results Page --- */` section:
714
+
715
+ ```css
716
+ /* --- History Page --- */
717
+ #page-history {
718
+ flex-direction: column;
719
+ height: 100vh;
720
+ overflow: hidden;
721
+ }
722
+
723
+ .history-topbar {
724
+ padding: var(--space-5) var(--space-12);
725
+ border-bottom: 1px solid var(--border-light);
726
+ background-color: var(--surface);
727
+ flex-shrink: 0;
728
+ display: flex;
729
+ align-items: center;
730
+ justify-content: space-between;
731
+ }
732
+
733
+ .history-list {
734
+ flex: 1;
735
+ overflow-y: auto;
736
+ padding: var(--space-12);
737
+ display: grid;
738
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
739
+ gap: var(--space-5);
740
+ align-content: start;
741
+ }
742
+
743
+ .history-card {
744
+ background-color: var(--surface);
745
+ border: 1px solid var(--border-light);
746
+ border-radius: var(--radius-md);
747
+ padding: var(--space-8);
748
+ cursor: pointer;
749
+ transition: border-color var(--motion-default) var(--ease-default),
750
+ background-color var(--motion-default) var(--ease-default);
751
+ }
752
+
753
+ .history-card:hover {
754
+ border-color: var(--border);
755
+ background-color: var(--shell-cool);
756
+ }
757
+
758
+ .history-card-name {
759
+ font-size: var(--text-base);
760
+ font-weight: 600;
761
+ color: var(--ink);
762
+ margin-bottom: var(--space-3);
763
+ }
764
+
765
+ .history-card-meta {
766
+ display: flex;
767
+ align-items: center;
768
+ justify-content: space-between;
769
+ margin-top: var(--space-4);
770
+ }
771
+
772
+ .history-card-date {
773
+ font-family: var(--font-data);
774
+ font-size: var(--text-xs);
775
+ color: var(--ink-muted);
776
+ }
777
+
778
+ .history-card-indicators {
779
+ font-size: var(--text-xxs);
780
+ color: var(--ink-faint);
781
+ }
782
+
783
+ .history-empty {
784
+ flex: 1;
785
+ display: flex;
786
+ flex-direction: column;
787
+ align-items: center;
788
+ justify-content: center;
789
+ color: var(--ink-muted);
790
+ font-size: var(--text-sm);
791
+ }
792
+
793
+ .topbar-link {
794
+ font-size: var(--text-xs);
795
+ color: var(--ink-muted);
796
+ text-decoration: none;
797
+ font-weight: 500;
798
+ }
799
+
800
+ .topbar-link:hover {
801
+ color: var(--ink);
802
+ text-decoration: underline;
803
+ }
804
+ ```
805
+
806
+ - [ ] **Step 3: Add height override in index.html `<style>` block**
807
+
808
+ Add in the `<style>` block:
809
+
810
+ ```css
811
+ #page-history {
812
+ height: 100vh;
813
+ flex-direction: column;
814
+ overflow: hidden;
815
+ }
816
+ ```
817
+
818
+ - [ ] **Step 4: Commit**
819
+
820
+ ```bash
821
+ cd /Users/kmini/Github/Aperture
822
+ git add frontend/index.html frontend/css/merlx.css
823
+ git commit -m "feat: add history page HTML and CSS"
824
+ ```
825
+
826
+ ---
827
+
828
+ ## Task 7: Frontend β€” Topbar Links on Existing Pages
829
+
830
+ **Files:**
831
+ - Modify: `frontend/index.html`
832
+
833
+ - [ ] **Step 1: Add "My analyses" and "Sign out" links to existing topbars**
834
+
835
+ In `frontend/index.html`, update the **indicators page topbar** β€” add links in the right-side div. Find the indicators topbar right-side div (contains only the Back button) and update:
836
+
837
+ ```html
838
+ <div style="display:flex; align-items:center; gap: var(--space-5);">
839
+ <a href="#" class="topbar-link nav-my-analyses">My analyses</a>
840
+ <a href="#" class="topbar-link nav-signout">Sign out</a>
841
+ <button id="indicators-back-btn" class="btn btn-secondary">Back</button>
842
+ </div>
843
+ ```
844
+
845
+ Update the **results page topbar** β€” add links before the "New analysis" button:
846
+
847
+ ```html
848
+ <div class="results-topbar">
849
+ <span class="logo">MERL<span class="x">x</span></span>
850
+ <h2 style="font-size: var(--text-base); font-weight: 600; color: var(--ink);">Results Dashboard</h2>
851
+ <div style="display:flex; align-items:center; gap: var(--space-5);">
852
+ <a href="#" class="topbar-link nav-my-analyses">My analyses</a>
853
+ <a href="#" class="topbar-link nav-signout">Sign out</a>
854
+ <button
855
+ class="btn btn-secondary"
856
+ id="results-new-btn"
857
+ style="font-size: var(--text-xs);"
858
+ >
859
+ New analysis
860
+ </button>
861
+ </div>
862
+ </div>
863
+ ```
864
+
865
+ Remove the `onclick="window.location.reload()"` from the results new analysis button (we'll wire it up in JS).
866
+
867
+ - [ ] **Step 2: Commit**
868
+
869
+ ```bash
870
+ cd /Users/kmini/Github/Aperture
871
+ git add frontend/index.html
872
+ git commit -m "feat: add My analyses and Sign out links to topbars"
873
+ ```
874
+
875
+ ---
876
+
877
+ ## Task 8: Frontend β€” Session Management, Nav Guard & Login Logic
878
+
879
+ **Files:**
880
+ - Modify: `frontend/js/app.js`
881
+
882
+ - [ ] **Step 1: Add session state and imports**
883
+
884
+ At the top of `frontend/js/app.js`, update the import line:
885
+
886
+ ```javascript
887
+ import { submitJob, getJob, requestMagicLink, verifyToken, listJobs } from './api.js';
888
+ ```
889
+
890
+ Add `session` to the state object:
891
+
892
+ ```javascript
893
+ const state = {
894
+ session: null, // { email, token }
895
+ aoi: null,
896
+ timeRange: null,
897
+ indicators: [],
898
+ jobId: null,
899
+ jobData: null,
900
+ };
901
+ ```
902
+
903
+ - [ ] **Step 2: Add session persistence helpers**
904
+
905
+ Add after the state object:
906
+
907
+ ```javascript
908
+ /* ── Session Persistence ────────────────────────────────── */
909
+
910
+ const SESSION_KEY = 'aperture_session';
911
+
912
+ function saveSession(email, token) {
913
+ state.session = { email, token };
914
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(state.session));
915
+ }
916
+
917
+ function loadSession() {
918
+ const raw = sessionStorage.getItem(SESSION_KEY);
919
+ if (raw) {
920
+ try { state.session = JSON.parse(raw); } catch { state.session = null; }
921
+ }
922
+ }
923
+
924
+ function clearSession() {
925
+ state.session = null;
926
+ sessionStorage.removeItem(SESSION_KEY);
927
+ }
928
+ ```
929
+
930
+ - [ ] **Step 3: Add nav guard to navigate()**
931
+
932
+ Update the `navigate()` function. After the poll timer cleanup and before hiding pages, add:
933
+
934
+ ```javascript
935
+ // Auth guard β€” redirect to login if not authenticated
936
+ const AUTH_REQUIRED = ['define-area', 'indicators', 'confirm', 'status', 'results', 'history'];
937
+ if (AUTH_REQUIRED.includes(pageId) && !state.session) {
938
+ pageId = 'login';
939
+ }
940
+ ```
941
+
942
+ - [ ] **Step 4: Add login and history to PAGES and pageSetup**
943
+
944
+ Update the PAGES array:
945
+
946
+ ```javascript
947
+ const PAGES = ['landing', 'login', 'define-area', 'indicators', 'confirm', 'status', 'results', 'history'];
948
+ ```
949
+
950
+ Add to pageSetup object:
951
+
952
+ ```javascript
953
+ 'login': setupLogin,
954
+ 'history': setupHistory,
955
+ ```
956
+
957
+ - [ ] **Step 5: Implement setupLogin**
958
+
959
+ Add the function:
960
+
961
+ ```javascript
962
+ /* ── Login Page ─────────────────────────────────────────── */
963
+
964
+ let _loginInit = false;
965
+
966
+ function setupLogin() {
967
+ if (_loginInit) return;
968
+ _loginInit = true;
969
+
970
+ const emailStep = document.getElementById('login-email-step');
971
+ const tokenStep = document.getElementById('login-token-step');
972
+ const emailInput = document.getElementById('login-email');
973
+ const tokenInput = document.getElementById('login-token');
974
+ const sendBtn = document.getElementById('login-send-btn');
975
+ const verifyBtn = document.getElementById('login-verify-btn');
976
+ const backLink = document.getElementById('login-back-link');
977
+ const heading = document.getElementById('login-token-heading');
978
+
979
+ let _loginEmail = '';
980
+
981
+ sendBtn.addEventListener('click', async () => {
982
+ const email = emailInput.value.trim();
983
+ if (!email) return;
984
+ sendBtn.disabled = true;
985
+ try {
986
+ const resp = await requestMagicLink(email);
987
+ _loginEmail = email;
988
+ emailStep.style.display = 'none';
989
+ tokenStep.style.display = 'block';
990
+ // Demo mode: auto-fill token
991
+ if (resp.demo_token) {
992
+ heading.textContent = 'Demo code';
993
+ tokenInput.value = resp.demo_token;
994
+ } else {
995
+ heading.textContent = 'Check your email';
996
+ }
997
+ } catch (err) {
998
+ showError(err.message || 'Failed to send magic link');
999
+ } finally {
1000
+ sendBtn.disabled = false;
1001
+ }
1002
+ });
1003
+
1004
+ verifyBtn.addEventListener('click', async () => {
1005
+ const token = tokenInput.value.trim();
1006
+ if (!token) return;
1007
+ verifyBtn.disabled = true;
1008
+ try {
1009
+ await verifyToken(_loginEmail, token);
1010
+ saveSession(_loginEmail, token);
1011
+ navigate('history');
1012
+ } catch (err) {
1013
+ showError(err.message || 'Invalid or expired code');
1014
+ } finally {
1015
+ verifyBtn.disabled = false;
1016
+ }
1017
+ });
1018
+
1019
+ backLink.addEventListener('click', (e) => {
1020
+ e.preventDefault();
1021
+ tokenStep.style.display = 'none';
1022
+ emailStep.style.display = 'block';
1023
+ tokenInput.value = '';
1024
+ });
1025
+ }
1026
+ ```
1027
+
1028
+ - [ ] **Step 6: Implement setupHistory**
1029
+
1030
+ ```javascript
1031
+ /* ── History Page ────────────────────────────────────────── */
1032
+
1033
+ let _historyInit = false;
1034
+
1035
+ function setupHistory() {
1036
+ const listEl = document.getElementById('history-list');
1037
+ const emptyEl = document.getElementById('history-empty');
1038
+
1039
+ // Wire up buttons (once)
1040
+ if (!_historyInit) {
1041
+ _historyInit = true;
1042
+ document.getElementById('history-new-btn').addEventListener('click', () => navigate('define-area'));
1043
+ document.getElementById('history-empty-cta').addEventListener('click', () => navigate('define-area'));
1044
+ document.getElementById('history-signout').addEventListener('click', (e) => {
1045
+ e.preventDefault();
1046
+ clearSession();
1047
+ navigate('landing');
1048
+ });
1049
+ }
1050
+
1051
+ // Fetch and render jobs
1052
+ listJobs().then(jobs => {
1053
+ listEl.innerHTML = '';
1054
+ if (jobs.length === 0) {
1055
+ listEl.style.display = 'none';
1056
+ emptyEl.style.display = 'flex';
1057
+ return;
1058
+ }
1059
+ listEl.style.display = 'grid';
1060
+ emptyEl.style.display = 'none';
1061
+ for (const job of jobs) {
1062
+ const card = document.createElement('div');
1063
+ card.className = 'history-card';
1064
+ card.setAttribute('role', 'button');
1065
+ card.setAttribute('tabindex', '0');
1066
+ const dateStr = new Date(job.created_at).toLocaleDateString(undefined, {
1067
+ year: 'numeric', month: 'short', day: 'numeric',
1068
+ });
1069
+ card.innerHTML = `
1070
+ <div class="history-card-name">${_esc(job.aoi_name || 'Untitled')}</div>
1071
+ <div class="history-card-meta">
1072
+ <span class="history-card-date">${_esc(dateStr)}</span>
1073
+ <span><span class="badge badge-${job.status}">${_esc(job.status)}</span></span>
1074
+ </div>
1075
+ <div class="history-card-indicators">${job.indicator_count} indicator${job.indicator_count !== 1 ? 's' : ''}</div>
1076
+ `;
1077
+ card.addEventListener('click', () => {
1078
+ state.jobId = job.id;
1079
+ navigate(job.status === 'complete' ? 'results' : 'status');
1080
+ });
1081
+ card.addEventListener('keydown', (e) => {
1082
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
1083
+ });
1084
+ listEl.appendChild(card);
1085
+ }
1086
+ }).catch(err => {
1087
+ showError('Failed to load analyses');
1088
+ });
1089
+ }
1090
+ ```
1091
+
1092
+ - [ ] **Step 7: Wire up global topbar links and update init**
1093
+
1094
+ Add after setupHistory:
1095
+
1096
+ ```javascript
1097
+ /* ── Global Topbar Wiring ───────────────────────────────── */
1098
+
1099
+ function wireTopbarLinks() {
1100
+ document.querySelectorAll('.nav-my-analyses').forEach(link => {
1101
+ link.addEventListener('click', (e) => { e.preventDefault(); navigate('history'); });
1102
+ });
1103
+ document.querySelectorAll('.nav-signout').forEach(link => {
1104
+ link.addEventListener('click', (e) => {
1105
+ e.preventDefault();
1106
+ clearSession();
1107
+ navigate('landing');
1108
+ });
1109
+ });
1110
+ // Results page "New analysis" button
1111
+ const resultsNewBtn = document.getElementById('results-new-btn');
1112
+ if (resultsNewBtn) {
1113
+ resultsNewBtn.addEventListener('click', () => navigate('define-area'));
1114
+ }
1115
+ }
1116
+ ```
1117
+
1118
+ Update the bottom of the file β€” find the `DOMContentLoaded` or the init block. The current init (at the bottom of app.js) likely starts with navigating to landing. Update it:
1119
+
1120
+ ```javascript
1121
+ /* ── Bootstrap ──────────────────────────────────────────── */
1122
+
1123
+ document.addEventListener('DOMContentLoaded', () => {
1124
+ loadSession();
1125
+ wireTopbarLinks();
1126
+ navigate('landing');
1127
+ });
1128
+ ```
1129
+
1130
+ - [ ] **Step 8: Update setupLanding to navigate to login instead of define-area**
1131
+
1132
+ In `setupLanding()`, change the CTA click handler from:
1133
+ ```javascript
1134
+ navigate('define-area')
1135
+ ```
1136
+ to:
1137
+ ```javascript
1138
+ navigate('login')
1139
+ ```
1140
+
1141
+ - [ ] **Step 9: Auto-fill email on confirm page**
1142
+
1143
+ In `setupConfirm()`, after the line that populates `confirm-indicators`, add:
1144
+
1145
+ ```javascript
1146
+ // Auto-fill email from session
1147
+ const emailInput = document.getElementById('confirm-email');
1148
+ if (state.session && state.session.email) {
1149
+ emailInput.value = state.session.email;
1150
+ emailInput.readOnly = true;
1151
+ }
1152
+ ```
1153
+
1154
+ - [ ] **Step 10: Commit**
1155
+
1156
+ ```bash
1157
+ cd /Users/kmini/Github/Aperture
1158
+ git add frontend/js/app.js
1159
+ git commit -m "feat: add login flow, history page, session management, and nav guard"
1160
+ ```
1161
+
1162
+ ---
1163
+
1164
+ ## Task 9: Integration Test β€” Full Flow
1165
+
1166
+ **Files:**
1167
+ - Modify: `tests/test_api_auth.py`
1168
+
1169
+ - [ ] **Step 1: Add end-to-end auth + job list test**
1170
+
1171
+ Add to `tests/test_api_auth.py`:
1172
+
1173
+ ```python
1174
+ @pytest.mark.asyncio
1175
+ async def test_full_auth_and_job_list_flow(client):
1176
+ email = "flow@example.com"
1177
+
1178
+ # 1. Request magic link
1179
+ resp = await client.post("/api/auth/request", json={"email": email})
1180
+ assert resp.status_code == 200
1181
+ demo_token = resp.json()["demo_token"]
1182
+
1183
+ # 2. Verify token
1184
+ resp = await client.post(
1185
+ "/api/auth/verify", json={"email": email, "token": demo_token}
1186
+ )
1187
+ assert resp.status_code == 200
1188
+ assert resp.json()["verified"] is True
1189
+
1190
+ # 3. Submit a job with auth
1191
+ headers = {"Authorization": f"Bearer {email}:{demo_token}"}
1192
+ job_payload = {
1193
+ "aoi": {"name": "Test Area", "bbox": [32.5, 15.5, 32.6, 15.6]},
1194
+ "time_range": {"start": "2025-03-01", "end": "2026-03-01"},
1195
+ "indicator_ids": ["fires"],
1196
+ "email": email,
1197
+ }
1198
+ resp = await client.post("/api/jobs", json=job_payload, headers=headers)
1199
+ assert resp.status_code == 201
1200
+ job_id = resp.json()["id"]
1201
+
1202
+ # 4. List jobs β€” should see the one we created
1203
+ resp = await client.get("/api/jobs", headers=headers)
1204
+ assert resp.status_code == 200
1205
+ jobs = resp.json()
1206
+ assert len(jobs) == 1
1207
+ assert jobs[0]["id"] == job_id
1208
+ assert jobs[0]["aoi_name"] == "Test Area"
1209
+ assert jobs[0]["indicator_count"] == 1
1210
+
1211
+ # 5. Other user sees nothing
1212
+ other_email = "other@example.com"
1213
+ other_token = _make_token(other_email)
1214
+ other_headers = {"Authorization": f"Bearer {other_email}:{other_token}"}
1215
+ resp = await client.get("/api/jobs", headers=other_headers)
1216
+ assert resp.status_code == 200
1217
+ assert resp.json() == []
1218
+ ```
1219
+
1220
+ - [ ] **Step 2: Run all tests**
1221
+
1222
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v`
1223
+ Expected: ALL PASS
1224
+
1225
+ - [ ] **Step 3: Commit**
1226
+
1227
+ ```bash
1228
+ cd /Users/kmini/Github/Aperture
1229
+ git add tests/test_api_auth.py
1230
+ git commit -m "test: add end-to-end auth and job list integration test"
1231
+ ```
1232
+
1233
+ ---
1234
+
1235
+ ## Task 10: Final Verification
1236
+
1237
+ - [ ] **Step 1: Run full test suite**
1238
+
1239
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v --tb=short`
1240
+ Expected: ALL PASS, zero failures
1241
+
1242
+ - [ ] **Step 2: Start the server and smoke-test manually**
1243
+
1244
+ Run: `cd /Users/kmini/Github/Aperture && python -m uvicorn app.main:app --port 7860 --host 0.0.0.0`
1245
+
1246
+ Verify in browser:
1247
+ 1. Landing page β†’ "Start a new analysis" β†’ Login page appears
1248
+ 2. Enter email β†’ demo token auto-fills β†’ Verify β†’ History page (empty state)
1249
+ 3. "New analysis" β†’ Define Area β†’ full flow works
1250
+ 4. After job submission, "My analyses" link β†’ History shows the job
1251
+ 5. "Sign out" β†’ returns to landing
1252
+ 6. Refresh β†’ session restored from sessionStorage
1253
+
1254
+ - [ ] **Step 3: Final commit with all files**
1255
+
1256
+ Ensure everything is committed. Run `git status` to check for any uncommitted changes.