Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,9 @@ __marimo__/

# Generated D1 seed chunk files (backend/scripts/import_alma_json_to_d1.py)
backend/data/seed_chunks/

# Stress-test harness (load-test/README.md).
# Generated account credentials and live session cookies must never be committed.
backend/data/seed_load_test_users.sql
load-test/sessions.json
load-test/results/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Every new frontend feature must work on both phone and desktop.
- Current active D1 for deployments is `studyplanner-db` (`80ca9092-ddc6-454a-b04a-8ccae85ef2f5`) through the Worker binding `DB`. The cutover from `studyplaner-db-test` was approved and executed with the `integrate_new_db` branch (multi-period ALMA catalog).
- `studyplaner-db-test` (`297f7a28-9069-431d-b989-49acf2537513`) is the previous test database; do not switch the active runtime DB again without explicit human approval.
- The D1 database name and UUID are public Cloudflare binding config and may be committed; never commit `AUTH_TOKEN_SECRET` or any generated secret value.
- Do not raise `compatibility_date` in `backend/wrangler.toml` without a cold `wrangler dev --remote` check first. Raising it to `2026-04-01` failed 60/60 requests with a Pyodide snapshot error and flipped entrypoint dispatch from `on_fetch` to `fetch`. Details in that file's comment and `docs/load-test-2026-08.md`.
- Run `npm run db:verify-config` before deploys or after touching Cloudflare/Pages config. The GitHub workflow with the same check should be required on `main` branch protection.
- To refresh the ALMA catalog, re-seed the existing D1 **in place** — do not create/swap a DB: `py backend/scripts/import_alma_json_to_d1.py --input <courses_multi_semester.json> --apply --skip-create --skip-swap --skip-migrate`. The seed DELETEs all catalog rows and reinserts only the periods present in the JSON, so keep every period you want to retain in the input. See that script's docstring for the D1 remote-import limits (compound-SELECT coalescing, Durable-Object reset) it works around.
- **User-generated data must never be keyed on `courses.id`.** The importer reassigns course ids from 1 on every in-place re-seed, so an id-keyed row silently re-points to a different course. Key on the ALMA course number instead — `COALESCE(courses.number, courses.unit_id)`, exposed as `course_catalog.normalize_review_key()`. `course_reviews` (migration 0034) and `course_external_links` (0021) both follow this; neither belongs in the importer's `SEEDED_TABLES_DELETE_ORDER`.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Deliver a prioritized list: file, issue, and why it matters.
`debug-onboarding-*@example.com` (e.g. `[email protected]`).
They are intentionally kept (not deleted) for future debugging. Ignore them in user
counts; remove with an explicit `DELETE ... WHERE username = '<exact>'` only if asked.
- Stress-test accounts follow the same rule under `[email protected]`, seeded by
`backend/scripts/seed_load_test_users.py`. They are retained between runs (sessions stay
valid 30 days) and must also be ignored in user counts. See `load-test/README.md`.

## Course catalog data (ALMA scraper → D1)

Expand Down
223 changes: 223 additions & 0 deletions backend/scripts/seed_load_test_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Seed the `loadtest-*` accounts used by the concurrent-user stress test.

py backend/scripts/seed_load_test_users.py --count 20 # dry run
py backend/scripts/seed_load_test_users.py --count 20 --apply # write to D1

The password is never stored in the repo: set LOADTEST_PASSWORD in the
environment before running. The same value must be passed to the k6 harness
(see load-test/README.md).

Why seed through SQL instead of POSTing /api/auth/register:
AUTH_REGISTRATION_POLICY in backend/src/services/request_rate_limit.py allows 5
registrations per hour per client IP, so creating 20 accounts through the API
from one machine would take four hours.

The rows this writes mirror what register_user() in
backend/src/services/authentication.py produces:

* user_auth — username/email/password_hash/password_salt. The hash format is
PBKDF2-HMAC-SHA256 at PASSWORD_PBKDF2_ITERATIONS, hex-encoded, exactly as
backend/src/password_hashing.py computes it. The Worker derives the same
digest through WebCrypto rather than hashlib, which is byte-for-byte
identical for these parameters — so hashes written here verify there. If the
iteration count ever changes, logins for these accounts break until this
script is re-run.
* user_state — display name plus the supported PO 2021 study program and its
default regulation version, resolved by subselect. login_user() would
backfill a bare user_state row on its own, but seeding the study program
matters: without it /api/me/progress short-circuits and the stress test
would exercise a much cheaper query path than a real account does.

user_progress is left to ensure_user_progress() on first login.

Re-running is safe: user_auth rows are upserted (so a password change is just a
re-run) and user_state rows use INSERT OR IGNORE so an account that has since
accumulated planner data is never reset.

These accounts are intentionally retained after a test run, like the
debug-onboarding-* accounts described in CLAUDE.md. Exclude them from user
counts.
"""

from __future__ import annotations

import argparse
import hashlib
import os
import secrets
import subprocess
import sys
import time
from pathlib import Path

ROOT_DIR = Path(__file__).resolve().parents[1]
DEFAULT_OUT_SQL = ROOT_DIR / "data" / "seed_load_test_users.sql"
DEFAULT_DB_NAME = "studyplanner-db"
DEFAULT_COUNT = 20
DEFAULT_ACCOUNT_TEMPLATE = "loadtest-{index:02d}@example.com"

# Must stay in sync with PASSWORD_PBKDF2_ITERATIONS in
# backend/src/password_hashing.py — a mismatch produces accounts that exist but
# can never log in.
PASSWORD_PBKDF2_ITERATIONS = 310_000

# Mirrors SUPPORTED_REGULATION_SOURCE_STATUS / SUPPORTED_REGULATION_PO_VERSION.
SUPPORTED_SOURCE_STATUS = "official"
SUPPORTED_PO_VERSION = "2021"


def hash_password(password: str, salt_hex: str) -> str:
"""Hash exactly as password_hashing.hash_password_hex does."""
password_hash = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
bytes.fromhex(salt_hex),
PASSWORD_PBKDF2_ITERATIONS,
)
return password_hash.hex()


def sql_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"


def build_account_usernames(count: int, template: str) -> list[str]:
if count < 1:
raise ValueError("count must be at least 1")
return [template.format(index=index) for index in range(1, count + 1)]


def build_seed_sql(usernames: list[str], password: str, now_unix: int) -> str:
"""Return idempotent SQL creating one credential + state row per account."""
statements: list[str] = [
"-- Generated by backend/scripts/seed_load_test_users.py — do not edit by hand.",
"-- Stress-test accounts; see load-test/README.md.",
"",
]

for username in usernames:
salt_hex = secrets.token_hex(16)
password_hash = hash_password(password, salt_hex)
quoted_username = sql_quote(username)
statements.append(
"INSERT INTO user_auth ("
"username, email, password_hash, password_salt, created_at_unix, updated_at_unix"
") VALUES ("
f"{quoted_username}, {quoted_username}, "
f"{sql_quote(password_hash)}, {sql_quote(salt_hex)}, {now_unix}, {now_unix}"
") ON CONFLICT(username) DO UPDATE SET "
"password_hash = excluded.password_hash, "
"password_salt = excluded.password_salt, "
"updated_at_unix = excluded.updated_at_unix;"
)

statements.append("")

for username in usernames:
quoted_username = sql_quote(username)
display_name = sql_quote(username.split("@", 1)[0])
statements.append(
"INSERT OR IGNORE INTO user_state ("
"username, display_name, created_at_unix, updated_at_unix"
f") VALUES ({quoted_username}, {display_name}, {now_unix}, {now_unix});"
)

statements.append("")

# The study program is attached in two follow-up UPDATEs rather than inline
# in each INSERT, so each subselect is written once instead of once per
# account. Both are guarded by `IS NULL`, so re-running never overwrites a
# program an account has since been given.
username_list = ", ".join(sql_quote(username) for username in usernames)
official = sql_quote(SUPPORTED_SOURCE_STATUS)
po_version = sql_quote(SUPPORTED_PO_VERSION)

# Resolved by subselect rather than a hardcoded id so this keeps working
# after a catalog re-seed renumbers study_programs.
statements.append(
"UPDATE user_state SET study_program_id = ("
"SELECT sp.id FROM study_programs AS sp"
" JOIN study_program_regulation_versions AS sprv"
" ON sprv.study_program_id = sp.id AND sprv.is_default = 1"
" JOIN regulation_versions AS rv ON rv.id = sprv.regulation_version_id"
f" WHERE sp.source_status = {official} AND sp.po_version = {po_version}"
f" AND rv.source_status = {official} AND rv.version_label = {po_version}"
" ORDER BY sp.id LIMIT 1"
f") WHERE username IN ({username_list}) AND study_program_id IS NULL;"
)
statements.append(
"UPDATE user_state SET regulation_version_id = ("
"SELECT sprv.regulation_version_id FROM study_program_regulation_versions AS sprv"
" JOIN regulation_versions AS rv ON rv.id = sprv.regulation_version_id"
" WHERE sprv.study_program_id = user_state.study_program_id"
" AND sprv.is_default = 1"
f" AND rv.source_status = {official} AND rv.version_label = {po_version}"
" LIMIT 1"
f") WHERE username IN ({username_list}) AND regulation_version_id IS NULL;"
)

statements.append("")
return "\n".join(statements)


def wrangler_d1_execute_file(db_name: str, sql_path: Path, *, remote: bool) -> None:
target = "--remote" if remote else "--local"
print(f"[wrangler] executing {sql_path} on '{db_name}' {target} ...")
result = subprocess.run(
["wrangler", "d1", "execute", db_name, target, "--file", str(sql_path)],
cwd=ROOT_DIR, text=True, shell=True,
stdin=subprocess.DEVNULL, encoding="utf-8", errors="replace",
)
if result.returncode != 0:
raise SystemExit(f"wrangler d1 execute failed (exit {result.returncode})")


def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
help=f"Number of accounts to seed (default {DEFAULT_COUNT}).")
parser.add_argument("--account-template", default=DEFAULT_ACCOUNT_TEMPLATE,
help="Username/email template; must contain {index}.")
parser.add_argument("--out-sql", type=Path, default=DEFAULT_OUT_SQL,
help="Where to write the generated SQL.")
parser.add_argument("--db-name", default=DEFAULT_DB_NAME,
help=f"D1 database name (default {DEFAULT_DB_NAME}).")
parser.add_argument("--local", action="store_true",
help="Target the local D1 simulator instead of --remote.")
parser.add_argument("--apply", action="store_true",
help="Actually execute the SQL. Without it the script only writes the file.")
return parser.parse_args()


def main() -> None:
args = parse_arguments()

password = os.environ.get("LOADTEST_PASSWORD", "").strip()
if not password:
raise SystemExit(
"LOADTEST_PASSWORD is not set. Choose a throwaway password and export it, "
"e.g. $env:LOADTEST_PASSWORD = '<value>' — it is never stored in the repo."
)

usernames = build_account_usernames(args.count, args.account_template)
print(f"[seed] hashing {len(usernames)} passwords at {PASSWORD_PBKDF2_ITERATIONS:,} PBKDF2 iterations ...")
seed_sql = build_seed_sql(usernames, password, int(time.time()))

args.out_sql.parent.mkdir(parents=True, exist_ok=True)
args.out_sql.write_text(seed_sql, encoding="utf-8")
print(f"[seed] wrote {args.out_sql} ({len(usernames)} accounts: {usernames[0]} .. {usernames[-1]})")

if not args.apply:
target = "--local" if args.local else "--remote"
print("[seed] dry run — nothing executed. To apply:")
print(f" wrangler d1 execute {args.db_name} {target} --file {args.out_sql}")
return

wrangler_d1_execute_file(args.db_name, args.out_sql, remote=not args.local)
print("[seed] done. Verify with:")
print(f" wrangler d1 execute {args.db_name} {'--local' if args.local else '--remote'} "
f"--command \"SELECT COUNT(*) FROM user_auth WHERE username LIKE 'loadtest-%'\"")


if __name__ == "__main__":
sys.exit(main())
3 changes: 3 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@
class Default(WorkerEntrypoint):
"""Cloudflare Worker entry point for the StudyPlanner API."""

# Must stay `on_fetch` at the compatibility date pinned in wrangler.toml.
# Later dates dispatch to `fetch` instead, which is one of the reasons the
# bump was reverted — see the comment there.
async def on_fetch(self, request: Any) -> Any:
return await route_request(request, self.env)
108 changes: 108 additions & 0 deletions backend/src/password_hashing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""PBKDF2 password hashing.

Currently this always runs `hashlib`. The WebCrypto path below is dormant, and
the reason is worth recording because it is not discoverable from the docs:

NotSupportedError: Pbkdf2 failed: iteration counts above 100000 are not
supported (requested 310000).

Cloudflare Workers refuses `crypto.subtle.deriveBits` above 100,000 PBKDF2
iterations. PASSWORD_PBKDF2_ITERATIONS is 310,000, so every call would throw.

The motivation was cost. Measured against production, a login spends 421-538 ms
of CPU here, and `/api/auth/login` is the only endpoint that has failed with
"Worker exceeded CPU time limit". For scale: the same 310,000 iterations take
~313 ms as native OpenSSL and ~4.3 s as a pure-Python loop, so Pyodide's
`hashlib` is compiled C running in WASM at roughly 1.5x native — not
interpreted. Native WebCrypto would therefore have saved something in the
region of a third, not an order of magnitude.

Using it means dropping to <=100,000 iterations, which is a security decision
(it weakens hashing against offline attack) and needs a per-user iteration
count plus rehash-on-login to migrate the stored hashes. That call has not been
made, so nothing here changes behaviour today.

The two implementations agree byte for byte where both can run — same
algorithm, salt, iteration count and 32-byte output — which is what makes a
future switch a change of executor rather than a hash migration.
"""

from __future__ import annotations

from typing import Any

import hashlib

PASSWORD_PBKDF2_ITERATIONS = 310_000
DERIVED_KEY_BITS = 256

# Hard limit enforced by workerd, not a tunable.
WEBCRYPTO_MAX_PBKDF2_ITERATIONS = 100_000

# Resolved on first use. The JS bridge either exists for the lifetime of an
# isolate or it does not, so probing once avoids paying an import failure on
# every request under the fallback.
_webcrypto_usable: bool | None = None


def hash_password_with_hashlib(password: str, salt_bytes: bytes, iterations: int) -> bytes:
return hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt_bytes, iterations)


def _to_js_bytes(data: bytes) -> Any:
from js import Uint8Array

buffer = Uint8Array.new(len(data))
buffer.assign(data)
return buffer


async def _hash_password_with_webcrypto(password: str, salt_bytes: bytes, iterations: int) -> bytes:
from js import Object, Uint8Array, crypto
from pyodide.ffi import to_js

key = await crypto.subtle.importKey(
'raw',
_to_js_bytes(password.encode('utf-8')),
'PBKDF2',
False,
to_js(['deriveBits']),
)
derived_bits = await crypto.subtle.deriveBits(
to_js(
{
'name': 'PBKDF2',
'salt': _to_js_bytes(salt_bytes),
'iterations': iterations,
'hash': 'SHA-256',
},
dict_converter=Object.fromEntries,
),
key,
DERIVED_KEY_BITS,
)
return Uint8Array.new(derived_bits).to_bytes()


async def hash_password_hex(
password: str,
salt_hex: str,
iterations: int = PASSWORD_PBKDF2_ITERATIONS,
) -> str:
"""Return the hex PBKDF2-HMAC-SHA256 digest of a password for a given salt."""
global _webcrypto_usable

salt_bytes = bytes.fromhex(salt_hex)
# Checked rather than attempted: above the cap workerd raises on every call,
# and burning an exception per login to rediscover a fixed limit is waste.
if iterations <= WEBCRYPTO_MAX_PBKDF2_ITERATIONS and _webcrypto_usable is not False:
try:
digest = await _hash_password_with_webcrypto(password, salt_bytes, iterations)
_webcrypto_usable = True
return digest.hex()
except Exception as exc: # noqa: BLE001 - any failure must degrade, not 500
_webcrypto_usable = False
# Surfaces in `wrangler tail`. Logins keep working via hashlib.
print(f'[auth] WebCrypto PBKDF2 unavailable, falling back to hashlib: {exc}')

return hash_password_with_hashlib(password, salt_bytes, iterations).hex()
Loading
Loading