Compare commits

...

77 Commits

Author SHA1 Message Date
Elijah Voigt dc7cfec897 feat(quotesdb): add admin verify endpoint, remove reset-auth-code UI
- Add GET /api/admin/verify — side-effect-free code check used by the
  admin unlock flow; registered before reset-auth-code in the router
- Remove "Reset auth code" section from admin panel (UI + dead API code);
  rotation is now CLI-only via `wrangler secret put ADMIN_AUTH_CODE`
- Add rotate-admin-code justfile recipe using pwgen for local key rotation
- Add pwgen to Nix dev shell
- Update OpenAPI spec with /api/admin/verify definition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 9418bd4b0b feat(quotesdb): support ADMIN_AUTH_CODE Cloudflare secret for admin auth
Merge qdb-api-d4a624 — ticket d4a624
3 months ago
Elijah Voigt dcbc659ec1 feat(quotesdb): support ADMIN_AUTH_CODE Cloudflare secret for admin auth
Add AppState to handlers, read ADMIN_AUTH_CODE from Worker env, gate
reset-auth-code with 409 when secret is active, update tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt bac6696c4b fix(quotesdb): make site footer stick to bottom of viewport
Yew's Renderer::new() renders into <body>, not #app, so the flex
column layout on #app had no effect. Move display:flex and
flex-direction:column to body so .main-content{flex:1} correctly
pushes .site-footer to the bottom of the viewport.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt deb3ec40f6 refactor(quotesdb): move db/handlers to lib modules, upgrade worker to 0.7, update infra
- Move src/bin/api/db/ and src/bin/api/handlers/ to src/db/ and
  src/handlers/ so they compile as library modules accessible to both
  the native binary and the Cloudflare Workers entry point
- Upgrade worker crate 0.5 → 0.7; add workers-api feature flag and
  cdylib/rlib crate-type to Cargo.toml
- Update flake.nix: add worker-build and just to the dev shell; bump
  flake.lock (nixpkgs + rust-overlay)
- Consolidate rate limit rules to one (Free plan allows only 1 rule
  per zone in the http_ratelimit phase)
- Update infra/worker.tf to deploy via wrangler rather than Terraform
  (Cloudflare provider v4 can't upload ES module + wasm bundles)
- Extend .gitignore to exclude *.wasm build artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt b00f24ae85 fix(quotesdb): fix D1 exec() newline truncation in migrations, add justfile and migrate command
D1's exec() treats newlines as statement separators, causing multiline
CREATE TABLE statements to be truncated after the first line and return
"incomplete input: SQLITE_ERROR" on every request.

Fix: switch run_migrations() in D1Repository to use prepare(sql).run()
instead of exec(sql), which treats the full string as a single statement.

Also moves db and handlers modules from src/bin/api/ to src/ (library
modules), adds justfile with build/deploy/migrate recipes, adds
migrations/schema.sql for direct wrangler d1 execute usage, and adds
wrangler.toml for worker deployment configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 66cbe67100 fix(quotesdb): fix report submit button always disabled, add auth code hint
- Remove captcha-gated disabled state on report submit button — Turnstile
  does not fire a DOM input event so captcha_solved never became true; rate
  limiting is handled at the WAF layer so the gate was incorrect
- Token is still read from the hidden Turnstile input at submit time if present
- Add explanatory hint below "Enter Auth Code" heading in AuthModal explaining
  the code was provided or generated when the quote was created
- Add .auth-modal__hint CSS class for the hint text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 1728141517 fix(quotesdb): add PartialEq to report types, remove unused import
- Derive PartialEq on ReportSummary, ReportListResponse, ReportRow, and
  QuoteReports so they can be held in Yew state enums that require PartialEq
- Remove unused ApiError import from moderation_tab.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 5dadc23246 chore(quotesdb): close ticket 3f22f2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 25adf3897f feat(quotesdb): add admin moderation tab with report detail modal
Adds a Moderation tab to the admin page (visible after unlock) showing a
paginated list of reported quotes. Clicking a row opens a detail modal with
the full quote, all individual reports, and action buttons to delete the
quote, hide it, or dismiss the reports.

- api.rs: add ReportSummary, ReportListResponse, ReportRow, QuoteReports types
  and five admin_* async functions for the reports endpoints
- components/moderation_tab.rs: new ModerationTab function_component with
  paginated list, row-click loading state, and an inline detail modal
- components/mod.rs: expose moderation_tab module
- pages/admin.rs: introduce AdminTab enum and tab bar; wrap existing settings
  content in the Settings tab; add Moderation tab rendering ModerationTab
- style.css: add styles for admin tabs, moderation list table, and detail modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 00a9a36510 chore(quotesdb): close ticket f4930e
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 77c131c08a feat(quotesdb): add hidden toggle on quote detail page
- Add HideAuth action variant to the Action enum
- Show yellow badge when quote.hidden is true
- Add Hide/Make Public toggle button in the actions bar
- Reuse AuthModal for the hide/unhide auth prompt
- Call POST /api/quotes/:id with { hidden: !current } and X-Auth-Code
- Update quote state on success; re-prompt on 403
- Add .page-quote__hidden-badge CSS styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 01cddd6e95 chore(quotesdb): close ticket 354276
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt b6f03fd967 feat(quotesdb): add report button with modal on quote page
Adds a Report button to /quotes/:id that opens a modal containing:
- Optional reason textarea (max 256 chars) with live character counter
- Cloudflare Turnstile CAPTCHA widget (always-passes test sitekey as const)
- Submit button disabled until CAPTCHA token is non-empty
- Cancel button

On submit POSTs { reason?, captcha_token } to POST /api/quotes/:id/report.
Shows a success banner on 201 or an error via ErrorDisplay on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 872ac9592c chore(quotesdb): close ticket a6e8ba
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt f90dc2dc5e feat(quotesdb): collapsible filter panel on browse page
Add a toggle button ("Filters ▼ / ▲") above the quote list on the
browse page. Filter controls are hidden by default and expand when
clicked. Each filter (Author, Tag, Date range) is on its own labelled
row with consistent styling. Existing API query logic is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt e169d8b2cc chore(quotesdb): close ticket 6c5904
Mark admin moderation endpoints ticket as done following successful
implementation and test pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 684c58fdfe feat(quotesdb): add admin moderation endpoints
Implements ticket 6c5904 — five admin-authenticated endpoints for
moderation workflows:

  GET  /api/admin/reports              paginated list of reported quotes
  GET  /api/admin/reports/:id          full quote + all report rows
  DELETE /api/admin/reports/:id/quote  unconditionally delete a quote
  POST /api/admin/reports/:id/hide     set hidden=1 on a quote
  DELETE /api/admin/reports/:id/reports clear all reports for a quote

All endpoints require X-Admin-Code header; 403 on missing/wrong code.

DB layer additions:
- QuoteRepository trait gains list_reports, get_reports_for_quote,
  admin_delete_quote, hide_quote, and clear_reports methods
- New ReportRow, ReportSummary, ReportListResult, and QuoteReports
  types added to db/mod.rs
- Implementations in native.rs (rusqlite) and d1.rs (Cloudflare D1)

Tests added:
- 14 unit handler tests using MockRepo (3 per endpoint covering
  success, 404, and 403 cases)
- 5 integration tests using real SQLite via NativeRepository
- 10 DB-layer unit tests in native.rs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt df3a288c9f chore(quotesdb): add ticket for collapsible filter panel on browse page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt db71399b2f feat(quotesdb): add Cloudflare WAF rate limiting rules via OpenTofu
Adds infra/rate-limits.tf with a cloudflare_ruleset (phase: http_ratelimit)
implementing per-IP rate limits on all mutating API endpoints:
- PUT /api/quotes: 5 requests per 10 minutes (quote creation)
- POST /api/quotes/:id/report: 3 requests per hour (abuse reports)
- POST /api/quotes/🆔 10 requests per minute (quote updates)
- DELETE /api/quotes/🆔 10 requests per minute (quote deletes)

The report rule is ordered before the general update rule to ensure the
more-specific /report path matches before the broader /api/quotes/:id
pattern. Documents the approach, plan requirements, and layered protection
rationale in docs/ARCHITECTURE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt ab76d35bd5 feat(quotesdb): add footer with contact email to all pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 4d4edae841 fix(quotesdb): remove spurious X-Auth-Code header, drop unused current param, fix error messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 9827dcc5b9 feat(quotesdb): admin page auth-first flow, remove admin from nav
- Remove the Admin link from the top navigation bar; /admin remains
  reachable by direct URL but is no longer discoverable from normal
  browsing.
- Rework /admin to an auth-first flow: on load the page shows only a
  password input and an Unlock button. On success, the admin controls
  (submission lock/unlock, auth code reset) are revealed; on failure a
  clear error message is shown and the page stays locked. Refreshing
  always resets to locked state (code is in component state only).
- Add api::verify_admin_code() — calls POST /api/admin/reset-auth-code
  with new_code equal to the entered code, making the call idempotent
  on success (code unchanged) while still returning 403 on mismatch.
- Fix pre-existing wasm build breakage in quote.rs: UpdateQuoteInput
  gained a hidden field in an earlier ticket but quote.rs was never
  updated. Added hidden: None to the struct literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 995fff4046 fix(quotesdb): use char count for reason validation, remove duplicate CountRow in d1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt eecdbba9d7 feat(quotesdb): add reports table and POST /api/quotes/:id/report endpoint
- Add CREATE_REPORTS migration constant (was unused — now wired in)
- Wire CREATE_REPORTS into run_migrations for both NativeRepository and D1Repository
- Add create_report to QuoteRepository trait with NotFound semantics
- Implement create_report in NativeRepository (two-step: existence check then insert)
- Implement create_report in D1Repository (two-step: COUNT check then insert)
- Add report_handler: POST /api/quotes/{id}/report, 201/400/404/500
- Register route before /{id} in router so static /report suffix wins
- Add create_report to MockRepo in handler tests
- Add handler tests: test_report_success, test_report_quote_not_found, test_report_reason_too_long
- Add DB tests: test_create_report_success, test_create_report_not_found
- Add ReportInput schema and /api/quotes/{id}/report path to openapi.yaml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 14cc879743 test(quotesdb): add hidden flag filter tests to native repository
Add three tests verifying hidden-quote filtering behaviour in
NativeRepository:

- list_quotes_excludes_hidden: hidden quotes do not appear in paginated
  listing results.
- get_random_quote_excludes_hidden: get_random_quote returns None when
  the only quote is hidden.
- get_quote_returns_hidden_quote: get_quote (direct ID lookup) still
  returns the quote when it is hidden.

Also refactor the inline row-mapping closure in list_quotes to use the
existing row_to_quote helper, eliminating duplicated column mapping
logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 86c5e4990d feat(quotesdb): add hidden field to openapi spec
Add `hidden` (boolean, required) to the Quote response schema so all
GET responses reflect the field. Add `hidden` (boolean, optional) to
QuoteUpdateRequest so callers can toggle visibility via POST /api/quotes/:id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt c59efdc373 feat(quotesdb): add hidden flag to quotes
- Add `hidden: bool` to the `Quote` struct and `hidden: Option<bool>` to
  `UpdateQuoteInput` in `src/lib.rs`
- Add `ALTER_QUOTES_ADD_HIDDEN` migration constant in `db/migrations.rs`
- Apply the ALTER TABLE migration in `NativeRepository::run_migrations` and
  `D1Repository::run_migrations` with try/ignore for idempotency
- Exclude hidden quotes from `list_quotes` (WHERE hidden = 0) and
  `get_random_quote` in both native and D1 implementations
- Update all SELECT queries to include the `hidden` column
- Handle `hidden` field in `update_quote` SET clause for both implementations
- Update `MockRepo` and `sample_quote` in handler tests to include `hidden`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 549accded0 chore(quotesdb): add tickets for footer, hidden quotes, reporting, moderation, and rate limiting
New tickets:
- b2af7f: ui — footer with contact email
- 8a7fba: api — hidden flag for quotes (schema + endpoints)
- 77237f: api — reports table + POST /api/quotes/:id/report
- 6c5904: api — admin moderation endpoints
- f4930e: ui — hidden toggle on quote pages
- 354276: ui — report button with modal and Turnstile captcha
- 3f22f2: ui — admin moderation tab
- cb8de0: ui — admin auth-first flow, remove from default nav
- 06d304: infra — Cloudflare rate limiting

Reopened sub-project and root tickets; wired dependencies.
3 months ago
Elijah Voigt 00d195c86f chore(quotesdb): commit tickets, TODO, and infra README update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt c4a59ec9ad feat(quotesdb): show locked banner on /submit when submissions are closed
Check GET /api/status on mount; if submissions_locked is true, hide the
form and show a .submissions-closed-banner instead. Fail-open: on error,
treat as unlocked and display the form normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 511c9fbf54 fix(quotesdb): fix admin_reset_auth_code call, handle status fetch error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 5d2780a72a fix(quotesdb): fix admin_reset_auth_code call, handle status fetch error
Pass "" for the unused `current` param in admin_reset_auth_code (the
server only checks X-Admin-Code). Handle get_status() failure in the
on-mount effect with fail-open behaviour: set submissions_locked=Some(false)
and display "Could not fetch status." so the UI never hangs on
"Loading status...".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 49f70cc5e8 feat(quotesdb): /admin page component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt a4d59b4371 fix(quotesdb): atomic update_admin_auth_code, fix handler docstring
Replace the two-step read-check-write in update_admin_auth_code with a
single atomic UPDATE … WHERE key = 'admin_auth_code' AND value = ?current
in both NativeRepository and D1Repository. Rows-affected count is checked:
zero means the code was absent or mismatched → DbError::Forbidden; one
means success.

Also remove the now-unnecessary replacement2 clone binding in native.rs.

Fix the reset_auth_code handler doc comment to accurately describe that a
missing X-Admin-Code header is caught by the handler itself (before any DB
call), while a wrong-but-present code reaches the DB layer which returns
DbError::Forbidden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt ab398b690c feat(quotesdb): POST /api/admin/reset-auth-code endpoint
Adds handler, route registration, request/response types, and five unit
tests for the admin auth-code rotation endpoint. Updates openapi.yaml
with the new path and a ResetAuthCodeResponse component schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 80b998c192 feat(quotesdb): enforce submission lock on PUT /api/quotes
Add a pre-flight check at the top of create_handler that calls
get_submissions_locked() before processing the request. Returns
423 Locked with {"error": "submissions are closed"} when locked.

Update openapi.yaml to document the 423 response on PUT /api/quotes.

Add three unit tests: locked → 423, unlocked → 201, unlock-then-create → 201.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 3684e196dd fix(quotesdb): fix verify_admin_code docstring, add 500 to OpenAPI, make handlers private
- Clarify verify_admin_code docstring to say "standard string equality"
  instead of leaving comparison method implicit
- Add missing "500" response entries to /api/admin/lock and
  /api/admin/unlock in openapi.yaml
- Remove pub from lock_submissions and unlock_submissions to match all
  other handlers in the file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 401a4f45a5 feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints
Add two admin-protected endpoints that toggle the global submissions lock:
- POST /api/admin/lock  — sets submissions_locked = true
- POST /api/admin/unlock — sets submissions_locked = false

Both require the X-Admin-Code header and return { "submissions_locked": bool }
on success, or 403 on missing/wrong code. Operation is idempotent.

Shared helper verify_admin_code() fetches and compares the stored admin code.
Routes registered in the router() function. Five unit tests added covering
correct code, wrong code, missing header, and idempotent lock behaviour.

OpenAPI spec updated with AdminCode security scheme, LockResponse schema,
/api/admin/lock and /api/admin/unlock path entries, and an admin tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt f6f652ef3e docs(quotesdb): add /api/status to OpenAPI spec
Add GET /api/status path and StatusResponse schema. The endpoint
returns { "submissions_locked": bool } with 200 or 500, requires
no auth, and is tagged under the existing `meta` group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 6b90f34ccf feat(quotesdb): GET /api/status public endpoint
Adds the GET /api/status handler that returns {"submissions_locked": bool}.
Registers the route in the router before the quotes routes.
Adds three unit tests covering unlocked state, locked state, and default false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt b0cb813740 feat(quotesdb): admin API client functions in UI
Add ApiError::Forbidden variant, StatusResponse / ResetAuthCodeResponse /
LockResponse / ResetAuthCodeBody types, and four new async functions to
src/bin/ui/api.rs:
- get_status()           → GET /api/status
- admin_reset_auth_code() → POST /api/admin/reset-auth-code (X-Admin-Code + X-Auth-Code)
- admin_lock()           → POST /api/admin/lock (X-Admin-Code)
- admin_unlock()         → POST /api/admin/unlock (X-Admin-Code)

HTTP 403 responses map to ApiError::Forbidden in all three admin functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 585f4b2f02 test(quotesdb): add handler-level tests for DB admin methods
Make MockRepo stateful for admin_auth_code and submissions_locked so
the new QuoteRepository methods can be exercised without a real DB.

Add four tests to src/bin/api/handlers/mod.rs:
- get_submissions_locked returns false by default
- set_submissions_locked(true) then get_submissions_locked returns true
- update_admin_auth_code with correct current succeeds and returns new code
- update_admin_auth_code with wrong current returns Forbidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt c9142edbbf feat(quotesdb): DB layer — add submissions_locked + update_admin_auth_code
Add three new QuoteRepository trait methods and a seed helper:
- update_admin_auth_code(current, new_code): replaces the admin code if
  `current` matches; generates a fresh passphrase when new_code is None;
  returns DbError::Forbidden on mismatch.
- get_submissions_locked(): reads the submissions_locked key from
  admin_config; returns false when the key is absent.
- set_submissions_locked(locked): upserts "1"/"0" into admin_config.
- seed_submissions_locked(): INSERT OR IGNORE "0" — safe to call on every
  startup without clobbering an active lock.

Implemented in both NativeRepository (rusqlite) and D1Repository (wasm32).
Updated startup seeding in main.rs (native and wasm32 paths) to call
seed_submissions_locked after the existing admin auth code seeding.

Added 7 unit tests in db/native.rs covering all four specified scenarios:
default false, set-then-get, seed does not overwrite, correct code succeeds,
None new_code generates passphrase, wrong code returns Forbidden, stored
code unchanged after Forbidden.

MockRepo in handlers/mod.rs updated with stub implementations of all four
new trait methods to satisfy the trait bound.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 15d9de3947 docs(quotesdb): admin features design doc 3 months ago
Elijah Voigt 5dcbb334fa feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit
- infra/turnstile.tf: provision Turnstile widget (managed mode, quotes.elijah.run domain) with site_key and secret_key outputs
- infra/variables.tf: add var.domain (default: quotes.elijah.run)
- src/lib.rs: add cf_turnstile_token: Option<String> (#[serde(default)]) to CreateQuoteInput; update doctest
- Cargo.toml: add reqwest (0.12, rustls-tls) under native-only dependencies
- src/bin/api/handlers/mod.rs: add verify_turnstile() and CAPTCHA gate in create_handler, both gated on #[cfg(not(target_arch = "wasm32"))]
- src/bin/api/db/native.rs: add cf_turnstile_token: None to all CreateQuoteInput struct literals in tests
- api/openapi.yaml: document cf_turnstile_token field in QuoteCreateRequest schema
- index.html: add Turnstile JS script tag
- src/bin/ui/pages/submit.rs: add turnstile_token state, use_effect_with callback registration, widget div, token included in CreateQuoteInput
- docs/LOCAL_DEV.md: add Cloudflare Turnstile CAPTCHA section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 014dc39ea4 feat(quotesdb): date range filter for quotes list
Add 6 optional query parameters to GET /api/quotes:
  date_after_year/month/day and date_before_year/month/day

Changes:
- QuoteRepository::list_quotes gains date_after and date_before params
- NativeRepository and D1Repository build ISO date prefix WHERE clauses;
  quotes with NULL date are excluded when any bound is set
- list_handler validates component ordering (month requires year, etc.)
  and returns 400 on invalid combinations
- build_date_bound helper converts y/m/d components to ISO prefix strings
- UI api::list_quotes and browse page gain From/To year filter inputs
- author page call updated to pass None for the new date params
- openapi.yaml extended with 6 new query parameter entries
- 6 new integration tests covering after, before, range, and 400 cases
- 1 new native DB unit test covering all filter combinations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 267a95aa13 feat(quotesdb): admin super auth code for quote moderation
Add an admin_config table storing a single admin auth code that
bypasses per-quote auth checks for update and delete operations.
The code is auto-generated on first startup and printed to stderr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt bdf99b32c4 feat(quotesdb): add workers-rs WASM entry point to api binary
- Gate native Tokio/Axum main() with #[cfg(not(target_arch = "wasm32"))]
- Add #![cfg_attr(target_arch = "wasm32", no_main)] to suppress missing-main error
- Add #[worker::event(fetch)] entry point using worker::HttpRequest / http::Response<axum::body::Body>
- Enable `http` feature on worker dep so fetch handler uses standard http types
- Add axum (json+query features), tower-service, and http to wasm32 deps
- Move async-trait to shared [dependencies] so both targets have it
- Make db::d1 module pub so main.rs can access D1Repository on wasm32
- Fix worker::d1::Database → D1Database and PreparedStatement → D1PreparedStatement
- Add #[cfg_attr(target_arch = "wasm32", worker::send)] to all 7 handler fns
  so their futures satisfy Axum's Handler<Send> bound on single-threaded wasm32
3 months ago
Elijah Voigt d9f14bfc53 fix(quotesdb): resolve compiler warnings in api and ui
- Remove redundant #![cfg(target_arch="wasm32")] from d1.rs (module
  declaration in mod.rs already gates it)
- Remove unused D1Repository re-export from db/mod.rs
- Drop unused page/total_count fields from UI ListResponse struct
  (only total_pages is consumed by the browse page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 7e2f01e822 feat(quotesdb): implement D1Repository for Cloudflare Workers
Replace all 7 stub methods in src/bin/api/db/d1.rs with full working
implementations using the Cloudflare D1 API from workers-rs 0.5.

Implements:
- run_migrations: executes four DDL statements via db.exec()
- list_quotes: dynamic WHERE clause with positional params, COUNT query,
  paginated SELECT, per-quote tag fetch
- get_quote: prepared statement with first::<QuoteRow>()
- get_random_quote: ORDER BY RANDOM() LIMIT 1
- create_quote: INSERT + batch tag insert + read-back for timestamps
- update_quote: auth check, dynamic SET clause, optional tag replacement,
  read-back of updated row
- delete_quote: auth check, DELETE, returns DeleteResult enum

Also adds helper structs (QuoteRow, AuthRow, TagRow, CountRow),
fetch_tags() helper method, and unsafe Send/Sync impls required for
Arc<dyn QuoteRepository + Send + Sync> on single-threaded wasm32.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt b48f56712e fix(quotesdb): home page friendly empty state when no quotes
Detect 404 from random quote endpoint and render a friendly
'Nothing here yet — Submit a quote!' message with a link to
/submit instead of the generic error display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt ad06c8befb fix(quotesdb): submit form UX fixes (6a4c61, dfd185, 5b3475)
- Replace 'Submit another' link with 'Browse all quotes' -> Route::Browse
- Change date input from type=text to type=date, remove placeholder
- Make author optional (defaults to Anonymous), update label
- Clarify auth code label to 'Edit/delete passphrase'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt e3fe2253f9 fix frontend proxy issue 3 months ago
Elijah Voigt d8560ef3d5 chore(quotesdb): close all tickets — implementation complete
All 69 implementation tickets done. Root ticket ec118c closed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 4f1aa5560a test(quotesdb): add integration test suite for all API endpoints
Adds 26 integration tests in handlers::integration_tests using a real
NativeRepository backed by a NamedTempFile SQLite database. Each test
gets an isolated database via test_router(). tempfile added to
[dev-dependencies]. Closes tickets: 5f5ba0, 9b581f, 789d0f, aa0eab,
f9f448, 4a4c26, 93f1b6, fae330, 8c87db, 893eba, e8f5cf, ce1e4f.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 7529b43845 fix(quotesdb): gate UI binary on wasm32, update UI ticket statuses
- main.rs: add #[cfg(target_arch = "wasm32")] guards on all wasm32-only
  code; add stub fn main() for native targets so cargo check passes
- Ticket statuses updated for all completed UI page/component tickets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt c436ba07c7 feat(quotesdb): merge full page implementations, tag filter, and stylesheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt d317648d2f feat(quotesdb): add comprehensive BEM stylesheet for all UI components
Covers all component and page classes:
- Navigation bar (nav, nav__brand, nav__links, nav__link)
- QuoteCard (quote-card, quote-card__text, footer, author, source, tags, tag, link)
- ErrorDisplay (error-display, error-display__message)
- Pagination (pagination, pagination__btn, pagination__info)
- AuthModal (auth-modal__overlay, auth-modal, auth-modal__title/input/actions)
- All page containers (page-home, page-browse, page-quote, page-author, page-submit)
- Edit form, submit form, success state with auth code box
- Base styles: reset, typography, buttons, form inputs
- Responsive breakpoint at 640px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 183994b3dc feat(quotesdb): implement all UI page components (Home, Browse, QuoteDetail, Author, Submit)
- HomePage: fetches random quote on mount, displays with QuoteCard and browse/submit links
- BrowsePage: paginated list with author and tag filter inputs, Pagination component
- QuotePage: view/edit/delete with AuthModal gating, 403/404 handling, sessionStorage auth
- AuthorPage: lists quotes by author with tag filter and pagination
- SubmitPage: full form with all fields, success state showing auth code prominently
- Tag filter (d3d502) integrated into Browse and Author pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt bc48924d16 feat(quotesdb): implement API DB layer and all HTTP handlers
DB layer (src/bin/api/db/):
- native.rs: NativeRepository (tokio-rusqlite) implementing all CRUD ops,
  dynamic WHERE for filters, two-phase auth check for update, 13 unit tests
- d1.rs: D1Repository wasm32 stub (all methods return Internal error)
- connection.rs: open() helper — WAL + foreign_keys pragmas
- mod.rs: cfg-gate async_trait (Send on native, ?Send on wasm32)

Handlers (src/bin/api/handlers/mod.rs):
- All 7 routes: GET /api/, random, {id}, list, PUT create, POST update, DELETE
- Router order: random BEFORE {id} (prevents "random" matching as id)
- Auth: X-Auth-Code header validation → 403 on mismatch
- 13 handler unit tests with MockRepo

main.rs: opens DB, runs migrations, wraps in Arc<dyn Repo + Send + Sync>,
  binds on $PORT (default 3000)

Cargo.toml: tower dev-dep for ServiceExt::oneshot in tests

All 32 tests pass (26 api + 6 lib)

Tickets closed: 00aff0 a5049d 6e829e 28e7d9 886bfd 2ce22e 5dbb7d 05f8ae
                d792e2 5d9f5a b20b5a 175382 03bb91

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 52e771e9c4 feat(quotesdb): merge UI shell, API client, storage and base components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 6f7614c0c8 feat(quotesdb): implement UI app shell, API client, storage, and base components
- Add BrowserRouter with all 5 routes (Home, Browse, QuoteDetail, Author, Submit)
- Implement typed API client (list, get, random, create, update, delete)
- Implement sessionStorage auth code helpers (set/get/clear)
- Add ErrorDisplay, QuoteCard, AuthModal, Pagination components
- Add stub page components for initial compilation
- Fix Cargo.toml: uuid js feature for wasm32, getrandom 0.3 wasm_js for rand dep,
  js-sys and Storage web-sys feature for API client and storage module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt a7b2d6fd4e feat(quotesdb): add QuoteRepository trait, migrations, and thiserror dependency
- db/mod.rs: QuoteRepository async trait + ListResult/DeleteResult/DbError types
- db/migrations.rs: SQL DDL strings for quotes, quote_tags, and indexes
- lib.rs: fix rand 0.9 trait import (SliceRandom → IndexedRandom)
- Cargo.toml: add thiserror = "2" for DbError derive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 83f4aacdf5 feat(quotesdb): set up Trunk.toml and index.html for UI build
- Trunk.toml: target index.html, proxy /api/* to localhost:3000
- index.html: link CSS, Rust wasm binary (ui), copy _redirects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 8da0d754a0 chore(quotesdb): merge 9ef703 - add _redirects SPA fallback 3 months ago
Elijah Voigt af8c476305 chore(quotesdb): add _redirects SPA fallback for Cloudflare Pages routing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 9f28d99a93 feat(quotesdb): add build.rs to convert api/openapi.yaml to JSON at compile time
- Create build.rs at crate root using serde_yaml (build-dep only) to parse
  api/openapi.yaml and write compact JSON to $OUT_DIR/openapi.json
- cargo:rerun-if-changed ensures re-conversion only when spec changes
- serde_yaml never enters the Workers or native binary (build-dep only)
- Downstream GET /api/ handler consumes via include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))

Closes ticket 8892d5
3 months ago
Elijah Voigt b695cb5b6d feat(quotesdb): add Yew/Wasm UI dependencies to Cargo.toml
- Add yew 0.22 with csr feature for Rust/Wasm SPA frontend
- Add yew-router 0.19 for client-side routing
- Add gloo 0.11 for Web API utilities (timers, fetch, events)
- Add wasm-bindgen 0.2 matching Nix wasm-bindgen-cli 0.2.108
- Add wasm-bindgen-futures 0.4 for async fetch support
- Add web-sys 0.3 for raw DOM/browser API bindings
All UI deps scoped to wasm32 cfg target to avoid host build pollution

Closes ticket 93515e
3 months ago
Elijah Voigt 7bd2bf4ed5 chore(quotesdb): set up Cargo.toml with api and ui dependencies
- Add axum 0.8 + tokio 1 (native-only) for the HTTP API server
- Add rusqlite 0.32 + tokio-rusqlite 0.6 (native-only) for local SQLite
- Add async-trait 0.1 (native-only) for QuoteRepository abstraction
- Add worker 0.5 with d1 feature (wasm32-only) for Cloudflare Workers
- Add rand 0.9 + serde + serde_json (shared) for passphrase/serialisation
- Add build-dependencies: serde_json 1 + serde_yaml 0.9 for build.rs
- Pin rusqlite to 0.32 to match tokio-rusqlite 0.6 transitive dependency

Closes ticket 1f5bb5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt b0cf17ec83 feat(quotesdb): add generate_id() using UUID v4 — WASM-compatible ID generation
- Add uuid = { version = "1", features = ["v4", "serde"] } to [dependencies]
- Add getrandom = { version = "0.4", features = ["wasm_js"] } under wasm32 cfg
- Implement generate_id() in src/lib.rs with rustdoc and doctest
- Add unit tests for length, hyphen count, and uniqueness

Closes ticket 7a0d9f

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt dc73fc8f69 plan to implement the current batch of tickets 3 months ago
Elijah Voigt 5926d33bcc chore(quotesdb): resolve all triage tickets and create implementation tickets
- All 21 TRIAGE decision tickets resolved with chosen approaches documented
- This session: e2bd9b (SPA routing → _redirects), 2ec8b1 (OpenAPI → build.rs),
  0d84fa (HTTP client → reqwest), 0bc655 (auth code → session storage)
- New implementation tickets created: 9ef703, 8892d5, 5379eb
- Downstream tickets updated with resolved approaches and correct dependencies
- ARCHITECTURE.md updated with pinned WASM dependency versions (yew 0.22,
  yew-router 0.19, wasm-bindgen 0.2)
- XML tags added to all tickets for improved LLM guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt ec2a4055ca docs(quotesdb): add XML tags to all tickets for improved LLM guidance
All 80 nbd tickets updated with structured XML tags:
- Task tickets: <context>, <goal>, <constraints>, <skills>, <validation>, <commit>
- TRIAGE tickets: <context>, <question>, <options>, <resolution>, <commit>
- Project tickets: <context>, <goal>, <skills>, <validation>
- ec118c (root): <skills>, <goal>, <context>, <design>, <phases>, <verification>
- b38032 (done): <goal>, <current-state>, <target-state>, <changes>, <constraints>, <validation>, <summary>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt 65b0fdf1e2 some tickets marked in progress 3 months ago
Elijah Voigt 91ecd91176 refactor(quotesdb): collapse to single crate with api and ui binaries
Replaces the three separate sub-crates (api/, ui/, tests/) with a single
Cargo crate at the quotesdb/ root. Shared code lives in src/lib.rs; the
api and ui are multi-binary targets; integration tests use the standard
Cargo tests/ layout. Trunk files moved to project root with data-bin="ui".

Closes ticket b38032.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
Elijah Voigt de99f1c047 chore(quotesdb): bootstrap project skeleton and design doc
- Scaffold api/, ui/, tests/, infra/, docs/ directories
- Stub Cargo.toml for api, ui, and tests crates
- Write finalized design doc to docs/plans/2026-02-27-quotesdb-design.md
- Add placeholder PLANNING.md, ARCHITECTURE.md, README.md per domain
- Add stub main.rs and tests.rs for api and ui
- Add index.html and Trunk.toml for ui
- Add placeholder infra/main.tf with Cloudflare provider stub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago

@ -0,0 +1,63 @@
# Builds the quotesdb API Worker Wasm binary and applies OpenTofu infrastructure.
# Triggered on push to the quotesdb branch when API or infra files change.
# Required secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ZONE_ID
name: Deploy quotesdb API
on:
push:
branches:
- quotesdb
paths:
- "quotesdb/src/bin/api/**"
- "quotesdb/src/lib.rs"
- "quotesdb/infra/**"
- "quotesdb/Cargo.toml"
- "quotesdb/Cargo.lock"
jobs:
deploy-api:
runs-on: ubuntu-latest
defaults:
run:
working-directory: quotesdb
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
quotesdb/target
key: ${{ runner.os }}-cargo-api-${{ hashFiles('quotesdb/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-api-
- name: Build API Wasm binary
run: cargo build --release --bin api --target wasm32-unknown-unknown
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
- name: Initialise and apply infrastructure
working-directory: quotesdb/infra
env:
TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TF_VAR_cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_VAR_cloudflare_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
tofu init
tofu apply -auto-approve
- name: Apply D1 schema migrations
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: wrangler d1 execute quotesdb --file infra/schema.sql --remote

@ -0,0 +1,57 @@
# Builds the quotesdb Yew/Wasm UI with Trunk and deploys to Cloudflare Pages.
# Triggered on push to the quotesdb branch when UI files change.
# Required secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
name: Deploy quotesdb UI
on:
push:
branches:
- quotesdb
paths:
- "quotesdb/src/bin/ui/**"
- "quotesdb/index.html"
- "quotesdb/Trunk.toml"
- "quotesdb/_redirects"
- "quotesdb/src/lib.rs"
- "quotesdb/Cargo.toml"
- "quotesdb/Cargo.lock"
jobs:
deploy-ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: quotesdb
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
quotesdb/target
key: ${{ runner.os }}-cargo-ui-${{ hashFiles('quotesdb/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-ui-
- name: Install Trunk
run: cargo install trunk
- name: Build UI with Trunk
run: trunk build --release
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name quotesdb-ui --branch quotesdb

@ -51,11 +51,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1772173633, "lastModified": 1772956932,
"narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=", "narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6", "rev": "608d0cadfed240589a7eea422407a547ad626a14",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -101,11 +101,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1772420823, "lastModified": 1773025773,
"narHash": "sha256-q3oVwz1Rx41D1D+F6vg41kpOkk3Zi3KwnkHEZp7DCGs=", "narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "458eea8d905c609e9d889423e6b8a1c7bc2f792c", "rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf",
"type": "github" "type": "github"
}, },
"original": { "original": {

@ -43,6 +43,7 @@
# Cloudflare # Cloudflare
pkgs.wrangler pkgs.wrangler
pkgs.worker-build
# General # General
pkgs.pkg-config pkgs.pkg-config
@ -62,6 +63,10 @@
# Build book # Build book
pkgs.mdbook pkgs.mdbook
pkgs.just
pkgs.pwgen
]; ];
shellHook = '' shellHook = ''

@ -0,0 +1,9 @@
# quotesdb local development environment variables
# Copy to .env and customise. The .env file is gitignored — never commit it.
#
# All variables below are optional for local development.
# In production, the Workers runtime uses the D1 binding — DATABASE_URL is unused.
# Path to the local SQLite database file used by `cargo run` (native API server).
# The file is created automatically on first run; migrations run on startup.
DATABASE_URL=./quotesdb.sqlite

@ -0,0 +1,7 @@
dist/
# Binary database file
quotesdb.sqlite*
# compiled files
*.wasm

@ -0,0 +1,343 @@
+++
title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls"
priority = 8
status = "done"
ticket_type = "task"
dependencies = []
+++
<context>
Resolution of TRIAGE ticket e8a330: **SQLx is NOT compatible with Cloudflare Workers/D1.**
D1 is accessed through the workers-rs JavaScript binding layer, not a TCP connection.
SQLx relies on TCP connections (Postgres, MySQL, SQLite file) and cannot work in the Workers runtime.
**Chosen approach: `cfg(target_arch)`-based split**
- `#[cfg(target_arch = "wasm32")]` → workers-rs D1 bindings (`worker::d1::D1Database`)
- `#[cfg(not(target_arch = "wasm32"))]``rusqlite` + `tokio-rusqlite` (native dev/test)
`cargo test` on the native host automatically selects the rusqlite path. No feature flags,
no wrangler dev required for integration tests. The design doc's "Query layer: SQLx" is
superseded by this approach.
This decision also resolves TRIAGE tickets a91260 and 2ab7a8 (workers-rs native test binaries):
the `cfg(target_arch)` split handles the test environment automatically.
</context>
<goal>
Implement the database abstraction layer for `quotesdb-api`:
1. **`src/bin/api/db/mod.rs`** — `QuoteRepository` async trait + shared result types
2. **`src/bin/api/db/d1.rs`** — `D1Repository` using workers-rs D1 bindings (`wasm32` only)
3. **`src/bin/api/db/native.rs`** — `NativeRepository` using `rusqlite`/`tokio-rusqlite` (native only)
4. **`src/bin/api/db/migrations.rs`** — SQL migration strings (`CREATE TABLE IF NOT EXISTS`)
5. **`Cargo.toml`** — wire cfg-split dependencies for workers-rs and rusqlite
</goal>
<implementation-plan>
## 1. Cargo.toml dependency additions
```toml
# Dependencies always present (both targets)
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# API only — WASM/Workers target
[target.'cfg(target_arch = "wasm32")'.dependencies]
worker = { version = "0.7", features = ["d1", "axum"] }
# API only — native target (local dev and cargo test)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
rusqlite = { version = "0.31", features = ["bundled"] }
tokio-rusqlite = "0.5"
```
`rusqlite` with `features = ["bundled"]` compiles SQLite in — no system SQLite dependency.
## 2. Module file layout
```
src/bin/api/
├── main.rs # cfg-split: workers-rs event handler OR native tokio::main
├── router.rs # build_router<R: QuoteRepository>(...) — shared for both targets
├── handlers/ # Axum route handlers — generic over repo type
└── db/
├── mod.rs # QuoteRepository trait + shared types (DbError, ListResult, etc.)
├── d1.rs # D1Repository — cfg(target_arch = "wasm32")
├── native.rs # NativeRepository — cfg(not(target_arch = "wasm32"))
└── migrations.rs # SQL migration strings
```
## 3. QuoteRepository trait (`src/bin/api/db/mod.rs`)
```rust
#[cfg(target_arch = "wasm32")]
mod d1;
#[cfg(not(target_arch = "wasm32"))]
mod native;
pub mod migrations;
#[cfg(target_arch = "wasm32")]
pub use d1::D1Repository;
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeRepository;
pub struct ListResult {
pub quotes: Vec<crate::Quote>,
pub page: u32,
pub total_pages: u32,
pub total_count: u32,
}
pub enum DeleteResult { Deleted, NotFound, Forbidden }
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("database error: {0}")]
Internal(String),
#[error("not found")]
NotFound,
#[error("forbidden")]
Forbidden,
}
/// Async repository interface for all quote operations.
///
/// `?Send` is required because `D1Database` wraps JS values and is not `Send`.
/// Both implementations satisfy this bound.
#[async_trait::async_trait(?Send)]
pub trait QuoteRepository {
/// Run CREATE TABLE IF NOT EXISTS migrations. Call once on startup.
async fn run_migrations(&self) -> Result<(), DbError>;
async fn list_quotes(
&self, page: u32, author: Option<&str>, tag: Option<&str>,
) -> Result<ListResult, DbError>;
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
/// Creates a quote. Generates an auth_code if not provided.
/// Returns (quote, auth_code).
async fn create_quote(&self, input: crate::CreateQuoteInput) -> Result<(crate::Quote, String), DbError>;
async fn update_quote(
&self, id: &str, auth_code: &str, input: crate::UpdateQuoteInput,
) -> Result<Option<crate::Quote>, DbError>;
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
}
```
**Note on `?Send`**: Axum's `State<Arc<dyn QuoteRepository>>` requires `Send + Sync` on native
(tokio multi-threaded). Since the trait is `?Send`, use a concrete type alias instead of
a trait object:
```rust
// In router.rs — concrete type alias, no dyn needed
#[cfg(target_arch = "wasm32")]
pub type AppRepo = db::D1Repository;
#[cfg(not(target_arch = "wasm32"))]
pub type AppRepo = db::NativeRepository;
pub fn build_router(repo: Arc<AppRepo>) -> Router { ... }
```
This avoids the trait-object/Send complexity entirely. Handlers receive `State<Arc<AppRepo>>`.
## 4. D1Repository (`src/bin/api/db/d1.rs`)
```rust
// Only compiled for wasm32 — imports from worker crate are safe here
#![cfg(target_arch = "wasm32")]
use worker::d1::D1Database;
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
pub struct D1Repository { db: D1Database }
impl D1Repository {
pub fn new(db: D1Database) -> Self { Self { db } }
}
#[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository {
async fn run_migrations(&self) -> Result<(), DbError> {
self.db.prepare(super::migrations::CREATE_QUOTES)
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
self.db.prepare(super::migrations::CREATE_QUOTE_TAGS)
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(())
}
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
let row = self.db
.prepare("SELECT q.id, q.text, q.author, q.source, q.date, q.created_at, q.updated_at \
FROM quotes q WHERE q.id = ?1")
.bind(&[id.into()])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<serde_json::Value>(None).await
.map_err(|e| DbError::Internal(e.to_string()))?;
// Deserialize and attach tags via separate query
// ...
Ok(row.map(|v| serde_json::from_value(v).unwrap()))
}
// ... remaining methods
}
```
Tags require a separate query per quote (or a GROUP_CONCAT aggregation):
```sql
SELECT tag FROM quote_tags WHERE quote_id = ?1
```
## 5. NativeRepository (`src/bin/api/db/native.rs`)
```rust
// Only compiled for non-wasm32
#![cfg(not(target_arch = "wasm32"))]
use tokio_rusqlite::Connection;
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
pub struct NativeRepository { conn: Connection }
impl NativeRepository {
pub async fn new(db_path: &str) -> Result<Self, DbError> {
let conn = Connection::open(db_path).await
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(Self { conn })
}
}
#[async_trait::async_trait(?Send)]
impl QuoteRepository for NativeRepository {
async fn run_migrations(&self) -> Result<(), DbError> {
self.conn.call(|conn| {
conn.execute_batch(&format!(
"PRAGMA foreign_keys = ON; {}; {};",
super::migrations::CREATE_QUOTES,
super::migrations::CREATE_QUOTE_TAGS,
))?;
Ok(())
}).await.map_err(|e| DbError::Internal(e.to_string()))
}
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
let id = id.to_string();
self.conn.call(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes WHERE id = ?1")?;
// map_row to Quote struct, then fetch tags separately
// ...
Ok(None) // placeholder
}).await.map_err(|e| DbError::Internal(e.to_string()))
}
// ... remaining methods
}
```
## 6. Migrations (`src/bin/api/db/migrations.rs`)
```rust
/// Creates the quotes table if it does not already exist.
pub const CREATE_QUOTES: &str = "
CREATE TABLE IF NOT EXISTS quotes (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
source TEXT,
date TEXT,
auth_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)";
/// Creates the quote_tags join table with cascade delete.
pub const CREATE_QUOTE_TAGS: &str = "
CREATE TABLE IF NOT EXISTS quote_tags (
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (quote_id, tag)
)";
```
## 7. main.rs cfg-split entry points
```rust
// src/bin/api/main.rs
// ── WASM / Workers entry point ──────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
use worker::*;
#[cfg(target_arch = "wasm32")]
#[event(fetch)]
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
let db = env.d1("DB")?;
let repo = std::sync::Arc::new(db::D1Repository::new(db));
router::build_router(repo).call(req, env, ctx).await
}
// ── Native entry point (local dev + cargo test server) ──────────────────────
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() {
let db_path = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "./quotesdb.sqlite".to_string());
let repo = std::sync::Arc::new(
db::NativeRepository::new(&db_path).await.expect("failed to open DB")
);
repo.run_migrations().await.expect("failed to run migrations");
let app = router::build_router(repo);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
```
</implementation-plan>
<constraints>
- `async_trait::async_trait(?Send)` required — `D1Database` wraps JS values and is NOT `Send`.
- Use concrete type alias (`AppRepo`) in handlers/router instead of `dyn QuoteRepository` to avoid
the Send + Sync trait object constraint on native Axum.
- `rusqlite` must use `features = ["bundled"]` — no system SQLite dependency.
- Tags are stored in a separate table; always fetch them with a second query per quote.
- The `auth_code` column must be included in DB SELECT for update/delete auth checks but
NEVER returned in public GET responses.
- Foreign keys must be explicitly enabled in rusqlite: `PRAGMA foreign_keys = ON`.
- `tokio-rusqlite` v0.5 uses `spawn_blocking` internally — safe to use from async handlers.
</constraints>
<related-tickets>
- Resolves TRIAGE: e8a330 (SQLx + workers-rs + D1 compatibility)
- Also resolves TRIAGE: a91260 (workers-rs native test binaries) and 2ab7a8 (test harness approach)
- Supersedes: a5049d (DB connection module — SQLx approach invalidated)
- Informs: 1f5bb5 (Cargo.toml — cfg-split deps), 6e829e (api main.rs — cfg-split entry point), 9b581f (test harness)
</related-tickets>
<skills>
Use `superpowers:test-driven-development` — write `NativeRepository` tests before implementing query methods.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check # verifies native build (rusqlite path)
cargo clippy
cargo test # tests use NativeRepository automatically
# Also verify WASM target compiles (workers-rs D1 path):
cargo check --target wasm32-unknown-unknown
```
</validation>
<commit>
`feat(quotesdb): implement QuoteRepository trait and cfg-split D1/rusqlite DB abstraction`
</commit>

@ -0,0 +1,95 @@
+++
title = "Add Trunk proxy config to Trunk.toml: forward /api/* to local API server"
priority = 7
status = "done"
ticket_type = "task"
dependencies = ["a9534d"]
+++
<context>
The `quotesdb` project uses Trunk to build and serve the Yew (Wasm) frontend. During `trunk serve`, the UI
runs on `localhost:8080` while the API runs separately on `localhost:3000`. Without a proxy, the browser
would make cross-origin requests from `:8080` to `:3000`, requiring CORS headers.
Triage a9534d resolved this: use Trunk's built-in `[[proxy]]` to forward `/api/*` requests to the API server.
No CORS configuration is required anywhere — the proxy makes all API calls appear same-origin to the browser.
</context>
<decision>
**Chosen approach (triage a9534d):** Trunk proxy.
Rationale:
- Mirrors the production architecture: in production, Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site.
- Frontend uses relative URLs (`/api/quotes`, not `http://localhost:3000/api/quotes`) — the same URLs work in both dev and production without any configuration.
- Zero CORS configuration needed in the API or the frontend. Tower-http CORS middleware is not required.
- Standard, well-supported pattern for SPA development with a separate API backend.
</decision>
<goal>
Add a `[[proxy]]` section to `Trunk.toml` in the `quotesdb/` root:
```toml
[build]
target = "index.html"
[[proxy]]
rewrite = "/api"
backend = "http://localhost:3000"
```
This configuration means:
- Requests from the browser to `http://localhost:8080/api/quotes` are forwarded to `http://localhost:3000/api/quotes`.
- The `/api` prefix is preserved in the forwarded URL (Trunk appends the matched path to `backend`).
- `trunk serve` handles the proxying automatically — no manual setup required by developers.
- The API server port `3000` matches the plain Axum `cargo run` dev server (see ticket 6e829e).
</goal>
<local-dev-workflow>
Local development workflow after this change:
```sh
# Terminal 1 — start the API server
cd quotesdb
cargo run
# Terminal 2 — start the UI dev server with proxy
cd quotesdb
trunk serve
# Browser opens at http://localhost:8080
# API calls go to /api/* (proxied transparently to localhost:3000)
```
No environment variables, no hardcoded URLs, no CORS headers needed.
</local-dev-workflow>
<production-routing>
In production (Cloudflare Pages + Workers), the same `/api/*` path prefix is used. Cloudflare
can route `example.com/api/*` to the Worker and `example.com/*` to Pages via a Custom Domain
or a Worker route rule. This is configured in infra/. The frontend code does not change.
</production-routing>
<constraints>
- API port must be `3000` — this must be consistent with however ticket 6e829e configures the local Axum server.
- If the API port changes, update `Trunk.toml` accordingly and document the change.
- Do not use `trunk.serve.proxy` (legacy format) — use `[[proxy]]` table array format.
- This ticket's change is 3 lines in `Trunk.toml`. Keep it minimal.
</constraints>
<validation>
From the `quotesdb/` directory (requires `cargo run` running in another terminal):
```sh
trunk serve &
curl http://localhost:8080/api/quotes # should proxy to http://localhost:3000/api/quotes
```
At minimum, verify `trunk build` succeeds:
```sh
trunk build
```
</validation>
<commit>
`chore(quotesdb): add Trunk proxy config to forward /api/* to local API server`
</commit>

@ -1,7 +1,162 @@
+++ +++
title = "Implement 4-word passphrase auth_code generator (must work in WASM/workers-rs)" title = "Implement 4-word passphrase auth_code generator (must work in WASM/workers-rs)"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "1f5bb5", "6ed325"] dependencies = ["1f5bb5", "6ed325"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
Auth codes are 4-word passphrases (e.g. `ocean-table-purple-storm`) assigned to quotes on creation. They are stored plaintext and used to authorise updates and deletes.
**TRIAGE 6ed325 resolved:** Use a custom embedded word list (EFF Short Word List 1) with `rand::rngs::OsRng` from `rand 0.10`. OsRng does not use thread-local storage and is safe on wasm32. Entropy on WASM comes from `getrandom 0.4` with the `wasm_js` feature, which calls `crypto.getRandomValues()` — available in both browsers and Cloudflare Workers.
</context>
<goal>
Implement a `generate_auth_code() -> String` function in `src/lib.rs` that produces a random 4-word passphrase. Place it in shared lib code so both the API (generation) and UI (display) can reference it.
</goal>
<implementation>
## 1. Cargo.toml changes (covered by ticket 1f5bb5, listed here for reference)
```toml
[dependencies]
rand = "0.10"
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues())
# Required by both rand (OsRng) and uuid (v4) on wasm32 targets
getrandom = { version = "0.4", features = ["wasm_js"] }
```
## 2. Embed the EFF Short Word List 1 in src/lib.rs
The EFF Short Word List 1 contains 1296 common English words designed for memorable passphrases.
Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt
Generate the Rust const array (run from shell, paste output into src/lib.rs):
```sh
curl -s 'https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt' \
| awk '{print $2}' \
| awk 'NR % 8 == 1 {printf " "} {printf "\"%s\", ", $0} NR % 8 == 0 {print ""}' \
| sed '$a\' | tr -d '\n' | sed 's/, $//'
```
Place the word list as a module-level constant:
```rust
/// EFF Short Word List 1 — 1296 common English words designed for memorable passphrases.
/// Source: <https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt>
const WORDS: &[&str] = &[
"acid", "acorn", "acre", "acts", "afar", "affix", "aged", "agent",
"agile", "aging", "agony", "ahead", "aide", "aids", "aim", "ajar",
// ... (full 1296-word list, generated via shell command above)
];
```
## 3. Implement generate_auth_code() in src/lib.rs
```rust
use rand::rngs::OsRng;
use rand::seq::SliceRandom;
/// Generates a random 4-word passphrase in the format `word-word-word-word`.
///
/// Words are drawn from the EFF Short Word List 1 (1296 common English words).
/// The passphrase is used as an `auth_code` to authorize quote edits and deletes.
///
/// Uses `rand::rngs::OsRng` for entropy, which is safe on both native and
/// `wasm32-unknown-unknown` targets. On WASM (Cloudflare Workers), entropy
/// is sourced via `crypto.getRandomValues()` through `getrandom`'s `wasm_js` feature.
///
/// # Examples
///
/// ```
/// let code = quotesdb::generate_auth_code();
/// let words: Vec<&str> = code.split('-').collect();
/// assert_eq!(words.len(), 4);
/// assert!(words.iter().all(|w| !w.is_empty()));
/// ```
pub fn generate_auth_code() -> String {
WORDS
.choose_multiple(&mut OsRng, 4)
.copied()
.collect::<Vec<_>>()
.join("-")
}
```
## 4. Unit tests (src/lib.rs tests module or src/tests.rs)
```rust
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Verify format: exactly 4 non-empty words from WORDS, joined by hyphens.
#[test]
fn auth_code_has_four_valid_words() {
for _ in 0..100 {
let code = generate_auth_code();
let parts: Vec<&str> = code.split('-').collect();
assert_eq!(parts.len(), 4, "expected 4 words, got: {code}");
for word in &parts {
assert!(!word.is_empty(), "empty word in code: {code}");
assert!(
WORDS.contains(word),
"word '{word}' not in word list, code: {code}"
);
}
}
}
/// Verify randomness: 20 samples should produce at least 10 distinct codes.
#[test]
fn auth_codes_are_varied() {
let codes: HashSet<String> = (0..20).map(|_| generate_auth_code()).collect();
assert!(
codes.len() > 10,
"expected >10 unique codes in 20 samples, got {}",
codes.len()
);
}
}
```
</implementation>
<constraints>
- `generate_auth_code()` must live in `src/lib.rs` (shared code, not bin-specific)
- Use `rand::rngs::OsRng` — do NOT use `rand::thread_rng()` (thread-local, unsafe on WASM)
- Do not use `std::fs`, thread-based RNG, or any crate that requires file-system access
- All public items must have rustdoc comments with doc-examples (per project style)
- `getrandom = { version = "0.4", features = ["wasm_js"] }` must be in the wasm32 cfg section only,
not in `[dependencies]`, to avoid pulling wasm-bindgen into native builds
</constraints>
<skills>
Use `superpowers:test-driven-development` — write the unit tests (step 4) before implementing (step 3).
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement WASM-compatible 4-word passphrase auth_code generator`
</commit>

@ -0,0 +1,162 @@
+++
title = "Filter quotes by date range (before/after with year/month/day granularity)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Feature
Extend `GET /api/quotes` with date range filtering. Users can specify a "before" and/or "after" bound, with year/month/day granularity for each. Quotes without a date (`date IS NULL`) are excluded when a date filter is active.
---
## Query parameter design
Six optional query params are added:
| Param | Type | Description |
|---|---|---|
| `date_after_year` | u16 | Only include quotes dated on or after this year |
| `date_after_month` | u8 (112) | Narrows after-bound to this month |
| `date_after_day` | u8 (131) | Narrows after-bound to this day |
| `date_before_year` | u16 | Only include quotes dated on or before this year |
| `date_before_month` | u8 (112) | Narrows before-bound to this month |
| `date_before_day` | u8 (131) | Narrows before-bound to this day |
The handler constructs two partial ISO strings from these fields before calling `list_quotes`:
- **after bound** (`>=`): `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` — string comparison anchors to the start of the specified period (e.g., `2020` means ≥ `2020-01-01` in text comparison, but actually `>= '2020'` which works correctly since any 2020 date starts with `2020`).
- **before bound** (`<=`): `YYYY-MM-DD` where missing month defaults to `12` and missing day defaults to `31` — so `date_before_year=2020` means `<= '2020-12-31'`.
**Validation rules:**
- Month must be 112 if provided (without its year, it is an error).
- Day must be 131 if provided (without its year+month, it is an error).
- Return `400 Bad Request` for invalid combinations.
---
## Part 1 — API: `handlers/mod.rs`
**Extend `ListParams`:**
```rust
#[derive(Debug, Deserialize)]
struct ListParams {
#[serde(default = "default_page")]
page: u32,
author: Option<String>,
tag: Option<String>,
// Date range filter
date_after_year: Option<u16>,
date_after_month: Option<u8>,
date_after_day: Option<u8>,
date_before_year: Option<u16>,
date_before_month: Option<u8>,
date_before_day: Option<u8>,
}
```
**In `list_handler`**, construct the `date_after` and `date_before` strings before calling `repo.list_quotes`:
```rust
fn build_date_bound(year: Option<u16>, month: Option<u8>, day: Option<u8>) -> Option<String> {
match (year, month, day) {
(None, _, _) => None,
(Some(y), None, _) => Some(format!("{y:04}")),
(Some(y), Some(m), None) => Some(format!("{y:04}-{m:02}")),
(Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")),
}
}
```
Return `400` if month is present without year, or day is present without year+month.
---
## Part 2 — Repository trait: `db/mod.rs`
Extend `list_quotes` signature:
```rust
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>, // new
date_before: Option<&str>, // new
) -> Result<ListResult, DbError>;
```
---
## Part 3 — Native implementation: `db/native.rs`
In `NativeRepository::list_quotes`, build the WHERE clause dynamically. Current query filters on `author` and `tag` via subquery. Extend it:
```sql
-- Additional clauses appended when filters are present:
AND q.date IS NOT NULL
AND q.date >= ? -- when date_after is Some
AND q.date <= ? -- when date_before is Some
```
Use string comparison — ISO YYYY-MM-DD format sorts lexicographically, so `>=`/`<=` on the `date` TEXT column is correct. Prefix matching (e.g. `date >= '2020'` with `date = '2020-06-15'`) works because `'2020-06-15' >= '2020'` is true in SQLite string comparison.
---
## Part 4 — D1 implementation: `db/d1.rs`
Apply the same WHERE clause extensions to the D1 query builder.
---
## Part 5 — UI: Browse page
File: `src/bin/ui/pages/browse.rs`
Add date filter controls to the filter panel alongside author and tag filters:
- "After:" year input (`<input type="number" min="0" max="9999">`), optional month select (112), optional day input.
- "Before:" same.
On filter apply, include the populated fields as query params. When fields are empty, omit them.
---
## Part 6 — Mock repo in tests: `handlers/mod.rs`
Update `MockRepo::list_quotes` to accept the two new `date_after` / `date_before` params (ignore them in the mock — just extend the signature).
---
## Part 7 — OpenAPI spec: `api/openapi.yaml`
Add the six new query parameters to the `GET /api/quotes` operation with descriptions and `schema: {type: integer}`.
---
## Files touched
- `src/bin/api/handlers/mod.rs``ListParams` + `list_handler` + `MockRepo`
- `src/bin/api/db/mod.rs``QuoteRepository::list_quotes` signature
- `src/bin/api/db/native.rs` — SQL query extension
- `src/bin/api/db/d1.rs` — SQL query extension
- `src/bin/ui/pages/browse.rs` — date filter UI
- `api/openapi.yaml` — new query params
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
redocly lint api/openapi.yaml
```
Test manually:
- `GET /api/quotes?date_after_year=1900&date_before_year=2000` — should return only quotes with dates in the 20th century.
- `GET /api/quotes?date_after_year=2020&date_after_month=6` — on or after June 2020.
- `GET /api/quotes?date_after_month=3` — should return 400 (month without year).
## Commit scope
`feat(quotesdb): date range filter for quotes list`

@ -1,7 +1,44 @@
+++ +++
title = "Implement ui/src/main.rs — Yew app shell, BrowserRouter, route definitions for all 5 pages" title = "Implement ui/src/main.rs — Yew app shell, BrowserRouter, route definitions for all 5 pages"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "93515e", "dc3d2b"] dependencies = ["93515e", "dc3d2b", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The five frontend routes are:
- `/` — Home (random quote)
- `/browse` — Paginated quote list
- `/quotes/:id` — Single quote view/edit/delete
- `/author/:name` — All quotes by an author
- `/submit` — New quote form
</context>
<goal>
Implement `src/bin/ui/main.rs` — the Yew app shell and router:
1. Set up `BrowserRouter` (from yew-router)
2. Define a `Route` enum for all five routes
3. Render each route to its respective page component (stubs are fine initially)
4. Mount the app to the `#app` div in `index.html`
</goal>
<constraints>
- Resolve TRIAGE ticket 166996 (Yew/yew-router version) before starting.
- The `Route` enum must be exhaustive — all five routes listed above.
- Page components can be stubs (`html! { <p>"Home"</p> }`) in this ticket; full implementation is in separate tickets.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement Yew app shell and BrowserRouter with all 5 routes`
</commit>

@ -1,7 +1,55 @@
+++ +++
title = "Implement PUT /api/quotes — create quote, generate NanoID, generate auth_code if not provided, return 201 with auth_code" title = "Implement PUT /api/quotes — create quote, generate UUID v4 ID, generate auth_code if not provided, return 201 with auth_code"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d", "d792e2", "03bb91", "175382", "6f2e18"] dependencies = ["a5049d", "d792e2", "03bb91", "175382", "7a0d9f"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
`PUT /api/quotes` creates a new quote. The request body is JSON; `auth_code` is optional — if omitted, one is generated. The response is 201 with the full quote object and the `auth_code` (always returned so the user can save it).
Request body: `{ text, author, source?, tags?, date?, auth_code? }`
Response 201: `{ quote: {...}, auth_code: "word-word-word-word" }`
</context>
<goal>
Implement the `PUT /api/quotes` handler:
1. Deserialise and validate the request body (text and author are required)
2. Generate a UUID v4 ID for the quote by calling `generate_id()` from `src/lib.rs`
3. Generate an auth_code if not provided in the request
4. INSERT the quote into the `quotes` table
5. INSERT any tags into `quote_tags`
6. Return 201 with the created quote and auth_code
</goal>
<constraints>
- Return 422 if `text` or `author` is missing or empty.
- Use `generate_id()` from `src/lib.rs` for the quote ID — returns a UUID v4 string (36 chars). TRIAGE 6f2e18 resolved this: nanoid is not WASM-safe; uuid v4 is used instead. See ticket 7a0d9f.
- Use the shared `generate_auth_code()` function from `src/lib.rs`.
- Tag insertion must use the shared `replace_tags_for_quote()` logic (ticket 175382).
</constraints>
<skills>
Use `superpowers:test-driven-development` — write tests for: auto-generated auth_code, custom auth_code, missing required fields 422.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement PUT /api/quotes — create quote with UUID v4 and auth_code`
</commit>

@ -0,0 +1,52 @@
+++
title = "quotesdb/infra: Cloudflare rate limiting (WAF rules or Workers rate limiting per IP)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Summary
Investigate and implement rate limiting for the quotesdb API and frontend using Cloudflare's native tooling.
## Options to Evaluate
### 1. Cloudflare WAF Rate Limiting Rules (Recommended starting point)
- Available on free tier with limits; full control on Pro+
- Rules can match on IP, path, method
- Configure via OpenTofu (cloudflare_ruleset resource, phase: http_ratelimit)
- Example: max 10 POST/PUT/DELETE requests per IP per minute to /api/*
### 2. Cloudflare Workers Rate Limiting API
- workers-rs has bindings for the Rate Limiting API (available on paid plans)
- More fine-grained: can key on IP + user-defined keys (e.g., quote ID)
- Useful for per-resource rate limits (e.g., max N reports per IP per quote)
### 3. KV-based rate limiting in the Worker
- Manual implementation using Cloudflare KV as a counter store
- Works on free tier but adds latency and KV cost
- Last resort if WAF rules are insufficient
## Suggested Limits (to start)
- POST /api/quotes (create): 5 per IP per 10 minutes
- POST /api/quotes/:id/report: 3 per IP per hour
- POST /api/quotes/:id (update): 10 per IP per minute
- DELETE /api/quotes/:id: 10 per IP per minute
- GET endpoints: more generous or no limit (Cloudflare CDN caches anyway)
## Tasks
- [ ] Research which Cloudflare plan features are available for this project
- [ ] Implement WAF rate limiting rules in OpenTofu (infra/main.tf or new infra/rate-limits.tf)
- [ ] If Workers Rate Limiting API is needed, add workers-rs bindings and implement in api/main.rs
- [ ] Document the approach and any plan requirements in docs/ARCHITECTURE.md
- [ ] Verify rules are applied with a test script (curl loop)
## Notes
- CAPTCHA on the report endpoint (ticket 354276) provides an additional layer of bot protection
- Rate limiting should complement, not replace, CAPTCHA
## Validation
```sh
# from infra/
tofu validate
tofu plan
```

@ -1,7 +1,68 @@
+++ +++
title = "[TRIAGE] D1 binding chicken-and-egg — D1 ID not known until after apply, but Worker needs it at plan time" title = "[TRIAGE] D1 binding chicken-and-egg — D1 ID not known until after apply, but Worker needs it at plan time"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu apply`, but the Worker resource needs the D1 ID at plan time. How do we break this circular dependency?
</question>
<options>
1. **Two-phase apply** — apply D1 resource first, capture the output ID, then apply the Worker with the ID. Requires splitting `tofu apply` into two steps.
2. **`data` source lookup** — use a `cloudflare_d1_database` data source to look up an already-existing D1 database by name. Requires D1 to be created manually first or in a prior apply.
3. **OpenTofu `depends_on`** — express the dependency explicitly and let OpenTofu plan the two resources in the correct order. May work if the Cloudflare provider handles the reference correctly.
</options>
<decision>
**Option 3 (attribute reference) — and there is no chicken-and-egg problem.**
This is a common Terraform/OpenTofu misconception. Writing `database_id = cloudflare_d1_database.db.id` in the Worker resource creates an **implicit dependency** via the attribute reference. OpenTofu:
1. Sees that `cloudflare_workers_script.api` depends on `cloudflare_d1_database.db` (via the `.id` reference)
2. Plans D1 creation first; shows Worker `database_id` as `(known after apply)` — this is **expected and correct**
3. During `tofu apply`: creates D1 first → gets its ID from state → creates Worker with that ID
No two-phase apply, no `data` source, no explicit `depends_on`. A single `tofu apply` provisions both resources in the correct order.
Confirmed from Cloudflare provider v4 source:
- D1 resource: `cloudflare_d1_database` — outputs `id` (String)
- Worker resource: `cloudflare_workers_script` (plural) — `d1_database_binding` block with `database_id` and `name` fields
- This also confirms the answer to TRIAGE efee79: resource name is `cloudflare_workers_script`
Concrete HCL:
```hcl
resource "cloudflare_d1_database" "db" {
account_id = var.cloudflare_account_id
name = "quotesdb"
}
resource "cloudflare_workers_script" "api" {
account_id = var.cloudflare_account_id
name = "quotesdb-api"
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.db.id # (known after apply) — resolved automatically
}
}
```
API Worker CI/CD deploy ticket: 57fe5e
</decision>
<resolution>
1. Research the options above and choose the best approach for this project.
2. Update the `infra/worker.tf` and `infra/d1.tf` resources with the chosen approach. Update ticket a23489 and d0da0b with any constraints.
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
</resolution>
<commit>
`chore(quotesdb): resolve triage — d1-binding-standard-attribute-reference-no-chicken-and-egg`
</commit>

@ -1,7 +1,41 @@
+++ +++
title = "[TRIAGE] OpenTofu state backend — local file (gitignored) vs Terraform Cloud vs Cloudflare R2?" title = "[TRIAGE] OpenTofu state backend — local file (gitignored) vs Terraform Cloud vs Cloudflare R2?"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
OpenTofu state backend: should the `.tfstate` file be stored locally (gitignored), in Terraform Cloud (free tier), or in Cloudflare R2 (S3-compatible backend)?
</question>
<options>
1. **Local file** — simplest, but state is lost if the machine changes and cannot be shared. Suitable for solo development.
2. **Terraform Cloud** — free tier available, remote state with locking. Requires a Terraform Cloud account.
3. **Cloudflare R2** — S3-compatible, keeps state within Cloudflare ecosystem. Requires an R2 bucket and API credentials.
</options>
<resolution>
**Decision: Local file backend (gitignored).**
Rationale:
- `quotesdb` is a solo developer project — no team, no CI/CD pipeline that needs shared state.
- Infrastructure is small (1 Worker + 1 D1 + 1 Pages project). If state is lost, all resources can be recovered via `tofu import`.
- Terraform Cloud: unnecessary HashiCorp account dependency with no benefit for solo use.
- Cloudflare R2: chicken-and-egg problem — the R2 bucket itself must be manually bootstrapped before it can serve as the OpenTofu backend, adding setup complexity and extra credential scope.
- Local file: zero extra accounts, zero extra credentials, immediate to set up.
Implementation (see ticket 2d1371):
- `infra/terraform.tf`: use default local backend (no `backend` block needed — local is the OpenTofu default).
- `infra/.gitignore`: ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`, `.terraform.lock.hcl`.
- `infra/README.md`: document that state is local and how to recover with `tofu import` if lost.
</resolution>
<commit>
`chore(quotesdb): resolve triage — opentofu-state-backend-local-file-gitignored-vs-terraform-cl`
</commit>

@ -1,7 +1,42 @@
+++ +++
title = "Write api/README.md, api/docs/PLANNING.md, api/docs/ARCHITECTURE.md" title = "Write api/README.md, api/docs/PLANNING.md, api/docs/ARCHITECTURE.md"
priority = 3 priority = 3
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a6bce1"] dependencies = ["a6bce1"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
</context>
<goal>
Write the three documentation files for the API domain:
1. `README.md` — what the API does, how to run it (`cargo run`), how to test it, license, Claude Code disclaimer
2. `docs/PLANNING.md` — development phases and work log for the API sub-domain
3. `docs/ARCHITECTURE.md` — API component overview: router, handlers, database layer, auth, OpenAPI spec
</goal>
<constraints>
- README must include the dual Apache-2.0 + MIT license notice.
- README must include a disclaimer that the software was written with Claude Code (model: claude-sonnet-4-6).
- ARCHITECTURE.md must describe how the API binary wires together (router → handlers → db layer).
- PLANNING.md must reflect the actual work done (link to ticket IDs where appropriate).
</constraints>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`docs(quotesdb): write api README, PLANNING.md, and ARCHITECTURE.md`
</commit>

@ -1,7 +1,55 @@
+++ +++
title = "[TRIAGE] Auth code storage strategy — localStorage persistence vs component-only state?" title = "[TRIAGE] Auth code storage strategy — localStorage persistence vs component-only state?"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Auth code storage strategy for the UI: should the auth code be stored in localStorage (persisted across sessions) or kept only in component state (lost on page reload)?
</question>
<options>
1. **Component state only** — auth code is lost on page reload. User must re-enter it each time. Simpler and more secure.
2. **localStorage per quote ID** — store `auth_code_{id}` in localStorage so the user doesn't need to re-enter it for quotes they created. Risk: plaintext in localStorage.
3. **Session storage** — same as localStorage but cleared when the tab closes. Middle ground.
</options>
<resolution>
**Chosen approach: Option 3 — session storage per quote ID.**
The auth code is stored in `sessionStorage` under the key `auth_code_{quote_id}`. It is
automatically cleared when the browser tab closes. No manual clear-on-delete is strictly
required, but is implemented as good practice (after a successful DELETE, the code is no
longer useful and should not linger).
Why not localStorage (option 2): the app explicitly tells users to store their auth code
externally ("Store this — it cannot be recovered later"). localStorage is indefinite and
has a wider XSS exposure window; session storage provides the same in-session convenience
without the long-term risk.
Why not component state (option 1): the code would be lost on every page navigation or
reload, making the edit/delete flow frustrating in practice.
Session storage covers the primary use case — "I just created this quote and want to edit
it" — without adding unnecessary persistence complexity.
Implementation ticket: **5379eb** — creates `src/bin/ui/storage.rs` with `get_auth_code`,
`set_auth_code`, `clear_auth_code` utilities wrapping `web_sys::window().session_storage()`,
plus the `initial_value: Option<String>` prop addition to `AuthModal` and the parent-component
integration pattern (read on modal open, write on success, clear on 403 or DELETE).
Tickets updated:
- **f850c6** (AuthModal): triage dependency replaced with 5379eb; goal updated with
`initial_value` prop; constraints updated with resolved storage approach.
- **c3503b** (UI sub-project): 0bc655 removed, 5379eb added.
</resolution>
<commit>
`chore(quotesdb): resolve triage — auth-code-storage-strategy-localstorage-persistence-vs-compo`
</commit>

@ -0,0 +1,89 @@
+++
title = "quotesdb/api: POST /api/admin/lock and /api/admin/unlock endpoints"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## POST /api/admin/lock and /api/admin/unlock endpoints
Add the two admin-protected endpoints that toggle the global submissions lock. Both require `X-Admin-Code` and return the current lock state after the operation.
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait and seeds the `submissions_locked` row in the database. Complete 35685a first.
---
## Files to modify
- `src/bin/api/handlers/mod.rs` (or `src/bin/api/handlers/admin.rs`) — add `lock_submissions` and `unlock_submissions` handlers
- `src/bin/api/main.rs` — register the two new routes
No new DB trait methods are needed; both handlers reuse `set_submissions_locked(bool)` introduced in 35685a.
---
## Handlers
```rust
/// POST /api/admin/lock
/// Requires X-Admin-Code header. Sets submissions_locked = true.
/// Response: 200 { "submissions_locked": true } or 403 on bad code.
pub async fn lock_submissions(
State(repo): State<Arc<dyn QuoteRepository>>,
headers: HeaderMap,
) -> impl IntoResponse {
let admin_code = extract_admin_code(&headers);
if !verify_admin_code(&repo, admin_code).await { ... }
match repo.set_submissions_locked(true).await {
Ok(()) => Json(json!({ "submissions_locked": true })).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
/// POST /api/admin/unlock
/// Requires X-Admin-Code header. Sets submissions_locked = false.
/// Response: 200 { "submissions_locked": false } or 403 on bad code.
pub async fn unlock_submissions(
State(repo): State<Arc<dyn QuoteRepository>>,
headers: HeaderMap,
) -> impl IntoResponse {
// same pattern, locked = false
}
```
Implement a shared helper `verify_admin_code(repo, code) -> bool` (or extract inline) that fetches the stored admin code from `admin_config` and compares it. Use constant-time comparison if possible.
---
## Route registration (src/bin/api/main.rs)
```rust
.route("/api/admin/lock", post(handlers::lock_submissions))
.route("/api/admin/unlock", post(handlers::unlock_submissions))
```
---
## Tests
- `POST /api/admin/lock` with correct `X-Admin-Code``200 { "submissions_locked": true }`
- `POST /api/admin/unlock` with correct `X-Admin-Code``200 { "submissions_locked": false }`
- `POST /api/admin/lock` with wrong code → `403`
- `POST /api/admin/unlock` with missing header → `403`
- Lock/unlock idempotent: locking when already locked still returns `200 { "submissions_locked": true }`
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints
```

@ -1,7 +1,44 @@
+++ +++
title = "[TRIAGE] HTTP client selection for integration tests — reqwest vs hyper vs ureq (tokio vs blocking)" title = "[TRIAGE] HTTP client selection for integration tests — reqwest vs hyper vs ureq (tokio vs blocking)"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
HTTP client for integration tests: should we use reqwest (async, tokio), hyper (low-level), or ureq (synchronous/blocking)?
</question>
<options>
1. **reqwest** — most ergonomic, async, works well with tokio::test. Adds a heavier dependency but is widely used.
2. **hyper** — low-level, minimal dependencies. More verbose.
3. **ureq** — synchronous, no async runtime needed. Simple but requires spawning a background thread to run the server.
</options>
<resolution>
**Chosen approach: Option 1 — `reqwest` with `#[tokio::test]`.**
The API server is Axum + Tokio. `reqwest` is the idiomatic async HTTP client in this stack:
- `#[tokio::test]` + `reqwest` is the standard Rust integration-testing pattern for Axum services.
- `features = ["json"]` enables ergonomic `.json()` request bodies and `.json::<T>()` response
deserialization — essential for testing JSON API endpoints.
- Dev-dependency only: the weight of the crate does not affect the production binary size.
Options 2 (hyper) and 3 (ureq) were ruled out:
- hyper 1.x has a complex, low-level API that adds boilerplate with no test-writing benefit.
- ureq is synchronous; using it with an async Axum server would require spawning a background
thread for the server in every test, adding avoidable setup complexity.
Implementation ticket **5f5ba0** (already exists and is correctly specified) captures all
necessary work: adds `reqwest = { version = "0.12", features = ["json"] }`, `tokio`, `serde_json`,
and `tempfile` to `[dev-dependencies]` in `Cargo.toml`. No new ticket is required.
</resolution>
<commit>
`chore(quotesdb): resolve triage — http-client-selection-for-integration-tests-reqwest-vs-hyper`
</commit>

@ -1,7 +1,40 @@
+++ +++
title = "Implement shared QuoteCard component — displays text, author, source, date, tags" title = "Implement shared QuoteCard component — displays text, author, source, date, tags"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "93515e"] dependencies = ["93515e", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
</context>
<goal>
Implement a shared `QuoteCard` Yew component (`src/bin/ui/components/quote_card.rs`) that displays:
- Quote text (styled as a blockquote)
- Author name (linked to `/author/:name`)
- Optional source and date
- Tags as clickable chips (linking to `/browse?tag=X`)
This component is reused on the Home, Browse, Author, and Quote Detail pages.
</goal>
<constraints>
- Resolve TRIAGE ticket 5e3e37 (CSS/styling approach) before adding class names.
- Accept a `Quote` struct as a prop (from shared types in `src/lib.rs`).
- Author link must navigate to `/author/:name` using yew-router's `Link` component.
- Tags are optional — render nothing if the quote has no tags.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement shared QuoteCard component`
</commit>

@ -0,0 +1,89 @@
+++
title = "Write src/bin/ui/style.css — full stylesheet for all UI pages and components"
priority = 6
status = "done"
ticket_type = "task"
dependencies = ["dc3d2b"]
+++
<context>
CSS approach resolved in triage 5e3e37: **plain CSS** — a single `src/bin/ui/style.css` file linked from `index.html` via `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`.
All UI component tickets use BEM-style class names defined in this file. No CSS-in-Rust crates, no CDN Tailwind.
</context>
<goal>
Write `src/bin/ui/style.css` covering all pages and components in the Yew UI.
</goal>
<naming-convention>
BEM-style semantic class names. Blocks and elements use lowercase hyphenated names.
| Component / Page | Block class | Notable element classes |
|---|---|---|
| Global | `body`, `main`, `nav` | — |
| Navigation | `nav` | `nav__link`, `nav__brand` |
| QuoteCard | `quote-card` | `quote-card__text`, `quote-card__author`, `quote-card__meta`, `quote-card__tags`, `quote-card__tag` |
| Home page | `page-home` | `page-home__random`, `page-home__cta` |
| Browse page | `page-browse` | `page-browse__filters`, `page-browse__list` |
| Quote detail page | `page-quote` | `page-quote__actions` |
| Author page | `page-author` | `page-author__header` |
| Submit page | `page-submit` | `page-submit__form`, `page-submit__success` |
| Pagination | `pagination` | `pagination__btn`, `pagination__info` |
| Tag filter | `tag-filter` | `tag-filter__input`, `tag-filter__list`, `tag-filter__tag` |
| Auth modal | `auth-modal` | `auth-modal__overlay`, `auth-modal__dialog`, `auth-modal__input`, `auth-modal__actions` |
| Error display | `error-display` | `error-display__message` |
| Form elements | `form` | `form__field`, `form__label`, `form__input`, `form__textarea`, `form__error` |
| Buttons | `btn` | `btn--primary`, `btn--secondary`, `btn--danger` |
| Auth code reveal | `auth-reveal` | `auth-reveal__code`, `auth-reveal__note` |
| Loading | `loading` | — |
| Empty state | `empty-state` | `empty-state__message` |
</naming-convention>
<design-notes>
- Clean, minimal typography-focused design appropriate for a quotes site.
- Readable body font (system-ui or serif stack for quote text).
- Max-width container centered on page: ~720px for readability.
- Accessible colour contrast (WCAG AA minimum).
- Responsive: readable on mobile without horizontal scroll.
- No external font imports — use system fonts.
- Light theme only (no dark mode required).
</design-notes>
<yew-usage>
In Yew components, use class names as string literals:
```rust
html! {
<div class="quote-card">
<blockquote class="quote-card__text">{ &quote.text }</blockquote>
<cite class="quote-card__author">{ &quote.author }</cite>
</div>
}
```
For conditional classes use the `classes!` macro:
```rust
html! {
<button class={classes!("btn", "btn--primary", disabled.then_some("btn--disabled"))}>
{ "Submit" }
</button>
}
```
</yew-usage>
<validation>
After writing the CSS, verify it is picked up by Trunk:
```sh
trunk build
```
Inspect the generated `dist/` directory to confirm the CSS file is bundled.
</validation>
<commit>
`style(quotesdb): add UI stylesheet with BEM component classes`
</commit>

@ -0,0 +1,100 @@
+++
title = "quotesdb/ui: /submit page locked-state banner"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = ["161f32"]
+++
## /submit page locked-state banner (UI)
Modify the existing /submit page to check the submission lock on mount. When the lock is active, hide the submission form entirely and show a closed-submissions banner in its place.
Depends on ticket 161f32 (admin API client functions). Complete that ticket first.
---
## Files to modify
- `src/bin/ui/pages/submit.rs` — add status check on mount and conditional rendering
---
## Changes to SubmitPage component
### New state fields
Add to the existing component state:
| Field | Type | Purpose |
|---|---|---|
| `submissions_locked` | `Option<bool>` | `None` while loading, `Some(true/false)` after status check |
| `status_error` | `bool` | Set if `get_status()` itself fails (show form as fallback) |
### On mount
Spawn an async task that calls `api::get_status()`:
```rust
// In use_effect_with or similar hook, fired once on mount:
let submissions_locked = submissions_locked.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::get_status().await {
Ok(status) => submissions_locked.set(Some(status.submissions_locked)),
Err(_) => {
// On error, default to showing the form (fail open).
submissions_locked.set(Some(false));
}
}
});
```
### Render logic
```
if submissions_locked == None:
show a loading indicator (or nothing / skeleton)
elif submissions_locked == Some(true):
show closed banner, hide form
else:
show form as normal
```
### Closed banner markup (approximate)
```html
<div class="submissions-closed-banner">
<p>Submissions are currently closed.</p>
</div>
```
Style the banner to be visually distinct — use a muted/warning colour. Add the `.submissions-closed-banner` CSS class to `src/bin/ui/style.css`.
### Fail-open behaviour
If `api::get_status()` returns an error, treat it as unlocked (`Some(false)`) and display the form. Do not block a user from submitting due to a network error on the status check.
---
## Tests
No runtime unit tests (wasm-only). Verify the build:
```sh
cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Validation
```sh
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Commit
```
feat(quotesdb): show locked banner on /submit when submissions are closed
```

@ -0,0 +1,122 @@
+++
title = "quotesdb/ui: admin API client functions"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Admin API client functions (UI)
Add four async functions to the UI API client module that cover every admin and status endpoint introduced by the API tickets. These functions are consumed by the /admin page and the /submit page.
---
## Files to modify
- `src/bin/ui/api.rs` — add four new public async functions
---
## New types
Add to `src/bin/ui/api.rs` (or to a shared types module imported by api.rs):
```rust
/// Response from GET /api/status.
#[derive(Deserialize, Clone, PartialEq)]
pub struct StatusResponse {
pub submissions_locked: bool,
}
/// Response from POST /api/admin/reset-auth-code.
#[derive(Deserialize)]
struct ResetAuthCodeResponse {
pub auth_code: String,
}
/// Response from POST /api/admin/lock or /api/admin/unlock.
#[derive(Deserialize)]
struct LockResponse {
pub submissions_locked: bool,
}
```
---
## New functions
```rust
/// Fetch the current submission lock state from GET /api/status.
/// Returns Ok(StatusResponse) on success or ApiError on failure.
pub async fn get_status() -> Result<StatusResponse, ApiError> { ... }
/// Call POST /api/admin/reset-auth-code.
/// Sends X-Admin-Code: admin_code in the request header.
/// Body: { "new_code": new_code } (omit field if new_code is None).
/// Returns the new auth code string on success, or ApiError on failure.
pub async fn admin_reset_auth_code(
current: &str,
new_code: Option<&str>,
admin_code: &str,
) -> Result<String, ApiError> { ... }
/// Call POST /api/admin/lock.
/// Sends X-Admin-Code: admin_code in the request header.
/// Returns Ok(true) on success, or ApiError (including ApiError::Forbidden on 403).
pub async fn admin_lock(admin_code: &str) -> Result<bool, ApiError> { ... }
/// Call POST /api/admin/unlock.
/// Sends X-Admin-Code: admin_code in the request header.
/// Returns Ok(false) on success, or ApiError (including ApiError::Forbidden on 403).
pub async fn admin_unlock(admin_code: &str) -> Result<bool, ApiError> { ... }
```
Implementation notes:
- Use the same `gloo_net::http::Request` pattern already used in `api.rs` for other endpoints.
- Add an `ApiError::Forbidden` variant (or reuse an existing error variant) to communicate `403` responses back to the UI so pages can show "Wrong auth code." without a generic error.
- `admin_reset_auth_code`: serialize the body as `{ "new_code": "..." }` when `new_code` is `Some`, or as `{}` when `None`.
- `admin_lock` and `admin_unlock` send no request body (empty POST).
---
## ApiError extension
If `ApiError` does not already have a `Forbidden` variant, add one:
```rust
pub enum ApiError {
// ... existing variants ...
/// The server returned 403 Forbidden (wrong admin code).
Forbidden,
}
```
Map HTTP 403 → `ApiError::Forbidden` in each new function before returning.
---
## Tests
This module compiles only for `wasm32-unknown-unknown` so no `cargo test` unit tests are practical here. Instead, verify the build compiles cleanly:
```sh
cargo check --target wasm32-unknown-unknown --bin ui
```
Write a brief doc-comment on each function describing its endpoint, required header, and error conditions.
---
## Validation
```sh
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Commit
```
feat(quotesdb): admin API client functions in UI
```

@ -1,7 +1,32 @@
+++ +++
title = "[TRIAGE] Yew version selection and yew-router compatibility (0.21+?)" title = "[TRIAGE] Yew version selection and yew-router compatibility (0.21+?)"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Yew version selection: which version of Yew and yew-router should be used, and are they compatible with each other and the Nix dev shell?
</question>
<resolution>
**Decision: yew = "0.22", yew-router = "0.19"**
Research findings (2026-03-02):
- yew latest stable: 0.22.1
- yew-router latest stable: 0.19.0
- yew-router 0.19 requires `yew ^0.22.0` — confirmed compatible
- Both crates use `wasm-bindgen ^0.2`, compatible with `wasm-bindgen-cli 0.2.108` in the Nix dev shell
Actions taken:
- Updated ticket 93515e with explicit version constraints and serde placement guidance
- Documented chosen versions in `docs/ARCHITECTURE.md` under "Key Dependency Versions"
</resolution>
<commit>
`chore(quotesdb): resolve triage — yew-version-selection-and-yewrouter-compatibility-021`
</commit>

@ -1,7 +1,49 @@
+++ +++
title = "Implement tag join logic — fetch tags per quote, insert/replace tags on create/update" title = "Implement tag join logic — fetch tags per quote, insert/replace tags on create/update"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d"] dependencies = ["a5049d"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
Each quote can have multiple tags stored in the `quote_tags` join table. Tags are not normalised — they are stored as plain strings per quote. On create/update, all tags for the quote are replaced atomically.
</context>
<goal>
Implement tag fetch and upsert logic used by the API handlers:
1. `fetch_tags_for_quote(pool, quote_id) -> Vec<String>` — SELECT from quote_tags
2. `replace_tags_for_quote(pool, quote_id, tags: &[String])` — DELETE existing, INSERT new tags in a transaction
This logic should live in a `db` or `tags` module and be called from the create and update handlers.
</goal>
<constraints>
- Tag replacement must be atomic (use a transaction).
- Empty `tags` array means "remove all tags" — this is valid.
- Cascade delete on `quote_tags` handles tag cleanup when a quote is deleted — no separate delete-tags step needed.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write unit tests that verify tag insertion, replacement, and empty-tag cases.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement tag join logic — fetch and replace tags per quote`
</commit>

@ -1,7 +1,45 @@
+++ +++
title = "[TRIAGE] Cloudflare Workers WASM size limit — free tier 1MB limit may require paid plan for Rust binary" title = "[TRIAGE] Cloudflare Workers WASM size limit — free tier 1MB limit may require paid plan for Rust binary"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Cloudflare Workers WASM size limit: the free tier has a 1MB Worker script size limit. A Rust binary compiled for workers-rs may exceed this. Does this project require a paid Workers plan?
</question>
<options>
1. **Paid Workers plan** — removes the 1MB limit ($5/month). Simplest solution.
2. **Optimise binary size** — use `opt-level = "z"`, `lto = true`, `strip = true`, `wasm-opt`, and minimise dependencies. May bring the binary under 1MB.
3. **Split the Worker** — serve static assets from Pages and keep the Worker API-only (fewer dependencies).
</options>
<decision>
**The 1 MB limit in this ticket is outdated.** The current Cloudflare Workers limits (as of 2026) are:
- Workers Free: **3 MB after gzip compression**, 64 MB before compression
- Workers Paid: **10 MB after gzip**, 64 MB before compression
**Chosen approach: Free tier + binary size optimisation (no paid plan required).**
Rationale:
- The API Worker only handles API routes — no Yew/UI code is bundled into it (UI is on Pages).
- The database layer uses `workers-rs` native D1 bindings (not SQLx) per ticket e8a330 — this eliminates a heavy dependency.
- The `Cargo.toml` release profile already has `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`.
- `wrangler` applies `wasm-opt` automatically during deploy, further reducing WASM size.
- A simple CRUD API with these optimisations gzips well under 3 MB.
- Verification command: `wrangler deploy --outdir bundled/ --dry-run` (shows `gzip: X KiB`).
**Implementation ticket created: see ticket for binary size verification after Cargo.toml dependencies are added (1f5bb5).**
If the binary somehow exceeds 3 MB, fallback options are: further dependency pruning, or Workers Paid at $5/month.
</decision>
<commit>
`chore(quotesdb): resolve triage — cloudflare-workers-wasm-size-limit-free-tier-1mb-limit-may-r`
</commit>

@ -1,7 +1,39 @@
+++ +++
title = "Implement Home page (/) — fetch and display random quote, 'Browse all' link" title = "Implement Home page (/) — fetch and display random quote, 'Browse all' link"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "fc2f51"] dependencies = ["04f865", "1e6a09", "0d987f", "fc2f51", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The Home page is the landing page of the app. It displays a random quote fetched from `GET /api/quotes/random` and a "Browse all" link to `/browse`.
</context>
<goal>
Implement the Home page component (`src/bin/ui/pages/home.rs`):
1. On mount, fetch a random quote from the API via the API client module (ticket 1e6a09)
2. While loading, show a loading indicator
3. On success, render the `QuoteCard` component (ticket 0d987f)
4. On error, render the `ErrorDisplay` component (ticket fc2f51)
5. Render a "Browse all quotes →" link to `/browse`
</goal>
<constraints>
- Use `use_effect_with` (Yew 0.21+) or the equivalent hook to trigger the fetch on mount.
- The random quote endpoint returns 404 if the database is empty — display a friendly "no quotes yet" message in this case.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement Home page — random quote display`
</commit>

@ -1,7 +1,39 @@
+++ +++
title = "Implement Submit page (/submit) — quote creation form, display returned auth_code on success" title = "Implement Submit page (/submit) — quote creation form, display returned auth_code on success"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "04f865", "1e6a09", "fc2f51"] dependencies = ["04f865", "1e6a09", "fc2f51", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The Submit page (`/submit`) provides a form for creating a new quote. On success, it displays the returned auth code prominently so the user can save it.
</context>
<goal>
Implement the Submit page component (`src/bin/ui/pages/submit.rs`):
1. Render a form with fields: text (textarea), author, source (optional), date (optional), tags (comma-separated input), auth code (optional)
2. On submit, call `PUT /api/quotes` via the API client
3. On 201 success: show a success message and display the returned auth code in a copyable box
4. On error: render `ErrorDisplay` with the error message
</goal>
<constraints>
- The auth code returned must be displayed clearly — it cannot be recovered after the user leaves this page.
- Validate client-side: text and author are required (non-empty) before submitting.
- Parse the tags input by splitting on commas and trimming whitespace.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement Submit page — new quote form with auth code display`
</commit>

@ -1,7 +1,54 @@
+++ +++
title = "Implement API client module — typed fetch wrappers for all quotesdb-api endpoints" title = "Implement API client module — typed fetch wrappers for all quotesdb-api endpoints"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "93515e"] dependencies = ["93515e"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The API client module provides typed fetch wrappers around all quotesdb-api endpoints. The UI calls these functions from page components rather than making raw fetch calls directly.
</context>
<goal>
Implement `src/bin/ui/api.rs` (or `src/bin/ui/client.rs`) with async functions for each endpoint:
- `list_quotes(page, author, tag) -> Result<ListResponse>`
- `get_quote(id) -> Result<Quote>`
- `get_random_quote() -> Result<Quote>`
- `create_quote(body) -> Result<CreateResponse>`
- `update_quote(id, auth_code, body) -> Result<Quote>`
- `delete_quote(id, auth_code) -> Result<()>`
Each function sets the appropriate headers (including `X-Auth-Code` where needed) and deserialises the response.
</goal>
<base-url-approach>
CORS/proxy resolved in triage a9534d: **Trunk proxy, relative URLs**.
- Use **relative URLs** (`/api/quotes`, `/api/quotes/random`, etc.) — no base URL configuration needed.
- In local dev, Trunk's `[[proxy]]` in `Trunk.toml` forwards `/api/*` to `localhost:3000` transparently.
- In production, Cloudflare routes `/api/*` to the Worker at the same domain.
- Do NOT use `window.location.origin` as a base URL — relative paths work everywhere.
- Do NOT add any CORS headers in the frontend — no cross-origin requests occur.
</base-url-approach>
<constraints>
- Use `gloo::net::http` for HTTP requests (not reqwest — not available in WASM).
- All API paths are relative: `/api/quotes`, `/api/quotes/{id}`, `/api/quotes/random`.
- All functions must be `async` and return `Result` with a meaningful error type.
- Do NOT configure a base URL — relative URLs are sufficient and correct.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement typed API client module for all quotesdb-api endpoints`
</commit>

@ -1,7 +1,53 @@
+++ +++
title = "Set up api/Cargo.toml with all crate dependencies (axum, tokio, workers-rs, sqlx, serde, nanoid, etc.)" title = "Set up Cargo.toml with all crate dependencies (axum, tokio, workers-rs, rusqlite, serde, uuid, etc.)"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a91260"] dependencies = ["7a0d9f"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
</context>
<goal>
Create `Cargo.toml` for the `quotesdb` crate with all API-side dependencies. Include `[[bin]]` entries for both `api` and `ui` binaries, platform-specific dependency sections (`cfg(target_arch = "wasm32")`), dev-dependencies for tests, and the release profile with size optimizations.
</goal>
<constraints>
- `workers-rs` (`worker` crate) is WASM/Workers-only — gate under `[target.'cfg(target_arch = "wasm32")'.dependencies]`
- `tokio`, `axum`, `rusqlite`, `tokio-rusqlite` are native-only — gate under `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`
- Yew, wasm-bindgen, and web-sys are UI-only — gate under `[target.'cfg(target_arch = "wasm32")'.dependencies]`
(The single-crate structure means API-WASM and UI-WASM deps share the same cfg section — use
separate feature flags or bin-specific cfg if they conflict)
- Do NOT include `sqlx` — it is incompatible with the Workers target (TRIAGE e8a330 resolved)
- The `[profile.release]` block must set `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`
- **ID generation (TRIAGE 6f2e18 resolved):** Use `uuid = { version = "1", features = ["v4", "serde"] }` in `[dependencies]`. See ticket 7a0d9f.
- **Passphrase generation (TRIAGE 6ed325 resolved):** Use `rand = "0.10"` in `[dependencies]`. Use `rand::rngs::OsRng` (not `thread_rng`). See ticket 03bb91.
- **WASM entropy (both ID + passphrase):** Add `getrandom = { version = "0.4", features = ["wasm_js"] }` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. This is required by both `uuid` (v4 feature) and `rand` (OsRng) on wasm32. The `wasm_js` feature (renamed from `js` in getrandom 0.2) enables `crypto.getRandomValues()` for Cloudflare Workers and browsers. Do NOT use getrandom 0.2 or the old `js` feature name.
- See ticket 00aff0 for the full list of DB-related dependencies (rusqlite, tokio-rusqlite, async-trait)
- **OpenAPI spec (TRIAGE 2ec8b1 resolved):** Add a `[build-dependencies]` section with
`serde_json = "1"` and `serde_yaml = "0.9"`. These are used by `build.rs` (ticket 8892d5)
to convert `api/openapi.yaml` to JSON at compile time. They must NOT appear in `[dependencies]`.
</constraints>
<skills>
Use `superpowers:verification-before-completion` after adding dependencies — run `cargo check` to confirm the dependency tree resolves.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`chore(quotesdb): set up Cargo.toml with api and ui dependencies`
</commit>

@ -3,5 +3,27 @@ title = "quotesdb/infra"
priority = 7 priority = 7
status = "todo" status = "todo"
ticket_type = "project" ticket_type = "project"
dependencies = [] dependencies = ["06d304"]
+++ +++
<context>
This is the sub-project tracking ticket for `quotesdb/infra`. All infrastructure tasks depend on this ticket. The infra domain covers: OpenTofu project setup, Cloudflare Worker, D1 database, Pages project, custom domain, and documentation.
</context>
<goal>
All `quotesdb/infra` tasks are planned, implemented, validated, and closed. `tofu plan` reports no unexpected changes and `tofu apply` provisions the full Cloudflare stack.
</goal>
<skills>
Use `superpowers:dispatching-parallel-agents` when assigning multiple infra tasks to agents in parallel.
Use `superpowers:verification-before-completion` before marking this ticket done.
</skills>
<validation>
Run from the `infra/` directory:
```sh
tofu validate
tofu plan
```
</validation>

@ -1,7 +1,67 @@
+++ +++
title = "Implement GET /api/ — serve OpenAPI spec as JSON" title = "Implement GET /api/ — serve OpenAPI spec as JSON"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "1f5bb5", "2ec8b1"] dependencies = ["1f5bb5", "8892d5"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
The `GET /api/` endpoint serves the OpenAPI 3.1.0 specification as JSON. This endpoint requires no authentication and is the entry point for API documentation and client generation.
</context>
<goal>
Implement the `GET /api/` handler that returns the OpenAPI spec as `application/json`.
Strategy resolved in TRIAGE 2ec8b1: **compile-time embed via `build.rs`** (ticket 8892d5).
The `build.rs` converts `api/openapi.yaml` to JSON at build time and writes it to
`$OUT_DIR/openapi.json`. The handler serves this as a static `&str`:
```rust
// Embedded at compile time by build.rs — no runtime parsing, no serde_yaml in binary.
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
pub async fn get_openapi_spec() -> impl IntoResponse {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
OPENAPI_JSON,
)
}
```
Register the route in the Axum router as `GET /api/`.
</goal>
<constraints>
- The response `Content-Type` must be `application/json`.
- Do NOT use `serde_yaml` in this handler — the YAML→JSON conversion is done by `build.rs`
(ticket 8892d5). The handler only serves a pre-built static string.
- Do NOT use `OnceLock` or lazy parsing — `OPENAPI_JSON` is a `const &str` embedded at
compile time; no initialisation is needed.
- The spec at `api/openapi.yaml` is the source of truth — validate with
`redocly lint api/openapi.yaml` after any changes.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write a test that hits `GET /api/` and asserts the response is valid JSON with an `openapi` key.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement GET /api/ to serve OpenAPI spec as JSON`
</commit>

@ -1,7 +1,46 @@
+++ +++
title = "[TRIAGE] Test harness: how to import and start quotesdb-api in tests (workers-rs vs native build target)" title = "[TRIAGE] Test harness: how to import and start quotesdb-api in tests (workers-rs vs native build target)"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Test harness: how do we import and start the quotesdb-api binary in integration tests when it uses workers-rs, which targets the Cloudflare Workers runtime rather than a native Rust binary?
</question>
<options>
1. **Native feature flag** — add a `#[cfg(not(target_env = "worker"))]` branch in `main.rs` that exposes a plain Axum server. Integration tests use this branch (compiled for host target).
2. **Separate test binary** — create a `src/bin/api_test.rs` that is a native Axum server without workers-rs, used only in tests.
3. **Wrangler dev** — run `wrangler dev` in the background and point tests at it. Complex setup, slower CI.
</options>
<decision>
**Option 1 variant: `cfg(target_arch = "wasm32")` split — no feature flag, no separate binary.**
The `cfg(target_arch)` approach in Cargo.toml means that when `cargo test` runs on the native
host, the workers-rs crate is never pulled in (it is a `[target.'cfg(target_arch = "wasm32")'.dependencies]`
entry). The native Axum server path compiles automatically.
Integration tests in `tests/` start the server by calling a `spawn_test_server()` helper that:
1. Opens an in-memory or temp-file rusqlite DB (via `NativeRepository`)
2. Calls `router::build_router(repo)` to get the Axum `Router`
3. Binds to a random port with `tokio::net::TcpListener::bind("127.0.0.1:0")`
4. Spawns the server with `tokio::spawn(axum::serve(listener, app))`
5. Returns `(base_url, shutdown_handle)`
No wrangler dev, no separate binary, no feature flags. Standard `cargo test` workflow.
Resolved as part of TRIAGE e8a330 and a91260 (cfg-split architecture decision).
See implementation ticket 00aff0 for the DB abstraction details and ticket 9b581f for the
test harness implementation.
</decision>
<commit>
`chore(quotesdb): resolve triage — test-harness-how-to-import-and-start-quotesdbapi-in-tests-wo`
</commit>

@ -1,7 +1,38 @@
+++ +++
title = "Implement pagination component — prev/next buttons, current page indicator, total pages" title = "Implement pagination component — prev/next buttons, current page indicator, total pages"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "93515e"] dependencies = ["93515e", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
</context>
<goal>
Implement a shared `Pagination` Yew component (`src/bin/ui/components/pagination.rs`) that renders:
- A "Previous" button (disabled on page 1)
- Current page indicator (e.g. "Page 2 of 5")
- A "Next" button (disabled on the last page)
The component accepts `page`, `total_pages`, and an `on_page_change: Callback<u32>` prop.
</goal>
<constraints>
- Resolve TRIAGE ticket 5e3e37 (CSS/styling approach) before adding class names.
- Do not navigate programmatically — call `on_page_change` and let the parent update the URL or state.
- Render nothing (or a disabled shell) if `total_pages <= 1`.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement shared Pagination component`
</commit>

@ -1,7 +1,46 @@
+++ +++
title = "Implement GET /api/quotes/random — random row query (must be registered before /:id route)" title = "Implement GET /api/quotes/random — random row query (must be registered before /:id route)"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] dependencies = ["a5049d", "d792e2", "175382"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
`GET /api/quotes/random` returns a single random quote from the database. This endpoint **must be registered before** `GET /api/quotes/:id` in the Axum router, or it will never be reached (Axum matches in registration order and ":id" would match the literal string "random").
</context>
<goal>
Implement the `GET /api/quotes/random` handler that selects a random row from the `quotes` table and returns it with its tags. Return 404 if the database is empty.
</goal>
<constraints>
- **Router ordering is critical** — document the ordering requirement in a comment in `main.rs`.
- Use `ORDER BY RANDOM() LIMIT 1` for SQLite random selection.
- Include the quote's tags in the response.
- Return `404 Not Found` with `{"error": "no quotes available"}` if the table is empty.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write tests for: random quote returned (non-empty DB), 404 when DB is empty.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement GET /api/quotes/random`
</commit>

@ -1,7 +1,39 @@
+++ +++
title = "Set up infra/ OpenTofu project — providers.tf, terraform.tf, .gitignore for state" title = "Set up infra/ OpenTofu project — providers.tf, terraform.tf, .gitignore for state"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "07feaa"] dependencies = ["07feaa"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
</context>
<goal>
Bootstrap the OpenTofu project in `infra/`:
1. Create `infra/providers.tf` — declare the Cloudflare provider with the required version
2. `infra/terraform.tf`**use the local file backend** (07feaa resolved: local file is the correct choice for this solo project). The local backend is OpenTofu's default, so no explicit `backend` block is needed in `terraform.tf`. The file only needs the `required_providers` block (already partially present in `main.tf` — move it to `terraform.tf` and remove from `main.tf`).
3. Create `infra/.gitignore` — ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`, `.terraform.lock.hcl`
4. Run `tofu init` to initialise the provider
</goal>
<constraints>
- State backend is **local file** (resolved by 07feaa). No `backend` block is required — omitting it uses the local default.
- The Cloudflare provider requires an API token — document the expected environment variable (`CLOUDFLARE_API_TOKEN`) in a comment in `providers.tf`, do not hardcode it.
- Every `resource` and `data` block must have a comment explaining its purpose (per CLAUDE.md).
- Note: `infra/main.tf` currently contains the `terraform` block — move it to `infra/terraform.tf` during this task.
</constraints>
<validation>
Run from the `infra/` directory:
```sh
tofu validate
tofu plan
```
</validation>
<commit>
`chore(quotesdb): bootstrap OpenTofu infra project with Cloudflare provider`
</commit>

@ -1,7 +1,54 @@
+++ +++
title = "[TRIAGE] OpenAPI spec serving strategy — embed YAML at compile time vs load at runtime" title = "[TRIAGE] OpenAPI spec serving strategy — embed YAML at compile time vs load at runtime"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
OpenAPI spec serving strategy: should the spec be embedded at compile time (include_str! macro) or loaded at runtime from a file or generated programmatically?
</question>
<options>
1. **Compile-time embed**`include_str!("../../api/openapi.yaml")` bakes the YAML into the binary. Simple, no runtime file I/O needed for Workers.
2. **Runtime load** — read the file at startup. Does not work in Cloudflare Workers (no filesystem).
3. **Programmatic generation** — use a crate like `utoipa` to generate the spec from handler annotations. Most maintainable but adds complexity.
</options>
<resolution>
**Chosen approach: Option 1 — compile-time embed, via `build.rs` (not raw `include_str!` of YAML).**
The refined implementation uses a `build.rs` script rather than embedding the raw YAML and
parsing it at runtime. Specifically:
- `build.rs` reads `api/openapi.yaml`, parses it to `serde_json::Value` with `serde_yaml`,
writes compact JSON to `$OUT_DIR/openapi.json`, and emits
`cargo:rerun-if-changed=api/openapi.yaml` so the conversion re-runs on every spec change.
- The `GET /api/` handler serves the result as:
`const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));`
- `serde_yaml` is a `[build-dependencies]` entry only — it never enters the Workers binary,
keeping binary size minimal.
- Zero runtime overhead: no `OnceLock`, no lazy parsing, no heap allocation for the spec.
Options 2 (runtime load) and 3 (utoipa) were ruled out:
- Option 2 is impossible on Cloudflare Workers — there is no filesystem at runtime.
- Option 3 (utoipa) would require annotating all 7 handlers with macros and migrating away
from the hand-written `api/openapi.yaml` spec, which is already complete and validated.
The added complexity is not justified for a project of this size.
Tickets updated:
- **8892d5** (new): implements `build.rs` and adds `[build-dependencies]` to `Cargo.toml`.
- **28e7d9**: updated with the concrete `include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))`
handler pattern; now depends on 8892d5 instead of this triage ticket.
- **1f5bb5**: updated with the `[build-dependencies]` constraint.
- **f3dc74** (API sub-project): 8892d5 added as dependency.
</resolution>
<commit>
`chore(quotesdb): resolve triage — openapi-spec-serving-strategy-embed-yaml-at-compile-time-vs-`
</commit>

@ -1,7 +1,56 @@
+++ +++
title = "[TRIAGE] Local dev config: Turso (file SQLite) vs D1 binding selection strategy" title = "[TRIAGE] Local dev config: Turso (file SQLite) vs D1 binding selection strategy"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Local dev config: should the API use Turso (file-backed SQLite via libsql) or a D1 binding (via wrangler dev) for local development? How is the selection made at runtime?
</question>
<options>
1. **Turso/libsql** — lightweight local SQLite file, no Cloudflare account needed. Connection string via env var. SQLx-compatible.
2. **Wrangler D1 local**`wrangler dev` spins up a local D1 emulator. Closer to production but requires wrangler and a Cloudflare account even locally.
3. **Plain SQLite via sqlx** — use sqlx's SQLite driver with a local file. No Turso dependency needed for dev.
</options>
<resolution status="resolved">
**Chosen approach: rusqlite with a local SQLite file — a variant of Option 3, but using rusqlite instead of sqlx.**
This decision is a direct consequence of TRIAGE e8a330 (already resolved): SQLx is NOT compatible
with Cloudflare Workers/D1 at all. The chosen architecture is `cfg(target_arch = "wasm32")` compile-time split:
- `wasm32` (production) → workers-rs `D1Database` bindings
- native (local dev + tests) → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file
Rationale for rusqlite over Turso:
- No additional dependency or service (Turso = libsql client + server/cloud)
- `rusqlite` with `features = ["bundled"]` compiles SQLite in — zero system dependencies
- `cargo run` just works without any account, credentials, or external tooling
- `cargo test` works the same way — tests use the rusqlite path automatically
Rationale for rusqlite over wrangler D1 local:
- No wrangler, no Cloudflare account required for local dev or CI
- Eliminates a major developer friction point
- Integration tests use `NativeRepository` (rusqlite) directly without spawning wrangler
Selection mechanism: **compile-time** via `cfg(target_arch = "wasm32")`, not runtime env var.
The `DATABASE_URL` env var controls the SQLite file path (default: `./quotesdb.sqlite`).
Port note: Native API server binds to `localhost:3000` (Trunk UI dev server uses `localhost:8080`).
Port conflict found and fixed in ticket 00aff0 (was 8080, corrected to 3000).
Updated:
- Ticket 00aff0 (DB abstraction): corrected native server port 8080 → 3000
- Ticket af56a7 (local dev docs): updated title and body to reflect rusqlite approach
- Ticket 9c9546 (new): create `.env.example` documenting `DATABASE_URL`
</resolution>
<commit>
`chore(quotesdb): resolve triage — local-dev-config-turso-file-sqlite-vs-d1-binding-selection-s`
</commit>

@ -0,0 +1,43 @@
+++
title = "quotesdb/ui: report button with modal (reason field + captcha)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = ["77237f"]
+++
## Summary
Add a Report button to quote detail pages that opens a modal with an optional reason field and a CAPTCHA to prevent abuse.
## Details
- Report button appears on /quotes/:id page
- Clicking opens a modal with:
- Optional reason textarea (max 256 characters, show character counter)
- CAPTCHA widget (Cloudflare Turnstile — see infra, or hCaptcha as fallback)
- Submit button (disabled until CAPTCHA is completed)
- Cancel button
- On submit:
- POST /api/quotes/:id/report with { reason?, captcha_token }
- API verifies CAPTCHA server-side before creating report
- Show success message on 201
- Show error message on failure
## CAPTCHA
Use Cloudflare Turnstile (free, privacy-friendly). Site key stored as an environment variable in Trunk.toml or index.html. The API worker verifies the token via the Turnstile verify endpoint.
## Acceptance Criteria
- [ ] Report button visible on /quotes/:id
- [ ] Modal opens with reason textarea and CAPTCHA
- [ ] Character counter on reason field
- [ ] Submit disabled until CAPTCHA solved
- [ ] Correct POST request on submit
- [ ] Success and error feedback shown
- [ ] Modal closes on cancel
## Depends on
- 77237f (reports API endpoint)
## Validation
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -0,0 +1,100 @@
+++
title = "quotesdb/api: GET /api/status public endpoint"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## GET /api/status public endpoint
Add a public status endpoint that exposes whether submissions are currently locked. The UI calls this on mount for both the /submit and /admin pages.
---
## Files to modify
- `src/bin/api/db/mod.rs` — add `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait
- `src/bin/api/db/d1.rs` — implement the two new trait methods for the D1 backend; seed `submissions_locked = "0"` alongside `admin_auth_code` in the startup migration if not already present
- `src/bin/api/db/native.rs` — implement the two new trait methods for the native/SQLite backend
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/status.rs`) — add the `get_status` handler
- `src/bin/api/main.rs` — register the new route
---
## Database
The `admin_config` key/value table gains a second row. Seed it on startup (alongside `admin_auth_code`) if it does not already exist:
```sql
INSERT OR IGNORE INTO admin_config (key, value) VALUES ('submissions_locked', '0');
```
---
## New trait methods (src/bin/api/db/mod.rs)
Add to the `QuoteRepository` trait:
```rust
/// Return whether submissions are currently locked.
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
/// Set the submissions lock state.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
```
Both implementations read/write the `submissions_locked` key in `admin_config`, treating `"1"` as `true` and anything else as `false`.
---
## Handler
Add to the handlers module:
```rust
/// GET /api/status — returns current submission lock state; requires no auth.
pub async fn get_status(State(repo): State<Arc<dyn QuoteRepository>>) -> impl IntoResponse {
match repo.get_submissions_locked().await {
Ok(locked) => Json(json!({ "submissions_locked": locked })).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
```
---
## Route registration (src/bin/api/main.rs)
Register before the quotes router:
```rust
.route("/api/status", get(handlers::get_status))
```
---
## Tests
In `src/bin/api/handlers/` (or the relevant test module), add unit tests covering:
- `GET /api/status` returns `200` with `{ "submissions_locked": false }` when the DB value is `"0"`
- `GET /api/status` returns `200` with `{ "submissions_locked": true }` when the DB value is `"1"`
- `get_submissions_locked` returns `false` for missing key (graceful default)
Use a mock or in-memory SQLite repo for all handler tests.
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): GET /api/status public endpoint
```

@ -1,7 +1,28 @@
+++ +++
title = "Write ui/README.md, ui/docs/PLANNING.md, ui/docs/ARCHITECTURE.md" title = "Write ui/README.md, ui/docs/PLANNING.md, ui/docs/ARCHITECTURE.md"
priority = 3 priority = 3
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"] dependencies = ["1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
</context>
<goal>
Write the three documentation files for the UI domain:
1. `README.md` — what the UI is, how to run it (`trunk serve`), how to build (`trunk build`), license, Claude Code disclaimer
2. `docs/PLANNING.md` — development phases and work log for the UI sub-domain
3. `docs/ARCHITECTURE.md` — UI component tree overview, routing, API client, WASM compilation notes
</goal>
<constraints>
- README must include the dual Apache-2.0 + MIT license notice.
- README must include a disclaimer that the software was written with Claude Code (model: claude-sonnet-4-6).
- ARCHITECTURE.md must describe the component hierarchy and how the Yew router maps to page components.
</constraints>
<commit>
`docs(quotesdb): write ui README, PLANNING.md, and ARCHITECTURE.md`
</commit>

@ -0,0 +1,85 @@
+++
title = "Verify API worker gzipped binary size is within CF Workers free tier (3 MB limit)"
priority = 5
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5"]
+++
<context>
Resolved from TRIAGE ticket 182210. The original concern (1 MB Workers free tier limit) was based on
outdated information. The actual current limit is **3 MB after gzip** for the free tier (10 MB paid).
This ticket verifies that the API worker stays within that limit once all Cargo.toml dependencies
are pinned (ticket 1f5bb5). No structural changes are expected — the release profile and architecture
already make this highly likely.
Key facts:
- Worker size limit: Free = 3 MB (gzipped), Paid = 10 MB (gzipped)
- `Cargo.toml` release profile already has `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`
- `wrangler` applies `wasm-opt -Oz` automatically during build
- The API Worker contains only API code (no Yew/UI); UI runs on Cloudflare Pages
- Database layer uses `workers-rs` D1 bindings (not SQLx) — avoids a heavy dep
</context>
<implementation-plan>
## Step 1 — Build the release worker bundle
From the `quotesdb/` directory, after ticket 1f5bb5 has added all Cargo.toml dependencies:
```sh
wrangler deploy --outdir bundled/ --dry-run
```
This produces output like:
```
Total Upload: 523.41 KiB / gzip: 147.23 KiB
```
The `gzip:` figure is the size that counts against the 3 MB free tier limit.
## Step 2 — Evaluate the result
| Gzip size | Action |
|-----------|--------|
| < 2 MB | No action needed. Note the size in this ticket. |
| 23 MB | Note the size. Add a comment to track future dep additions carefully. |
| > 3 MB | See remediation steps below. |
## Step 3 (conditional) — Remediation if > 3 MB
Try in order:
1. **Audit dependencies** — run `cargo bloat --release --crates` to identify the largest contributors.
Remove or replace heavy crates (e.g., swap `chrono` for `time`, avoid full `tokio` features).
2. **Explicit `wasm-opt` pass** — if `wrangler` is not applying `wasm-opt` for some reason:
```sh
wasm-opt -Oz -o output.wasm input.wasm
```
3. **Workers Paid plan** — if the binary genuinely cannot be reduced below 3 MB, upgrade to the
Workers Paid plan ($5/month, 10 MB limit). Update `infra/` resources accordingly and document
the decision in `docs/ARCHITECTURE.md`.
## Step 4 — Document outcome
Record the final gzipped size in `docs/ARCHITECTURE.md` under the API section, and close this ticket.
</implementation-plan>
<validation>
```sh
# From quotesdb/ directory:
wrangler deploy --outdir bundled/ --dry-run
# Confirm "gzip: X KiB" is < 3 MB (3072 KiB)
```
</validation>
<commit>
`chore(quotesdb): verify api worker binary size within cf workers 3mb free tier limit`
</commit>

@ -0,0 +1,41 @@
+++
title = "quotesdb/ui: admin moderation tab (paginated reports, per-quote modal with delete/hide)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = ["6c5904", "cb8de0"]
+++
## Summary
Add a Moderation tab to the admin page showing a paginated list of reported quotes. Clicking a report opens a detail modal with the quote and all reports, plus action buttons.
## Tab: Moderation
- Only visible on the admin page after auth is unlocked
- Paginated list (10/page) of quotes with at least one report
- Each row shows: quote text (truncated), author, report count, most recent report date
- Clicking a row opens a Report Detail Modal
## Report Detail Modal
- Shows the full quote (text, author, source, date, tags)
- Lists all reports below: reason (or "No reason given"), reported date
- Action buttons:
- "Delete Quote" — calls DELETE /api/admin/reports/:quote_id/quote, closes modal, refreshes list
- "Hide Quote" — calls POST /api/admin/reports/:quote_id/hide, shows success, refreshes list
- "Dismiss Reports" — calls DELETE /api/admin/reports/:quote_id/reports, closes modal, refreshes list
- Close/Cancel button
## Depends on
- 6c5904 (admin moderation API endpoints)
- cb8de0 (admin auth-first flow)
## Acceptance Criteria
- [ ] Moderation tab visible on /admin after unlock
- [ ] Paginated list of reported quotes
- [ ] Report detail modal with quote + reports
- [ ] Delete, Hide, Dismiss actions work and refresh list
- [ ] Loading and error states handled
## Validation
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -1,7 +1,45 @@
+++ +++
title = "Test suite: PUT /api/quotes — create (auto auth_code, custom auth_code, missing fields 422)" title = "Test suite: PUT /api/quotes — create (auto auth_code, custom auth_code, missing fields 422)"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f", "05f8ae"] dependencies = ["9b581f", "05f8ae"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write the `PUT /api/quotes` test suite in `tests/test_create_quote.rs` (or similar). Test cases:
1. Create with auto-generated auth_code — verify 201, quote object returned, auth_code present in response
2. Create with custom auth_code in body — verify the provided code is stored and returned
3. Missing `text` field — verify 422 Unprocessable Entity
4. Missing `author` field — verify 422 Unprocessable Entity
5. Create with tags — verify tags appear in the returned quote
</goal>
<constraints>
- Use the shared test harness from ticket 9b581f.
- Auth code in the response must match the pattern `word-word-word-word`.
- Verify the created quote is retrievable via `GET /api/quotes/:id` after creation.
</constraints>
<skills>
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add PUT /api/quotes test suite — create quote`
</commit>

@ -0,0 +1,119 @@
+++
title = "Write .gitea/workflows/deploy-ui.yml — Gitea Actions workflow to build and deploy UI to Cloudflare Pages"
priority = 4
status = "done"
ticket_type = "task"
dependencies = ["ae886f", "dc3d2b"]
+++
<context>
Build strategy resolved in triage fc9bfd: pre-built artifact + Gitea Actions + `wrangler pages deploy`.
The Gitea instance at `gitea.elijah.run` runs Gitea Actions (GitHub Actions-compatible YAML). The workflow must:
1. Trigger on push to the `quotesdb` branch
2. Build the Yew/Wasm UI with `trunk build --release`
3. Deploy the `dist/` output to Cloudflare Pages via `wrangler pages deploy`
The Cloudflare Pages project (`quotesdb-ui`) is created by OpenTofu (ticket ae886f) and must exist before this workflow can successfully deploy.
</context>
<goal>
Create `.gitea/workflows/deploy-ui.yml` at the repository root (not inside `quotesdb/`).
</goal>
<implementation>
```yaml
# .gitea/workflows/deploy-ui.yml
# Builds the quotesdb Yew/Wasm UI with Trunk and deploys to Cloudflare Pages.
# Triggered on push to the quotesdb integration branch.
# Requires repository secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
name: Deploy quotesdb UI
on:
push:
branches:
- quotesdb
paths:
- "quotesdb/**"
jobs:
deploy-ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: quotesdb
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
quotesdb/target
key: ${{ runner.os }}-cargo-ui-${{ hashFiles("quotesdb/Cargo.lock") }}
restore-keys: |
${{ runner.os }}-cargo-ui-
- name: Install Trunk
run: |
curl -fsSL https://github.com/trunk-rs/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz \
| tar -xz -C ~/.cargo/bin
- name: Build UI with Trunk
run: trunk build --release
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name quotesdb-ui --branch main
```
</implementation>
<secrets>
The following repository secrets must be configured in Gitea (Settings → Secrets):
| Secret | Description |
|--------|-------------|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Pages:Edit and Account:Read permissions |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (visible in the Cloudflare dashboard URL) |
Documentation for secrets is tracked in ticket 71b1d4.
</secrets>
<notes>
- The workflow file lives at the **repository root** (`.gitea/workflows/`), not inside `quotesdb/`. Gitea Actions discovers workflows from the repo root.
- `working-directory: quotesdb` ensures all `run` steps execute from the project directory.
- `paths: ["quotesdb/**"]` limits deploys to pushes that actually change the UI project, avoiding spurious rebuilds.
- Trunk downloads the latest release binary from GitHub; pin to a specific version for reproducibility once stable.
- `wrangler-action@v3` handles `npx wrangler` invocation internally — no separate Node.js/wrangler install needed.
- `--branch main` tells Pages this deployment is for the production branch (matches `production_branch = "quotesdb"` in OpenTofu — adjust if Pages branch naming differs).
</notes>
<constraints>
- The Cloudflare Pages project (`quotesdb-ui`) must already exist (created by OpenTofu ticket ae886f) before the first deploy succeeds.
- `trunk build --release` must succeed locally before this workflow is useful; verify with `trunk build` first.
- Do not commit `CLOUDFLARE_API_TOKEN` or any secrets to the repository.
</constraints>
<validation>
After creating the workflow file:
1. Push to the `quotesdb` branch
2. Confirm the Gitea Actions run succeeds (Actions tab in Gitea UI)
3. Confirm the deployment appears in the Cloudflare Pages dashboard under `quotesdb-ui`
</validation>
<commit>
`ci(quotesdb): add Gitea Actions workflow to build and deploy UI to Cloudflare Pages`
</commit>

@ -0,0 +1,114 @@
+++
title = "Implement auth code session storage — utility module and AuthModal pre-fill integration"
priority = 7
status = "done"
ticket_type = "task"
dependencies = []
+++
<context>
Resolved from TRIAGE ticket 0bc655. The auth code (4-word passphrase) that authorises edit and
delete operations must be available to the UI without forcing the user to re-enter it on every
interaction within a browsing session.
Chosen strategy: **session storage per quote ID**. The code is stored in the browser's
`sessionStorage` under the key `auth_code_{id}` when first entered. It is automatically cleared
when the tab closes. No explicit clear-on-delete is required (session storage is short-lived by
design), but it is good practice and should be included.
Options considered:
- localStorage: ruled out — indefinite persistence is unnecessary; the app tells users to store
the code externally anyway, and localStorage has a wider XSS exposure window.
- Component state only: ruled out — code is lost on any page navigation or reload, making the
edit/delete flow unusable in practice.
</context>
<goal>
**Part 1 — Storage utility (`src/bin/ui/storage.rs`)**
Create a module with three public functions that wrap the browser's `sessionStorage` API:
```rust
use web_sys::window;
/// Retrieve the stored auth code for a given quote ID, if any.
pub fn get_auth_code(quote_id: &str) -> Option<String> {
let storage = window()?.session_storage().ok()??;
storage.get_item(&format!("auth_code_{quote_id}")).ok()?
}
/// Persist the auth code for a quote ID in sessionStorage.
pub fn set_auth_code(quote_id: &str, code: &str) {
if let Some(Ok(storage)) = window().map(|w| w.session_storage()) {
if let Some(storage) = storage {
let _ = storage.set_item(&format!("auth_code_{quote_id}"), code);
}
}
}
/// Remove the auth code for a quote ID from sessionStorage (call after DELETE).
pub fn clear_auth_code(quote_id: &str) {
if let Some(Ok(storage)) = window().map(|w| w.session_storage()) {
if let Some(storage) = storage {
let _ = storage.remove_item(&format!("auth_code_{quote_id}"));
}
}
}
```
Expose this module from the UI binary root: add `mod storage;` to `src/bin/ui/main.rs`.
**Part 2 — AuthModal pre-fill**
Update the `AuthModal` component (ticket f850c6) to accept an `initial_value: Option<String>`
prop. Pre-populate the `<input>` value from this prop when the modal opens. The parent
component is responsible for reading from storage and passing the value in.
```rust
#[derive(Properties, PartialEq)]
pub struct AuthModalProps {
pub on_submit: Callback<String>,
pub on_cancel: Callback<()>,
pub initial_value: Option<String>, // pre-fill if auth code is already stored
}
```
**Part 3 — SingleQuotePage integration**
In the SingleQuotePage (or whichever component renders edit/delete for a quote), integrate
storage around the `AuthModal`:
- Before opening the modal: read `storage::get_auth_code(&quote.id)` and pass it as
`initial_value` to `AuthModal`.
- After a successful **edit** (POST /api/quotes/:id returns 200): call
`storage::set_auth_code(&quote.id, &submitted_code)`.
- After a successful **delete** (DELETE /api/quotes/:id returns 204): call
`storage::clear_auth_code(&quote.id)`.
- If the API returns 403 (wrong code): do NOT store the code; clear any existing stored value
with `storage::clear_auth_code(&quote.id)` so a stale code is not re-offered.
</goal>
<constraints>
- The storage utility must compile only for `wasm32-unknown-unknown``web_sys::window()` is
not available on the host target. Gate the module under `#[cfg(target_arch = "wasm32")]` or
ensure it is only imported by the `ui` binary, which is always compiled for wasm32.
- `web_sys` must be available with the `Window`, `Storage` features — confirm these are included
in the `web_sys` dependency in `Cargo.toml` (ticket 93515e covers UI Cargo.toml setup).
- Do NOT use `gloo-storage` — it wraps localStorage by default and the API difference matters.
Use `web_sys` directly as shown above.
- The key pattern is `auth_code_{quote_id}` (underscore separator, not slash or dot).
- Session storage is tab-scoped: no cross-tab contamination is possible — no additional
scoping by domain or user is needed.
</constraints>
<validation>
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement auth code session storage utility and AuthModal pre-fill`
</commit>
<domain>quotesdb/ui</domain>

@ -0,0 +1,124 @@
+++
title = "Write .gitea/workflows/deploy-api.yml — Gitea Actions workflow to build and deploy API Worker via OpenTofu"
priority = 4
status = "done"
ticket_type = "task"
dependencies = ["a23489", "2d1371"]
+++
<context>
The API Worker is a workers-rs Wasm binary deployed to Cloudflare Workers. The OpenTofu resource (`infra/worker.tf`) reads the compiled Wasm via `filebase64("../target/wasm32-unknown-unknown/release/api.wasm")` and uploads it on `tofu apply`. This means the CI workflow must compile the Wasm before running `tofu apply`.
Counterpart to ticket 5137d7 (UI deploy via wrangler pages deploy).
</context>
<goal>
Create `.gitea/workflows/deploy-api.yml` at the repository root. The workflow must:
1. Compile the `api` binary for `wasm32-unknown-unknown`
2. Run `tofu apply` from `quotesdb/infra/` to upload the Worker and provision/update all infra
Triggered on push to `quotesdb` branch when files under `quotesdb/src/bin/api/` or `quotesdb/infra/` change.
</goal>
<implementation>
```yaml
# .gitea/workflows/deploy-api.yml
# Builds the quotesdb API Worker Wasm binary and applies OpenTofu infra.
# Triggered on push to the quotesdb integration branch when API or infra files change.
# Requires repository secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, TF_STATE_* (if using remote state).
name: Deploy quotesdb API
on:
push:
branches:
- quotesdb
paths:
- "quotesdb/src/bin/api/**"
- "quotesdb/src/lib.rs"
- "quotesdb/infra/**"
- "quotesdb/Cargo.toml"
- "quotesdb/Cargo.lock"
jobs:
deploy-api:
runs-on: ubuntu-latest
defaults:
run:
working-directory: quotesdb
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
quotesdb/target
key: ${{ runner.os }}-cargo-api-${{ hashFiles("quotesdb/Cargo.lock") }}
restore-keys: |
${{ runner.os }}-cargo-api-
- name: Build API Worker Wasm binary
run: cargo build --release --target wasm32-unknown-unknown --bin api
- name: Install OpenTofu
uses: opentofu/setup-opentofu@v1
- name: OpenTofu init
working-directory: quotesdb/infra
run: tofu init
- name: OpenTofu apply
working-directory: quotesdb/infra
run: tofu apply -auto-approve
```
</implementation>
<secrets>
The following repository secrets must be configured in Gitea (Settings → Secrets):
| Secret | Description |
|--------|-------------|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Workers:Edit, D1:Edit, Account:Read permissions |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID |
Remote state credentials (if applicable) — see ticket 71b1d4.
</secrets>
<notes>
- `opentofu/setup-opentofu@v1` is the official GitHub/Gitea Action for OpenTofu installation.
- The `env:` block at job level makes credentials available to both `tofu init` and `tofu apply` via the Cloudflare provider environment variable convention.
- The Wasm binary at `target/wasm32-unknown-unknown/release/api.wasm` is read by `filebase64()` in `infra/worker.tf` at apply time — the file must exist before `tofu apply` runs.
- `tofu apply -auto-approve` is safe in CI because the plan is deterministic and the repo is the source of truth.
- OpenTofu state: the `infra/` directory needs a configured backend. If using local state, the state file must be committed or a remote backend (e.g. Cloudflare R2) configured. See ticket 2d1371.
- The `paths` filter ensures the workflow only triggers when API code or infra config changes, avoiding spurious runs on UI-only pushes.
</notes>
<constraints>
- The Cloudflare infra (D1, Worker script resource) must be defined (ticket a23489, d0da0b) and `infra/` must be initialised (ticket 2d1371) before this workflow is useful.
- Do not commit Cloudflare credentials or OpenTofu state files containing secrets.
</constraints>
<validation>
After creating the workflow file:
1. Push to the `quotesdb` branch with a change to `src/bin/api/`
2. Confirm the Gitea Actions run succeeds
3. Confirm the Worker appears/updates in the Cloudflare Workers dashboard
</validation>
<commit>
`ci(quotesdb): add Gitea Actions workflow to build and deploy API Worker via OpenTofu`
</commit>

@ -1,7 +1,46 @@
+++ +++
title = "[TRIAGE] Database migration strategy for Cloudflare Workers (startup vs wrangler d1 execute)" title = "[TRIAGE] Database migration strategy for Cloudflare Workers (startup vs wrangler d1 execute)"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Database migration strategy for Cloudflare Workers: how should the `quotes` and `quote_tags` tables be created? Workers do not have a persistent startup phase like a long-running server.
</question>
<options>
1. **Startup migration** — run `CREATE TABLE IF NOT EXISTS` in the Worker fetch handler before processing the first request. Simple but adds latency to the first request.
2. **`wrangler d1 execute`** — apply the schema separately using the wrangler CLI. No runtime overhead but requires a separate CI step.
3. **SQLx migrate! macro** — embed migrations in the binary and run them at startup. Depends on SQLx compatibility with workers-rs (see TRIAGE e8a330).
</options>
<decision>
**Option 2: `wrangler d1 execute` as a separate CI/CD step.**
- Option 3 (SQLx) is ruled out — TRIAGE e8a330 established that SQLx is incompatible with workers-rs/D1.
- Option 1 (startup migration from the Workers handler) is impractical: Workers spin up per-request via V8 isolates. Running DDL before every request adds latency and is fragile.
- Option 2 is the canonical Cloudflare-recommended approach. It is idempotent (`CREATE TABLE IF NOT EXISTS`), keeps the Workers handler free of DDL overhead, and integrates cleanly into CI/CD as a post-`tofu apply` step.
**Production:** `wrangler d1 execute quotesdb --file infra/schema.sql --remote` — run once after first `tofu apply`, and again for each incremental migration file.
**Local dev / tests:** `NativeRepository::run_migrations()` (ticket 00aff0) runs `execute_batch` via rusqlite on native startup. No manual wrangler step needed.
This decision is co-resolved with TRIAGE 5c0c64, which asked the same question from the OpenTofu angle. Both arrive at the same answer.
</decision>
<resolution>
- Co-resolved with TRIAGE 5c0c64.
- Ticket a5049d updated with chosen strategy.
- Ticket bb1514 created: implementation plan for `infra/schema.sql`.
- Ticket 75489a updated: documents the wrangler workflow.
</resolution>
<commit>
`chore(quotesdb): resolve triage — database-migration-strategy-for-cloudflare-workers-startup-v`
</commit>

@ -0,0 +1,83 @@
+++
title = "Submit form: author optional (default Anonymous), clarify auth code auto-generation"
priority = 5
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
Two related UX issues on the submit form (`src/bin/ui/pages/submit.rs`):
### 1. Author field should be optional
Currently, `author` is required — the validation at line 53 returns early with `"Text and author are required."` if it is empty, and the field has `required=true` at line 177. Not all quotes have a known author, so it should be optional. When left blank, the API should receive `"Anonymous"` as the author.
### 2. Auth code label should clarify auto-generation
The label currently reads `"Custom auth code (optional)"`. The label itself does not explain what happens if left blank. The placeholder does say `"word-word-word-word (auto-generated if empty)"`, but users may not notice placeholders. The label should make it explicit.
## Expected behaviour
- Author field: leaving it blank submits the quote with author = `"Anonymous"`. The field should not block form submission.
- Auth code label: reads something like `"Custom auth code (optional — one will be generated if left blank)"`.
## How to fix
In `src/bin/ui/pages/submit.rs`:
**Author optional:**
1. Change the validation at line 53:
```rust
// Before
if text_val.is_empty() || author_val.is_empty() {
error.set(Some("Text and author are required.".to_string()));
return;
}
// After
if text_val.is_empty() {
error.set(Some("Quote text is required.".to_string()));
return;
}
```
2. When building `CreateQuoteInput`, default the author to `"Anonymous"` if empty:
```rust
author: if author_val.is_empty() {
"Anonymous".to_string()
} else {
author_val
},
```
3. Remove `required=true` from the author `<input>` (~line 177).
4. Update the label from `"Author *"` to `"Author (optional, defaults to Anonymous)"` or similar.
5. Update the placeholder from `"e.g. Mark Twain"` to `"e.g. Mark Twain (leave blank for Anonymous)"`.
**Auth code label:**
6. Change the label at line 236 from:
```
"Custom auth code (optional)"
```
to:
```
"Auth code (optional — one will be generated if left blank)"
```
## File
- `src/bin/ui/pages/submit.rs` (lines 5356, 6466, 163178, 235237)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually test: submit a quote with author left blank — it should succeed and display `Anonymous` as the author.
## Commit scope
`fix(quotesdb): submit form author optional and auth code label`

@ -1,7 +1,49 @@
+++ +++
title = "[TRIAGE] D1 migrations in OpenTofu — null_resource local-exec vs separate wrangler step vs manual" title = "[TRIAGE] D1 migrations in OpenTofu — null_resource local-exec vs separate wrangler step vs manual"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
D1 migrations in OpenTofu: how do we apply the SQL schema to a newly created D1 database? Options are a null_resource local-exec in OpenTofu, a separate wrangler d1 execute step, or a manual migration step.
</question>
<options>
1. **null_resource local-exec** — run `wrangler d1 execute` as a provisioner in OpenTofu. Ties infra and schema together in one `tofu apply`.
2. **Separate wrangler step** — document as a manual step after `tofu apply`. Simpler OpenTofu config, slightly more manual.
3. **API startup migration** — the API runs `CREATE TABLE IF NOT EXISTS` on startup. Works but risks schema drift in production.
</options>
<decision>
**Option 2: Separate wrangler step.**
- **null_resource local-exec rejected:** `null_resource` provisioners are an OpenTofu anti-pattern. They don't re-run unless tainted, aren't tracked in state, require wrangler installed on the CI runner at `tofu apply` time, and break idempotency. The convenience of a single command is not worth the coupling.
- **API startup migration rejected:** Cloudflare Workers spin up per-request via V8 isolates. There is no persistent startup phase. Running DDL (`CREATE TABLE IF NOT EXISTS`) before every request adds latency and is fragile. The Workers fetch handler (D1Repository, wasm32 path) does NOT run migrations. This is only viable for the native/local dev path (rusqlite), where `NativeRepository::run_migrations()` is called once at `main()` startup.
- **Separate wrangler step chosen:** This is Cloudflare's canonical approach. The schema SQL lives at `infra/schema.sql` (ticket bb1514). After `tofu apply`, run once:
```sh
wrangler d1 execute quotesdb --file infra/schema.sql --remote
```
Idempotent with `CREATE TABLE IF NOT EXISTS`. Integrates cleanly into CI/CD as a post-apply step. Keeps OpenTofu focused on infrastructure, not data.
**Note:** TRIAGE 580e66 asks the same question from the Workers runtime angle and arrives at the same answer. Both are now resolved together.
</decision>
<resolution>
- Ticket d0da0b updated: constraint clarified (wrangler step, no null_resource).
- Ticket a5049d updated: migration strategy constraint updated (580e66 also resolved).
- Ticket 75489a updated: dependency on bb1514 added; goal updated to reference infra/schema.sql.
- Ticket 580e66 resolved as co-decided.
- New ticket bb1514 created: full implementation plan for `infra/schema.sql`.
</resolution>
<commit>
`chore(quotesdb): resolve triage — d1-migrations-in-opentofu-nullresource-localexec-vs-separate`
</commit>

@ -1,7 +1,41 @@
+++ +++
title = "Implement Browse page (/browse) — paginated quote list with author/tag filter controls" title = "Implement Browse page (/browse) — paginated quote list with author/tag filter controls"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"] dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The Browse page displays a paginated list of all quotes with optional author and tag filters.
</context>
<goal>
Implement the Browse page component (`src/bin/ui/pages/browse.rs`):
1. Read `?page`, `?author`, `?tag` from the URL query string
2. Fetch quotes from `GET /api/quotes` with the query parameters
3. Render each quote with the `QuoteCard` component
4. Render the `Pagination` component with prev/next navigation (update URL query params on page change)
5. Render the `TagFilter` component and an author text input for filtering
6. Render `ErrorDisplay` on error
</goal>
<constraints>
- URL query parameters are the source of truth for current page and filters — use yew-router location hooks to read/write them.
- Changing a filter should reset to page 1.
- The author filter is a free-text input (case-insensitive match on the API side).
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement Browse page — paginated list with filters`
</commit>

@ -1,7 +1,52 @@
+++ +++
title = "Implement POST /api/quotes/:id — partial update, verify X-Auth-Code header, update updated_at" title = "Implement POST /api/quotes/:id — partial update, verify X-Auth-Code header, update updated_at"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] dependencies = ["a5049d", "d792e2", "175382"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
`POST /api/quotes/:id` performs a partial update of a quote. The caller must provide the correct auth code via the `X-Auth-Code` request header. Only fields present in the request body are updated; absent fields are left unchanged. Optional fields (`source`, `date`) can be explicitly set to `null` to clear them.
</context>
<goal>
Implement the `POST /api/quotes/:id` handler:
1. Extract `:id` from the path
2. Verify the `X-Auth-Code` header matches the stored `auth_code` — return 403 on mismatch
3. Apply a partial UPDATE to the `quotes` row (only update supplied fields)
4. Update `updated_at` timestamp
5. If `tags` is present in the body, replace all tags for the quote
6. Return 200 with the updated quote
</goal>
<constraints>
- Return 404 if the quote ID does not exist.
- Return 403 (not 401) on auth code mismatch; do not reveal whether the ID exists to unauthenticated callers.
- Setting a field to `null` in the request body should clear it (for `source` and `date`).
- `updated_at` must be set to `CURRENT_TIMESTAMP` on every update.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write tests for: valid auth 200, wrong auth 403, not found 404, partial update, null-to-clear.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement POST /api/quotes/:id — partial update with auth verification`
</commit>

@ -1,7 +1,45 @@
+++ +++
title = "Implement GET /api/quotes/:id — fetch by NanoID, return 404 if not found" title = "Implement GET /api/quotes/:id — fetch by NanoID, return 404 if not found"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] dependencies = ["a5049d", "d792e2", "175382"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
`GET /api/quotes/:id` returns a single quote by its NanoID. Returns 404 if no quote with that ID exists.
</context>
<goal>
Implement the `GET /api/quotes/:id` handler that looks up a quote by NanoID, fetches its tags, and returns the full quote JSON. Return 404 if the ID is not found.
</goal>
<constraints>
- Extract the `:id` path parameter using Axum's `Path` extractor.
- Include the quote's tags in the response.
- Return `404 Not Found` with `{"error": "not found"}` if the ID doesn't match any row.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write tests for: 200 with quote object, 404 not found.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement GET /api/quotes/:id`
</commit>

@ -1,7 +1,47 @@
+++ +++
title = "[TRIAGE] CSS/styling approach for Wasm — plain CSS, CDN Tailwind, or Wasm-compatible crate?" title = "[TRIAGE] CSS/styling approach for Wasm — plain CSS, CDN Tailwind, or Wasm-compatible crate?"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Tailwind (loaded in index.html), or a Wasm-compatible Rust styling crate?
</question>
<options>
1. **Plain CSS** — write a `style.css` file, include it in `index.html`. No build complexity. Simple and portable.
2. **CDN Tailwind** — add Tailwind CDN `<script>` to `index.html`. No build step needed. Larger page load; fine for small apps.
3. **Stylist or yew-style** — Rust crates for CSS-in-Wasm. More idiomatic but less documentation.
</options>
<decision>
**Plain CSS** — Option 1.
Rationale:
- CDN Tailwind's JIT scanner reads DOM/HTML for class names to generate utility CSS on-the-fly. In a Yew Wasm app, class names are Rust strings compiled into the Wasm binary — they are never present in the HTML that Tailwind's scanner reads. The result is a non-functional Tailwind build with no utility classes.
- Stylist/yew-style add a Wasm dependency, sparse documentation, and binary bloat for a 5-page app where co-location of styles provides no real benefit.
- Plain CSS + Trunk: Trunk natively bundles CSS via `<link data-trunk rel="css" href="src/bin/ui/style.css"/>` in `index.html`. Zero additional dependencies, no build complexity, easy maintainability.
Implementation:
- CSS file: `src/bin/ui/style.css` (Trunk discovers files relative to `index.html`)
- index.html link: `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`
- Naming convention: BEM-style semantic names — `quote-card`, `quote-card__text`, `quote-card__author`, `page-browse`, etc.
- Yew usage: `class={"quote-card"}` or `classes!["quote-card", conditional]`
- Dedicated implementation ticket: 0fbdd5
</decision>
<resolution>
1. Research the options above and choose the best approach for this project.
2. Update ticket dc3d2b (Trunk.toml + index.html) and all UI component tickets with the chosen CSS class strategy.
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
</resolution>
<commit>
`chore(quotesdb): resolve triage — css-styling-approach-plain-css`
</commit>

@ -1,7 +1,42 @@
+++ +++
title = "Implement Quote detail page (/quotes/:id) — view, edit form with auth prompt, delete with auth prompt" title = "Implement Quote detail page (/quotes/:id) — view, edit form with auth prompt, delete with auth prompt"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "f850c6", "fc2f51"] dependencies = ["04f865", "1e6a09", "0d987f", "f850c6", "fc2f51", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The Quote Detail page (`/quotes/:id`) shows a single quote. It also provides edit and delete actions, each guarded by the `AuthModal` component that prompts for the auth code.
</context>
<goal>
Implement the Quote Detail page component (`src/bin/ui/pages/quote_detail.rs`):
1. Extract `:id` from the route
2. Fetch the quote from `GET /api/quotes/:id`
3. Render the quote with `QuoteCard`
4. Render Edit and Delete buttons
5. Edit: show `AuthModal`, then show an edit form pre-filled with current values; on submit call `POST /api/quotes/:id`
6. Delete: show `AuthModal`, then call `DELETE /api/quotes/:id`; on success navigate to `/`
7. Show 403 error message on wrong auth code
</goal>
<constraints>
- 404 from the API should display a user-friendly "quote not found" message.
- After successful edit, re-fetch the quote to show updated data.
- After successful delete, navigate to `/browse`.
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement Quote Detail page — view, edit, delete with auth`
</commit>

@ -1,7 +1,55 @@
+++ +++
title = "Set up tests/Cargo.toml with integration test dependencies (reqwest/hyper, tokio, serde_json)" title = "Set up tests/Cargo.toml with integration test dependencies (reqwest/hyper, tokio, serde_json)"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "0d84fa"] dependencies = ["0d84fa", "fba598"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment.
Triage decisions:
- 0d84fa: HTTP client → `reqwest` with `tokio::test`
- fba598: Isolation strategy → per-test temp SQLite file via `tempfile` crate
</context>
<goal>
Add integration test dev-dependencies to `Cargo.toml` under `[dev-dependencies]`:
```toml
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
tempfile = "3"
```
Confirm `cargo check` passes after adding these.
</goal>
<constraints>
- Dev-dependencies do not need WASM compatibility — they are host-only.
- Use `#[tokio::test]` for async test functions.
- `tempfile` is required by the test harness isolation strategy (ticket fba598 / 9b581f).
- `reqwest` must include the `json` feature for `.json()` request body and `.json::<T>()` response deserialization.
</constraints>
<skills>
Use `superpowers:verification-before-completion` — run `cargo check` after adding deps to confirm they resolve.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`chore(quotesdb): add integration test dev-dependencies (reqwest, tokio, serde_json, tempfile)`
</commit>

@ -1,7 +1,39 @@
+++ +++
title = "Configure custom domain quotes.elijah.run → Cloudflare Pages (DNS record + Pages domain binding)" title = "Configure custom domain quotes.elijah.run → Cloudflare Pages (DNS record + Pages domain binding)"
priority = 6 priority = 6
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "ae886f"] dependencies = ["ae886f"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
The frontend is served at the custom domain `quotes.elijah.run`. This requires a DNS record pointing to Cloudflare Pages and a custom domain binding on the Pages project.
</context>
<goal>
Configure the custom domain in `infra/pages.tf` (or `infra/dns.tf`):
1. `cloudflare_pages_domain` resource — binds `quotes.elijah.run` to the Pages project
2. `cloudflare_record` resource — DNS CNAME record pointing `quotes` → the Pages `*.pages.dev` domain
Every block must have a comment.
</goal>
<constraints>
- The Cloudflare zone ID for `elijah.run` must be provided as a variable or looked up via a `data` source.
- SSL is handled automatically by Cloudflare — no certificate resources needed.
</constraints>
<validation>
Run from the `infra/` directory:
```sh
tofu validate
tofu plan
```
</validation>
<commit>
`feat(quotesdb): configure custom domain quotes.elijah.run for Cloudflare Pages`
</commit>

@ -0,0 +1,45 @@
+++
title = "Add workers-rs WASM entry point to api binary"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Goal
Enable `cargo build --release --bin api --target wasm32-unknown-unknown` so the api binary deploys as a Cloudflare Worker (see `infra/worker.tf`).
## Changes Required
### 1. `Cargo.toml`
Add axum to `[target.'cfg(target_arch = "wasm32")'.dependencies]` with tokio disabled:
```toml
axum = { version = "0.8", default-features = false, features = ["json"] }
```
All axum types used in handlers (Router, Path, Query, State, Json, etc.) are available without the tokio feature.
### 2. `src/bin/api/main.rs`
- Wrap existing native code (`mod handlers;`, `#[tokio::main] async fn main()`) in `#[cfg(not(target_arch = "wasm32"))]`
- Add `#[cfg(target_arch = "wasm32")] mod handlers;` (no change to handlers themselves)
- Add `#[event(fetch)]` workers-rs entry point that:
1. Gets D1 binding from env: `env.d1("DB")`
2. Creates `D1Repository::new(db)` — see companion ticket for D1 implementation
3. Calls `repo.run_migrations()`
4. Wraps repo in `Arc<dyn QuoteRepository + Send + Sync>`
5. Builds Axum router via existing `handlers::router(repo)`
6. Converts `worker::Request``http::Request<axum::body::Body>` (method, uri, headers, body bytes)
7. Calls router via `tower_service::Service::call()`
8. Converts `http::Response<axum::body::Body>``worker::Response` (status, headers, body bytes)
`tower_service` is already a transitive dep of axum.
## Dependencies
- Companion ticket for D1Repository implementation must be done first (or in parallel).
D1Repository must have `unsafe impl Send` and `unsafe impl Sync` for the Arc<dyn ... + Send + Sync> wrapper to work.
## Validation
```sh
cargo build --release --bin api --target wasm32-unknown-unknown
cargo build --release --bin api # native must still work
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -0,0 +1,121 @@
+++
title = "quotesdb/api: POST /api/admin/reset-auth-code endpoint"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## POST /api/admin/reset-auth-code endpoint
Add the admin-protected endpoint that replaces the stored admin auth code. The caller must supply the current code via `X-Admin-Code`. A new code may be provided in the request body; if omitted, the server generates a fresh 4-word passphrase.
---
## Files to modify
- `src/bin/api/db/mod.rs` — add `update_admin_auth_code` to the `QuoteRepository` trait
- `src/bin/api/db/d1.rs` — implement `update_admin_auth_code` for D1
- `src/bin/api/db/native.rs` — implement `update_admin_auth_code` for native SQLite
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/admin.rs`) — add the `reset_auth_code` handler
- `src/bin/api/main.rs` — register the new route
---
## New trait method (src/bin/api/db/mod.rs)
Add to the `QuoteRepository` trait:
```rust
/// Replace the admin auth code if `current` matches the stored value.
/// If `new_code` is `None`, generates a fresh 4-word passphrase.
/// Returns the new auth code string on success, or `DbError::Unauthorized`
/// if `current` does not match.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError>;
```
Implementation steps:
1. Fetch the stored `admin_auth_code` from `admin_config`.
2. If it does not match `current`, return `DbError::Unauthorized` (or a dedicated variant).
3. Determine the new code: use `new_code` if provided, otherwise call the existing passphrase-generation utility.
4. Write the new value to `admin_config` with `UPDATE`.
5. Return the new code string.
---
## Request / response types
```rust
#[derive(Deserialize)]
struct ResetAuthCodeRequest {
new_code: Option<String>,
}
#[derive(Serialize)]
struct ResetAuthCodeResponse {
auth_code: String,
}
```
---
## Handler
```rust
/// POST /api/admin/reset-auth-code
/// Requires X-Admin-Code header matching the stored admin passphrase.
/// Body: { "new_code": "optional-string" }
/// Response: 200 { "auth_code": "new-code" } or 403 on mismatch.
pub async fn reset_auth_code(
State(repo): State<Arc<dyn QuoteRepository>>,
headers: HeaderMap,
Json(payload): Json<ResetAuthCodeRequest>,
) -> impl IntoResponse {
let admin_code = match headers.get("x-admin-code").and_then(|v| v.to_str().ok()) {
Some(c) => c.to_owned(),
None => return StatusCode::FORBIDDEN.into_response(),
};
match repo.update_admin_auth_code(&admin_code, payload.new_code.as_deref()).await {
Ok(new_code) => Json(ResetAuthCodeResponse { auth_code: new_code }).into_response(),
Err(DbError::Unauthorized) => StatusCode::FORBIDDEN.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
```
---
## Route registration (src/bin/api/main.rs)
```rust
.route("/api/admin/reset-auth-code", post(handlers::reset_auth_code))
```
---
## Tests
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no body `new_code``200`, response contains a non-empty `auth_code`
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and explicit `new_code``200`, `auth_code` equals the supplied value
- `POST /api/admin/reset-auth-code` with wrong `X-Admin-Code``403`
- `POST /api/admin/reset-auth-code` with missing `X-Admin-Code` header → `403`
- After a successful reset, subsequent calls with the old code return `403` and with the new code return `200`
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): POST /api/admin/reset-auth-code endpoint
```

@ -0,0 +1,64 @@
+++
title = "quotesdb/api: DB layer — add submissions_locked + update_admin_auth_code"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Goal
Extend the DB abstraction layer with three new trait methods and seed on startup.
## New trait methods (add to `src/bin/api/db/mod.rs`)
```rust
/// Replace the admin auth code if `current` matches.
/// If `new_code` is None, generates a fresh 4-word passphrase.
/// Returns the new auth code on success.
/// Returns Err(DbError::Forbidden) if `current` does not match.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError>;
/// Return whether submissions are currently locked.
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
/// Persist the submissions lock state.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
```
## Implementations
Implement in both:
- `src/bin/api/db/native.rs` (NativeRepository — rusqlite)
- `src/bin/api/db/d1.rs` (D1Repository — Cloudflare Workers WASM)
## Seeding (startup)
In `src/bin/api/main.rs` (both native and wasm32 paths), after seeding
`admin_auth_code`, also seed `submissions_locked = '0'` using
`INSERT OR IGNORE` (use `set_submissions_locked` only when the key is absent,
or add a dedicated `seed_submissions_locked` helper).
## Testing
Add unit/integration tests in `src/bin/api/handlers/mod.rs` test module
or `tests/` covering:
- get_submissions_locked returns false by default
- set_submissions_locked(true) then get_submissions_locked returns true
- update_admin_auth_code with correct current succeeds
- update_admin_auth_code with wrong current returns Forbidden
## Validation
Run from `quotesdb/`:
```
cargo fmt && cargo check && cargo clippy && cargo test
```
## Commit scope
`feat(quotesdb): ...`
## Design reference
`docs/plans/2026-03-04-admin-features-design.md`

@ -0,0 +1,75 @@
+++
title = "Submit form: remove 'Submit another' link from success screen"
priority = 5
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
After successfully submitting a quote, the success screen (`src/bin/ui/pages/submit.rs`, lines 111133) offers two actions:
1. "View your quote" (primary button → `/quotes/:id`)
2. "Submit another" (secondary button → `/submit`)
The "Submit another" link pushes users toward submitting more quotes immediately. This is undesirable — the success screen should celebrate the submission and direct the user to view what they just created or browse the collection, not encourage repeat submissions.
## Expected behaviour
Remove the "Submit another" link from the success screen. Replace it with a "Browse all quotes" link (`/browse`) so the user has a natural next step that doesn't push them back to the submit form.
Final success screen actions:
1. "View your quote" (primary, `/quotes/:id`)
2. "Browse all quotes" (secondary, `/browse`)
## How to fix
In `src/bin/ui/pages/submit.rs`, in the success render block (lines 120130):
```rust
// Before
<div class="page-submit__actions">
<Link<Route>
to={Route::QuoteDetail { id: quote_id.clone() }}
classes="btn btn--primary"
>
{ "View your quote" }
</Link<Route>>
<Link<Route> to={Route::Submit} classes="btn">
{ "Submit another" }
</Link<Route>>
</div>
// After
<div class="page-submit__actions">
<Link<Route>
to={Route::QuoteDetail { id: quote_id.clone() }}
classes="btn btn--primary"
>
{ "View your quote" }
</Link<Route>>
<Link<Route> to={Route::Browse} classes="btn">
{ "Browse all quotes" }
</Link<Route>>
</div>
```
No new imports needed — `Route::Browse` is already defined.
## File
- `src/bin/ui/pages/submit.rs` (lines 120130)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually verify: after submitting a quote, the success screen shows "View your quote" and "Browse all quotes" (not "Submit another").
## Commit scope
`fix(quotesdb): remove submit-another link from success screen`

@ -0,0 +1,48 @@
+++
title = "quotesdb/api: admin moderation endpoints (list reports, delete/hide from report)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = ["8a7fba", "77237f"]
+++
## Summary
Add admin endpoints for viewing and acting on reported quotes.
## Endpoints
GET /api/admin/reports?page=N
- Returns paginated list of reported quotes (10/page)
- Each entry: quote summary + report count + most recent report date
- Requires X-Admin-Auth-Code header (admin auth)
- Returns 403 on auth mismatch
GET /api/admin/reports/:quote_id
- Returns the full quote + all reports for that quote (id, reason, created_at)
- Requires X-Admin-Auth-Code header
DELETE /api/admin/reports/:quote_id/quote
- Deletes the quote (cascades to reports via FK)
- Requires X-Admin-Auth-Code header
POST /api/admin/reports/:quote_id/hide
- Sets hidden=1 on the quote
- Requires X-Admin-Auth-Code header
DELETE /api/admin/reports/:quote_id/reports
- Clears all reports for a quote (dismiss reports without acting on the quote)
- Requires X-Admin-Auth-Code header
## Notes
- Admin auth is validated against the admin_auth_code in the DB (same as existing admin endpoints)
- Depends on: 8a7fba (hidden flag), 77237f (reports table)
## Acceptance Criteria
- [ ] All endpoints return correct data
- [ ] All endpoints require and validate admin auth
- [ ] Pagination works for GET /api/admin/reports
- [ ] Delete cascades correctly
- [ ] Unit tests for each endpoint
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -1,7 +1,43 @@
+++ +++
title = "Set up api/src/main.rs — Cloudflare Workers entry point and Axum router wiring" title = "Set up api/src/main.rs — Cloudflare Workers entry point and Axum router wiring"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "1f5bb5"] dependencies = ["1f5bb5"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
</context>
<goal>
Implement `src/bin/api/main.rs` — the Cloudflare Workers entry point and Axum router wiring. Set up the router with all seven API routes in the correct order (`GET /api/quotes/random` before `GET /api/quotes/:id`), connect the SQLx database pool, and wire in the workers-rs event handler.
</goal>
<constraints>
- Route registration order is critical: `GET /api/quotes/random` must be registered **before** `GET /api/quotes/:id` or the random endpoint will never match.
- Provide a `#[cfg(not(target_env = "worker"))]` conditional for running the API as a plain Axum server during local `cargo run`, alongside the workers-rs event macro for Cloudflare deployment.
- Database pool initialisation must handle both Turso (local) and D1 (worker) connection strings.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write integration test stubs in `tests/` before wiring up handlers.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): wire Axum router and workers-rs entry point in api main.rs`
</commit>

@ -1,7 +1,78 @@
+++ +++
title = "[TRIAGE] 4-word passphrase crate selection for WASM target (no_std/wasm32 constraints)" title = "[TRIAGE] 4-word passphrase crate selection for WASM target (no_std/wasm32 constraints)"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
4-word passphrase crate selection: which crate generates 4-word passphrases and compiles for wasm32-unknown-unknown without std thread-local RNG or filesystem access?
</question>
<options>
1. **passphrase-wordlist** — small crate, check WASM compatibility.
2. **bip39** — BIP-39 mnemonic words, widely available. Returns 12-word phrases by default; can take first 4 words.
3. **Custom word list** — embed a static word list in `src/lib.rs` and select 4 random words using `getrandom` with the `js` feature for WASM-compatible randomness.
</options>
<resolution>
1. Research the options above and choose the best approach for this project.
2. Update ticket 03bb91 (auth_code generator) and `Cargo.toml` (ticket 1f5bb5) with the chosen crate.
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
</resolution>
<decision>
## Chosen: Option 3 — Custom embedded word list + `rand 0.10` + `getrandom 0.4` (wasm_js)
### Research findings
| Option | Verdict |
|---|---|
| `passphrase-wordlist` | **Does not exist** on crates.io. Eliminated. |
| `bip39` | WASM-compatible (8.8M downloads, used in web crypto wallets). Rejected: carries BIP-39 cryptocurrency semantics; word list (2048 entries) is tuned for phonetic distinctness, not general memorability; introduces unnecessary complexity. |
| Custom word list | **Chosen.** Minimal deps, full control, idiomatic Rust. |
### Implementation approach
**Word list:** EFF Short Word List 1 — 1296 common English words designed for memorable passphrases.
Source: `https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt`
Generate the Rust array: `curl -s <url> | awk '{print $2}'`
**RNG:** `rand::rngs::OsRng` from `rand = "0.10"`.
- Does NOT use thread-local storage (safe for wasm32)
- Uses `getrandom` as its entropy backend
- For WASM targets: add `getrandom = { version = "0.4", features = ["wasm_js"] }` in the wasm32 cfg section so Cloudflare Workers (which expose `crypto.getRandomValues()`) can seed the RNG
**Version note:** `rand 0.10` requires `getrandom ^0.4`. The `wasm_js` feature in `getrandom 0.4` replaces the old `js` feature from `getrandom 0.2`. The latest `uuid 1.21.0` also requires `getrandom ^0.4`, so both deps share one getrandom version in the dependency graph.
### Cargo.toml changes (update ticket 1f5bb5)
```toml
[dependencies]
rand = "0.10"
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues())
# Required by both rand (OsRng) and uuid (v4) on wasm32 targets
getrandom = { version = "0.4", features = ["wasm_js"] }
```
**Correction:** Tickets 7a0d9f and 1f5bb5 previously referenced `getrandom = "0.2", features = ["js"]` — this is outdated. uuid 1.21 and rand 0.10 both require getrandom ^0.4, which renamed the feature to `wasm_js`. Both those tickets have been updated.
### Updated tickets
- **03bb91** — updated with full implementation plan (code, word list steps, tests)
- **1f5bb5** — corrected getrandom version to 0.4/wasm_js; added rand 0.10 dep
- **7a0d9f** — corrected getrandom version to 0.4/wasm_js (was 0.2/js)
</decision>
<commit>
`chore(quotesdb): resolve triage — 4word-passphrase-crate-selection-for-wasm-target-nostdwasm32`
</commit>

@ -1,7 +1,53 @@
+++ +++
title = "[TRIAGE] NanoID crate WASM compatibility with workers-rs target" title = "[TRIAGE] NanoID crate WASM compatibility with workers-rs target"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
NanoID crate WASM compatibility: does the chosen nanoid crate compile for wasm32-unknown-unknown with the workers-rs target? Some crates use thread-local RNG which is not available in WASM.
</question>
<options>
1. **nanoid crate** — check if it supports `getrandom` with `js` feature for WASM.
2. **uuid v4** — widely compatible, UUIDs are slightly longer than NanoIDs but universally supported.
3. **Custom NanoID** — implement NanoID generation using `getrandom` + custom alphabet. ~20 lines of code, no extra dependency.
</options>
<resolution>
**RESOLVED 2026-03-02 — Use UUID v4 (`uuid` crate).**
### Decision: Option 2 — uuid v4
The `nanoid` crate (v0.4.0) depends on `rand ^0.8`, which uses `thread_rng()` (thread-local RNG).
Thread-local storage is unreliable in wasm32-unknown-unknown, and the underlying `getrandom`
`wasm_js` feature is explicitly discouraged in libraries by the getrandom maintainers.
`uuid = { version = "1", features = ["v4", "serde"] }` with `getrandom = { version = "0.2",
features = ["js"] }` (wasm32 cfg section only) is the proven, zero-risk approach for
Cloudflare Workers. UUID v4 produces 36-char hyphenated IDs — slightly longer than NanoID's 21
chars, but negligible in practice and universally supported.
### Created ticket
7a0d9f — "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs"
- `generate_id()` in `src/lib.rs` returns `uuid::Uuid::new_v4().to_string()`
- Cargo.toml adds `uuid` to `[dependencies]` and `getrandom/js` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`
- Ticket 05f8ae (PUT handler) and 1f5bb5 (Cargo.toml) updated to reference this approach
### Updated tickets
- 05f8ae: "Generate a NanoID for the quote ID" → use `generate_id()` from lib.rs (UUID v4)
- 1f5bb5: Add `uuid = { version = "1", features = ["v4", "serde"] }` to [dependencies]; add
`getrandom = { version = "0.2", features = ["js"] }` under wasm32 cfg
</resolution>
<commit>
`chore(quotesdb): resolve triage — nanoid-crate-wasm-compatibility-with-workersrs-target`
</commit>

@ -1,7 +1,28 @@
+++ +++
title = "Document secrets management — Cloudflare API token, account ID, how to supply to OpenTofu and local dev" title = "Document secrets management — Cloudflare API token, account ID, how to supply to OpenTofu and local dev"
priority = 6 priority = 6
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "2d1371"] dependencies = ["2d1371"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
</context>
<goal>
Write documentation in `infra/README.md` or `docs/SECRETS.md` covering:
1. What secrets/credentials are required (Cloudflare API token, account ID)
2. How to provide them for local OpenTofu runs (environment variables or `.env` file — never commit)
3. How to provide them in CI/CD (GitHub Actions secrets or equivalent)
4. What permissions the Cloudflare API token needs (Workers, D1, Pages, DNS)
</goal>
<constraints>
- Do not commit any actual secrets or tokens — document the variable names only.
- Cross-reference the `.gitignore` for infra secrets files.
</constraints>
<commit>
`docs(quotesdb): document secrets management for Cloudflare credentials`
</commit>

@ -1,7 +1,39 @@
+++ +++
title = "Document D1 schema migration workflow — how to apply SQL schema changes to D1 in CI/CD" title = "Document D1 schema migration workflow — how to apply SQL schema changes to D1 in CI/CD"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "d0da0b"] dependencies = ["d0da0b", "bb1514"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
Cloudflare D1 uses SQL migrations. Because the Worker runs in the Cloudflare runtime (not a standard server), migrations must be applied via a separate mechanism.
TRIAGE 5c0c64 resolved: the chosen strategy is **Option 2 — separate wrangler step**. Schema SQL lives at `infra/schema.sql` (ticket bb1514). No `null_resource`, no startup migration from the Workers handler.
For local dev/tests, `NativeRepository::run_migrations()` (ticket 00aff0) calls `execute_batch` via rusqlite — no manual step needed there.
</context>
<goal>
Document the D1 schema migration workflow in `infra/README.md`:
1. The canonical schema file location: `infra/schema.sql`
2. How to apply the initial schema SQL to D1 after first `tofu apply`:
`wrangler d1 execute quotesdb --file infra/schema.sql --remote`
3. How to apply incremental migrations (numbered files under `infra/migrations/`)
4. How to apply migrations in CI/CD (two-step: `tofu apply` then `wrangler d1 execute`)
5. How local dev/tests work (NativeRepository handles this automatically, no manual step)
6. Cross-reference: TRIAGE decisions from 5c0c64 and 580e66
</goal>
<constraints>
- TRIAGE 5c0c64 is resolved — the strategy is a separate wrangler step. Document accordingly.
- `infra/schema.sql` must exist (ticket bb1514) before writing the exact wrangler command.
- D1 resource must be defined (ticket d0da0b) to confirm the database name "quotesdb".
- Do NOT document `null_resource` or startup migrations from the Workers handler.
</constraints>
<commit>
`docs(quotesdb): document D1 schema migration workflow in infra/README.md`
</commit>

@ -1,7 +1,24 @@
+++ +++
title = "Write tests/README.md" title = "Write tests/README.md"
priority = 3 priority = 3
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f"] dependencies = ["9b581f"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write `tests/README.md` explaining:
1. What the integration test suite covers
2. How to run the tests (`cargo test` from the `quotesdb/` directory)
3. How the test harness works (temporary SQLite DB, port binding, cleanup)
4. Any prerequisites for running tests locally
5. License notice
</goal>
<commit>
`docs(quotesdb): write integration tests README`
</commit>

@ -0,0 +1,42 @@
+++
title = "quotesdb/api: reports table and POST /api/quotes/:id/report endpoint"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Summary
Create a reports table and a public endpoint for reporting quotes for moderation review.
## Schema
```sql
CREATE TABLE reports (
id TEXT PRIMARY KEY, -- NanoID
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
reason TEXT, -- optional, max 256 chars
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
## API Endpoint
POST /api/quotes/:id/report
- Body: { reason?: string } — reason is optional, max 256 chars
- Validates reason length (400 if > 256 chars)
- Creates a report record
- Returns 201 on success
- Returns 404 if quote not found
## Rate Limiting Note
Rate limiting will be handled separately at the Cloudflare layer (see infra ticket 06d304). No application-level rate limiting needed here.
## Acceptance Criteria
- [ ] reports table created in migration
- [ ] POST /api/quotes/:id/report works
- [ ] reason is optional and validated (max 256 chars)
- [ ] 404 on unknown quote_id
- [ ] Unit tests cover success, missing quote, reason too long
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -1,7 +1,44 @@
+++ +++
title = "Test suite: GET /api/ — OpenAPI spec returned as valid JSON with expected structure" title = "Test suite: GET /api/ — OpenAPI spec returned as valid JSON with expected structure"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f", "28e7d9"] dependencies = ["9b581f", "28e7d9"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write the `GET /api/` test suite in `tests/test_openapi.rs` (or similar). Assert that the endpoint:
1. Returns HTTP 200
2. Returns `Content-Type: application/json`
3. Returns a body that is valid JSON
4. The JSON object contains an `openapi` key with value `"3.1.0"` (or `"3.0.x"`)
5. The JSON object contains `paths` and `info` keys
</goal>
<constraints>
- Use the shared test harness from ticket 9b581f.
- No authentication required for this endpoint.
</constraints>
<skills>
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add GET /api/ test suite — OpenAPI spec endpoint`
</commit>

@ -0,0 +1,123 @@
+++
title = "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs"
priority = 8
status = "done"
ticket_type = "task"
dependencies = []
+++
<context>
Resolved from TRIAGE ticket 6f2e18. The `nanoid` crate is not suitable for wasm32-unknown-unknown
because it depends on `rand`, which relies on thread-local RNG — unavailable in WASM. The safe,
WASM-compatible choice is UUID v4 via the `uuid` crate.
On the wasm32 target, `uuid`'s `v4` feature depends on `getrandom`, which requires the `wasm_js` feature
(renamed from `js` in getrandom 0.2; uuid 1.21+ requires getrandom ^0.4) to source entropy from the
Web Crypto API (`crypto.getRandomValues()`). This must be declared as a direct dependency in the
application's `Cargo.toml` at the wasm32 cfg section.
UUID v4 produces 36-character hyphenated strings (e.g. `550e8400-e29b-41d4-a716-446655440000`).
The design doc originally specified NanoID (~21 chars); UUID v4 is slightly longer but universally
supported and zero-risk on the Workers target. The DB schema comment should be updated accordingly.
</context>
<goal>
Add a `generate_id()` public function to `src/lib.rs` that:
- Returns a new UUID v4 as a `String`
- Compiles correctly for both the native host target AND `wasm32-unknown-unknown`
- Has a rustdoc comment with a doc-example (which also serves as a doctest)
</goal>
<implementation>
## 1. Cargo.toml changes
Add `uuid` to the shared (all-targets) dependencies section:
```toml
[dependencies]
uuid = { version = "1", features = ["v4", "serde"] }
```
Add `getrandom` with the `wasm_js` feature under the wasm32 cfg section (so native builds don't pull
in wasm-bindgen). **uuid 1.21+ requires getrandom ^0.4**; getrandom 0.4 renamed the `js` feature
to `wasm_js`. Also shared with the passphrase generator (ticket 03bb91 / TRIAGE 6ed325):
```toml
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.4", features = ["wasm_js"] }
```
## 2. src/lib.rs — generate_id()
```rust
/// Generates a new UUID v4 string for use as a database primary key.
///
/// Returns a 36-character hyphenated UUID string. Compatible with both
/// native and `wasm32-unknown-unknown` targets (uses Web Crypto API via
/// `getrandom/wasm_js` on WASM).
///
/// # Examples
///
/// ```
/// let id = quotesdb::generate_id();
/// assert_eq!(id.len(), 36);
/// assert_eq!(id.chars().filter(|&c| c == '-').count(), 4);
/// ```
pub fn generate_id() -> String {
uuid::Uuid::new_v4().to_string()
}
```
## 3. Callers
- `PUT /api/quotes` handler (ticket 05f8ae): call `generate_id()` to produce the new quote's `id`
- No other callers at this stage
## 4. DB schema comment update
In `docs/plans/2026-02-27-quotesdb-design.md` and `CLAUDE.md` design reference, update the schema
comment from:
```sql
id TEXT PRIMARY KEY, -- NanoID (~21 chars)
```
to:
```sql
id TEXT PRIMARY KEY, -- UUID v4 (36 chars), generated by generate_id()
```
</implementation>
<constraints>
- `generate_id()` must be in `src/lib.rs` (shared code, not bin-specific)
- UUID v4 is the only correct choice — do NOT use `nanoid`, `rand::thread_rng`, or any
crate that pulls in thread-local RNG primitives for WASM
- `getrandom = { version = "0.4", features = ["wasm_js"] }` must be in the wasm32 cfg section only,
not in `[dependencies]`, to avoid pulling wasm-bindgen into native builds
- Do NOT use getrandom 0.2 or the old `js` feature name — uuid 1.21+ requires getrandom ^0.4
- All public items must have rustdoc comments (per project style)
</constraints>
<skills>
Use `superpowers:test-driven-development` — write a unit test verifying length (36) and hyphen
count (4) before implementing.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): add generate_id() using UUID v4 — WASM-compatible ID generation`
</commit>

@ -0,0 +1,168 @@
+++
title = "Admin super auth code: delete any quote regardless of per-quote auth"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Feature
Add an **admin super auth code** — a single global passphrase that can delete (and update) any quote, bypassing the per-quote `auth_code` check. This allows the operator to moderate content without needing the original submitter's code.
The admin code is:
- Generated once on first startup using the same 4-word passphrase generator (`generate_auth_code` in `src/lib.rs`).
- Stored in the database in a new `admin_config` table.
- Printed prominently to stderr on every startup so the operator can note it.
- Never exposed via the API.
---
## Part 1 — Database: new migration
Add a new migration constant in `src/bin/api/db/migrations.rs`:
```rust
/// Creates the admin_config key/value table for storing global configuration.
///
/// Stores a single row for the admin auth code under the key `admin_auth_code`.
pub const CREATE_ADMIN_CONFIG: &str = "\
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)";
```
Run this migration in `QuoteRepository::run_migrations` after the existing migrations. The implementation then seeds the admin auth code if absent (see Part 2).
Update `infra/schema.sql` to include:
```sql
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
---
## Part 2 — Repository trait: `db/mod.rs`
Add two new methods to `QuoteRepository`:
```rust
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the table is empty (should not happen after migrations).
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
/// Insert the admin auth code into `admin_config` if it is not already set.
///
/// Called once during startup, after `run_migrations`.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
```
The startup sequence (in `main.rs`) becomes:
```rust
repo.run_migrations().await?;
// Seed admin code on first run
if repo.get_admin_auth_code().await?.is_none() {
let code = quotesdb::generate_auth_code();
repo.seed_admin_auth_code(&code).await?;
}
// Always print the admin code at startup
let admin_code = repo.get_admin_auth_code().await?.unwrap();
eprintln!("╔══════════════════════════════════════════════╗");
eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}");
eprintln!("╚══════════════════════════════════════════════╝");
```
---
## Part 3 — Native implementation: `db/native.rs`
Implement `get_admin_auth_code` and `seed_admin_auth_code` using rusqlite.
**Extend `delete_quote`** to accept the admin code as a fallback:
```rust
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
// ... existing logic ...
// Before returning Forbidden, check admin auth code
let admin_code = self.get_admin_auth_code().await?;
if Some(auth_code) == admin_code.as_deref() {
// Admin code matches — delete unconditionally
// ... execute DELETE without checking quotes.auth_code ...
return Ok(DeleteResult::Deleted);
}
Ok(DeleteResult::Forbidden)
}
```
Similarly extend `update_quote` to allow admin override.
The cleanest approach is to refactor `delete_quote` and `update_quote` to first attempt the per-quote auth check, and if it fails, check against the admin code.
---
## Part 4 — D1 implementation: `db/d1.rs`
Apply the same changes as Part 3 for the WASM/Cloudflare D1 path.
---
## Part 5 — API startup: `src/bin/api/main.rs`
Update the startup sequence as shown in Part 2. The admin code print must be clearly visible in logs.
---
## Part 6 — Mock repo in tests: `handlers/mod.rs`
Add stub implementations of `get_admin_auth_code` and `seed_admin_auth_code` to `MockRepo`:
```rust
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
Ok(None) // no admin code in tests by default
}
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
Ok(())
}
```
---
## Design notes
- The admin code is **never returned by any API endpoint** — there is no way to discover it via HTTP.
- The admin code is stored plaintext in `admin_config`, consistent with per-quote auth codes. This is acceptable given the stated security model (simple passphrase, no user accounts).
- Only `delete_quote` and `update_quote` check the admin code. Read operations are unaffected.
- The admin code is **not rotatable** via the API — an operator who needs to rotate it must manually update the database row.
---
## Files touched
- `src/bin/api/db/migrations.rs``CREATE_ADMIN_CONFIG` constant
- `src/bin/api/db/mod.rs` — two new trait methods + updated docstrings for `delete_quote`/`update_quote`
- `src/bin/api/db/native.rs` — implementations + admin fallback logic
- `src/bin/api/db/d1.rs` — same for D1
- `src/bin/api/handlers/mod.rs``MockRepo` stubs
- `src/bin/api/main.rs` — seed + print admin code on startup
- `infra/schema.sql``admin_config` table
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
```
Manual test:
1. Start the server: `cargo run`
2. Observe admin code printed to stderr.
3. Create a quote: `curl -X PUT http://localhost:3000/api/quotes -H 'Content-Type: application/json' -d '{"text":"Test","author":"A","tags":[]}'`
4. Try deleting with wrong code: should return 403.
5. Try deleting with admin code: should return 204.
6. Restart the server: same admin code should be printed (not regenerated).
## Commit scope
`feat(quotesdb): admin super auth code for quote moderation`

@ -1,7 +1,51 @@
+++ +++
title = "Implement GET /api/quotes — paginated list with author filter (case-insensitive) and tag filter" title = "Implement GET /api/quotes — paginated list with author filter (case-insensitive) and tag filter"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] dependencies = ["a5049d", "d792e2", "175382"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
`GET /api/quotes` returns a paginated list of quotes. Query parameters:
- `page` (default 1): page number (1-indexed)
- `author`: case-insensitive author filter (partial match acceptable)
- `tag`: filter to quotes that have this tag
Response shape: `{"quotes": [...], "page": N, "total_pages": N, "total_count": N}`. Page size is 10.
</context>
<goal>
Implement the `GET /api/quotes` handler with pagination, optional author filter, and optional tag filter. Each quote in the response must include its tags (fetched from `quote_tags`). Return the pagination metadata in the response envelope.
</goal>
<constraints>
- Author filter should be case-insensitive (`LIKE lower(?)` or `COLLATE NOCASE`).
- Tag filter requires a JOIN with `quote_tags` — ensure the query doesn't return duplicate quotes when a quote has multiple tags.
- Out-of-range page numbers should return an empty `quotes` array, not a 404.
- Tags must be fetched for each returned quote — either via a JOIN or N+1 queries (N+1 is acceptable for now given small dataset size).
</constraints>
<skills>
Use `superpowers:test-driven-development` — write tests for: page=1 default, page=2 with 15 quotes, author filter, tag filter, combined filters.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement GET /api/quotes — paginated list with author and tag filters`
</commit>

@ -0,0 +1,102 @@
+++
title = "Add build.rs — convert api/openapi.yaml to JSON at compile time for Workers embed"
priority = 8
status = "done"
ticket_type = "task"
dependencies = []
+++
<context>
Resolved from TRIAGE ticket 2ec8b1. The `GET /api/` endpoint must serve the OpenAPI spec as JSON.
The three strategies were:
1. Compile-time embed (chosen)
2. Runtime load from filesystem — impossible on Cloudflare Workers (no filesystem)
3. utoipa programmatic generation — significant complexity; spec already exists and is complete
The chosen approach: a `build.rs` script reads `api/openapi.yaml`, parses it to a
`serde_json::Value`, serialises it as compact JSON, and writes the result to
`$OUT_DIR/openapi.json`. The `GET /api/` handler then serves this via:
```rust
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
```
This means:
- `serde_yaml` ships only as a `[build-dependencies]` entry — it never enters the Workers binary.
- The handler is a zero-overhead static string response with no runtime parsing.
- `cargo:rerun-if-changed=api/openapi.yaml` ensures the conversion re-runs whenever the spec
is edited — no manual JSON regeneration step needed.
- `api/openapi.yaml` remains the single source of truth; the JSON output is ephemeral (in
`$OUT_DIR`, not committed to the repository).
</context>
<goal>
1. Create `build.rs` at the `quotesdb/` project root containing:
```rust
use std::{env, fs, path::Path};
fn main() {
// Re-run this script whenever the OpenAPI spec changes.
println!("cargo:rerun-if-changed=api/openapi.yaml");
let yaml =
fs::read_to_string("api/openapi.yaml").expect("api/openapi.yaml not found");
// Parse YAML to a generic JSON value, then re-serialise as compact JSON.
// serde_yaml is a build-only dependency — it does not appear in the final binary.
let value: serde_json::Value =
serde_yaml::from_str(&yaml).expect("api/openapi.yaml is invalid YAML");
let json = serde_json::to_string(&value).expect("JSON serialisation failed");
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
let out_path = Path::new(&out_dir).join("openapi.json");
fs::write(&out_path, json).expect("failed to write openapi.json");
}
```
2. Add the following to `Cargo.toml` (ticket 1f5bb5 should also include this):
```toml
[build-dependencies]
serde_json = "1"
serde_yaml = "0.9"
```
3. Verify the build succeeds and `$OUT_DIR/openapi.json` is produced:
```sh
cargo check
# $OUT_DIR is typically target/debug/build/quotesdb-*/out/openapi.json
```
</goal>
<constraints>
- `serde_yaml` must be a `[build-dependencies]` entry only — NOT in `[dependencies]`.
Adding it to `[dependencies]` would bloat the Workers WASM binary.
- Do NOT commit `$OUT_DIR/openapi.json` — it is generated automatically at build time.
- The `build.rs` file lives at the crate root (same level as `Cargo.toml`), not in `src/`.
- `api/openapi.yaml` is the source of truth; do not create or commit an `api/openapi.json`.
</constraints>
<downstream>
Ticket 28e7d9 (GET /api/ handler) depends on this ticket. The handler uses
`include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))` to serve the spec — see 28e7d9
for the Axum handler implementation.
</downstream>
<validation>
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`chore(quotesdb): add build.rs to convert api/openapi.yaml to JSON at compile time`
</commit>
<domain>quotesdb/api</domain>

@ -1,7 +1,43 @@
+++ +++
title = "Test suite: tag operations — create with tags, list by tag filter, update replaces all tags" title = "Test suite: tag operations — create with tags, list by tag filter, update replaces all tags"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f", "175382"] dependencies = ["9b581f", "175382"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write the tag operations test suite in `tests/test_tags.rs` (or similar). Test cases:
1. Create quote with tags — verify tags appear in the response
2. List quotes filtered by tag — `?tag=motivation` returns only tagged quotes
3. Update quote replaces all tags — old tags gone, new tags present
4. Delete quote cascades — no orphaned rows in `quote_tags`
</goal>
<constraints>
- Use the shared test harness from ticket 9b581f.
- Seed quotes with distinct tag sets to avoid test interference.
</constraints>
<skills>
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add tag operations test suite`
</commit>

@ -0,0 +1,37 @@
+++
title = "quotesdb/api: hidden flag for quotes (schema migration + endpoints)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Summary
Add a boolean hidden flag to quotes. Hidden quotes are excluded from listing endpoints and require direct URL access. Changing a quote from hidden to public requires the auth code.
## Schema Migration
Add column to quotes table:
```sql
ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
```
## API Changes
- GET /api/quotes — filter out hidden=1 quotes by default
- GET /api/quotes/random — exclude hidden quotes
- GET /api/quotes/:id — return hidden quotes (direct access allowed)
- PUT /api/quotes — new quotes default to hidden=0 (not hidden)
- POST /api/quotes/:id — allow toggling hidden field; requires X-Auth-Code header
- Changing hidden=1 → hidden=0 (unhide) requires valid auth code
- Changing hidden=0 → hidden=1 (hide) also requires valid auth code
- The quote response body should include the hidden field
## Acceptance Criteria
- [ ] Schema migration applied
- [ ] Listing endpoints exclude hidden quotes
- [ ] Direct quote access (/api/quotes/:id) works for hidden quotes
- [ ] Toggle hidden requires valid X-Auth-Code (403 on mismatch)
- [ ] All existing tests pass
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -1,7 +1,44 @@
+++ +++
title = "Test suite: DELETE /api/quotes/:id — valid auth 204 no body, wrong auth 403, not found 404, cascade deletes tags" title = "Test suite: DELETE /api/quotes/:id — valid auth 204 no body, wrong auth 403, not found 404, cascade deletes tags"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f", "b20b5a"] dependencies = ["9b581f", "b20b5a"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write the `DELETE /api/quotes/:id` test suite in `tests/test_delete_quote.rs` (or similar). Test cases:
1. Valid auth — 204 No Content, no response body
2. Wrong auth code — 403 Forbidden
3. Not found ID — 404 Not Found
4. Cascade deletes tags — verify `GET /api/quotes/:id` returns 404 after deletion and tags are gone
</goal>
<constraints>
- Use the shared test harness from ticket 9b581f.
- Create a quote with tags before each test, use its auth_code for valid-auth tests.
- After successful delete, verify the quote is no longer retrievable.
</constraints>
<skills>
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add DELETE /api/quotes/:id test suite`
</commit>

@ -1,7 +1,51 @@
+++ +++
title = "Set up ui/Cargo.toml with Yew/Wasm dependencies (yew, yew-router, gloo, wasm-bindgen, serde, etc.)" title = "Set up ui/Cargo.toml with Yew/Wasm dependencies (yew, yew-router, gloo, wasm-bindgen, serde, etc.)"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "166996"] dependencies = ["166996"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
TRIAGE resolved (ticket 166996): use yew = "0.22", yew-router = "0.19", wasm-bindgen = "0.2" (compatible with wasm-bindgen-cli 0.2.108 in the Nix dev shell).
</context>
<goal>
Add UI-side Yew/Wasm dependencies to `Cargo.toml` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. Use the following pinned versions:
- `yew = "0.22"` (latest stable: 0.22.1)
- `yew-router = "0.19"` (latest stable: 0.19.0, requires yew ^0.22.0)
- `gloo` — timers and fetch utilities for Yew
- `wasm-bindgen = "0.2"` (compatible with nix-shell wasm-bindgen-cli 0.2.108)
- `web-sys` — browser API bindings
- `serde` with `derive` feature
- `serde_json`
- `wasm-bindgen-futures` — for async fetch in Wasm
Also add `serde` and `serde_json` to `[dependencies]` (non-target-scoped) so shared lib types can use derive macros on both targets.
</goal>
<constraints>
- All UI-only dependencies must be scoped to the wasm32 target — they must not appear in host builds.
- `wasm-bindgen` version must match the `wasm-bindgen-cli` version in the Nix dev shell (currently 0.2.108).
- `serde` and `serde_json` are needed on both targets for shared types — add to `[dependencies]` not the wasm target section.
</constraints>
<skills>
Use `superpowers:verification-before-completion` — run `trunk build` to confirm WASM compilation succeeds.
</skills>
<validation>
From the `quotesdb/` directory:
```sh
cargo fmt
cargo check
trunk build
```
</validation>
<commit>
`chore(quotesdb): set up ui Cargo dependencies for Yew/Wasm`
</commit>

@ -1,7 +1,46 @@
+++ +++
title = "Test suite: GET /api/quotes — pagination (page=1, page=N, out-of-range), author filter, tag filter, no results" title = "Test suite: GET /api/quotes — pagination (page=1, page=N, out-of-range), author filter, tag filter, no results"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f", "886bfd"] dependencies = ["9b581f", "886bfd"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write the `GET /api/quotes` test suite in `tests/test_list_quotes.rs` (or similar). Test cases:
1. Pagination — page=1 with 15 quotes returns 10, page=2 returns 5; verify total_count, total_pages
2. Out-of-range page — returns empty `quotes` array, not 404
3. Author filter — `?author=X` returns only quotes by that author (case-insensitive)
4. Tag filter — `?tag=X` returns only quotes with that tag
5. Combined filters — author + tag
6. No results — filters that match nothing return empty array with total_count=0
</goal>
<constraints>
- Use the shared test harness from ticket 9b581f.
- Seed multiple quotes before running filter tests.
- Page size is 10 — tests that rely on pagination must seed at least 11 quotes.
</constraints>
<skills>
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add GET /api/quotes test suite — pagination and filters`
</commit>

@ -1,7 +1,120 @@
+++ +++
title = "Implement test server harness — spawn quotesdb-api with temp SQLite DB, return base URL" title = "Implement test server harness — spawn quotesdb-api with temp SQLite DB, return base URL"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "5f5ba0", "2ab7a8", "fba598"] dependencies = ["5f5ba0", "2ab7a8", "fba598"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment.
Architecture decided in triage:
- 2ab7a8: Server is spawned as a tokio task using the native Axum path (cfg-split, no workers-rs on host)
- fba598: Isolation strategy is **per-test temp SQLite file** via `tempfile` crate (transaction rollback cannot intercept server-side pool commits; in-memory SQLite is incompatible with multi-connection SQLx pools)
- 0d84fa: HTTP client for tests is `reqwest` with `tokio::test`
</context>
<goal>
Implement `tests/helpers.rs` providing a `spawn_test_server()` async function that:
1. Creates a temporary SQLite file via `tempfile::TempDir`
2. Opens a `SqlitePool` connected to that file
3. Runs migrations via `sqlx::migrate!()`
4. Builds the Axum router via `build_router(repo)` (same router used by the API binary)
5. Binds to a random port with `TcpListener::bind("127.0.0.1:0")`
6. Spawns the server with `tokio::spawn(axum::serve(...))`
7. Returns a `TestContext` that holds the `TempDir` (RAII cleanup), base URL, and task handle
</goal>
<implementation>
```rust
// tests/helpers.rs
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
use sqlx::SqlitePool;
pub struct TestContext {
_db_dir: TempDir, // deleted on drop
pub base_url: String,
_server: tokio::task::JoinHandle<()>,
}
pub async fn spawn_test_server() -> TestContext {
let db_dir = TempDir::new().expect("temp dir");
let db_path = db_dir.path().join("test.sqlite");
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&db_url).await.expect("pool");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrations");
let repo = Arc::new(NativeRepository::new(pool));
let app = build_router(repo);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestContext {
_db_dir: db_dir,
base_url: format!("http://127.0.0.1:{port}"),
_server: server,
}
}
```
Usage in a test:
```rust
#[tokio::test]
async fn test_create_quote() {
let ctx = spawn_test_server().await;
let client = reqwest::Client::new();
let res = client
.put(format!("{}/api/quotes", ctx.base_url))
.json(&serde_json::json!({"text": "hello", "author": "world"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
}
```
</implementation>
<constraints>
- `build_router` and `NativeRepository` must be pub-accessible from the `quotesdb` crate (may require re-exports in `src/lib.rs`).
- `sqlx::migrate!()` macro path is relative to the crate root — migrations must be in `migrations/` at the crate root.
- Each test gets a unique `TempDir`, so parallel test execution (`cargo test`) is safe.
- Do not set `--test-threads=1`; parallel execution must work.
- The `_server` handle is intentionally leaked (tokio runtime drops it when the test ends).
</constraints>
<dependencies-needed>
In `[dev-dependencies]` (ticket 5f5ba0):
- `tempfile = "3"`
- `reqwest = { version = "0.12", features = ["json"] }`
- `tokio = { version = "1", features = ["full"] }`
- `serde_json = "1"`
</dependencies-needed>
<skills>
Use `superpowers:test-driven-development` — the harness is itself tested by running `cargo test`.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): implement test server harness with per-test temp SQLite DB`
</commit>

@ -0,0 +1,66 @@
+++
title = "Create .env.example documenting DATABASE_URL and all local dev environment variables"
priority = 5
status = "done"
ticket_type = "task"
dependencies = ["33ed29"]
+++
<context>
TRIAGE 33ed29 resolved the local dev database strategy: rusqlite with a local SQLite file.
The only environment variable required for local development is `DATABASE_URL` (optional — defaults
to `./quotesdb.sqlite`). No Turso, no wrangler, no Cloudflare account needed locally.
A `.env.example` file in the project root serves as self-documenting reference for contributors.
The `.env` file itself is gitignored (never committed). `.env.example` is committed and documents
all variables with their defaults and a brief description.
</context>
<goal>
Create `quotesdb/.env.example` with the following content:
```sh
# quotesdb local development environment variables
# Copy to .env and customise. The .env file is gitignored and must never be committed.
#
# All variables below have sensible defaults for local development and are OPTIONAL.
# Path to the local SQLite database file used by `cargo run` (native API server).
# The file is created automatically on first run; migrations run on startup.
# In production this variable is unused — the Workers runtime uses the D1 binding.
DATABASE_URL=./quotesdb.sqlite
```
Also ensure `.gitignore` in the `quotesdb/` root has an entry for `.env`:
```gitignore
.env
```
</goal>
<decisions-reflected>
- TRIAGE 33ed29: rusqlite + local SQLite file. `DATABASE_URL` is the only required env var.
- No Cloudflare account, no wrangler, no Turso credentials needed for local dev.
</decisions-reflected>
<constraints>
- `.env.example` must be committed to the repo. `.env` must be gitignored.
- Only document variables that are actually used by the codebase (see ticket 6e829e / 00aff0 for where DATABASE_URL is read).
- Do not add placeholder values for production secrets — `.env.example` is for local dev only.
- If production-only secrets (e.g., Cloudflare API tokens for infra) are identified later, add them in a separate PR with appropriate comments.
</constraints>
<validation>
Verify `.env.example` is tracked and `.env` is gitignored:
```sh
git status # .env.example should appear as a new untracked file
echo "test" > .env
git status # .env must NOT appear (should be ignored)
rm .env
```
</validation>
<commit>
`chore(quotesdb): add .env.example documenting DATABASE_URL for local dev`
</commit>

@ -0,0 +1,46 @@
+++
title = "Home page: show friendly empty state when no quotes in database"
priority = 7
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
When the database is empty, `GET /api/quotes/random` returns a 404 response. The home page (`src/bin/ui/pages/home.rs`) currently treats all errors (including 404) the same way — it sets the `error` state and displays it via `<ErrorDisplay>`, which results in something like a raw JSON error message being shown to the user.
## Expected behaviour
When the API returns a 404 on `/api/quotes/random`, the home page should display a friendly empty-state message instead of a generic error:
> "Nothing here yet. Submit a quote!"
The message should include a link to `/submit`.
## How to fix
In `src/bin/ui/pages/home.rs`, in the `use_effect_with` block, inspect the `Err` value. The API client returns `ApiError::Server { status, .. }` for HTTP error codes. When `status == 404`, set a dedicated "empty" state (or detect it from the error) and render the friendly message instead of `<ErrorDisplay>`.
Relevant code: `src/bin/ui/pages/home.rs` — the `use_effect_with` error branch and the HTML render block.
## File
- `src/bin/ui/pages/home.rs`
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
```
Also manually test with an empty database:
```sh
rm -f quotesdb.sqlite
cargo run &
# navigate to http://localhost:3000 — should show the friendly message, not a raw error
```
## Commit scope
`fix(quotesdb): home page empty state`

@ -0,0 +1,64 @@
+++
title = "Add `_redirects` SPA fallback — create file and include in Trunk build via copy-file"
priority = 8
status = "done"
ticket_type = "task"
dependencies = []
+++
<context>
Resolved from TRIAGE ticket e2bd9b. Yew uses client-side routing (BrowserRouter), so a direct
URL such as `/browse` or `/quotes/abc123` will 404 on Cloudflare Pages unless a fallback is
configured. The chosen approach is a `_redirects` file with `/* /index.html 200`, which instructs
Cloudflare Pages to serve `index.html` for any path that does not match a static asset — without
changing the URL in the browser (HTTP 200 proxy, not a redirect).
This file must be present in the `dist/` output directory that `wrangler pages deploy` uploads.
Trunk handles this via its `copy-file` asset type: adding a `<link data-trunk rel="copy-file"
href="_redirects"/>` line to `index.html` causes Trunk to copy the file verbatim into `dist/`
on every build.
The API Worker claims `/api/*` at the Cloudflare routing level before Pages processes the request,
so the `/* /index.html 200` catch-all does not interfere with the API.
</context>
<goal>
1. Create `_redirects` at the `quotesdb/` project root (next to `index.html`) containing exactly:
```
/* /index.html 200
```
2. Add the following line to `index.html` inside `<head>`, alongside the other `data-trunk` links:
```html
<link data-trunk rel="copy-file" href="_redirects"/>
```
3. Run `trunk build` and verify that `dist/_redirects` exists with the correct single-line content.
4. Commit with:
```
chore(quotesdb): add _redirects SPA fallback for Cloudflare Pages routing
```
</goal>
<constraints>
- The `_redirects` file must live at the project root (same level as `index.html` and `Trunk.toml`),
not inside `src/` or a `static/` subdirectory.
- The line must use a 200 (proxy) code, not 301 or 302 — 200 preserves the URL in the browser,
which is required for client-side routing to work correctly.
- Do NOT add `/* /index.html 200` to the `_headers` file — headers do not fix routing.
- This ticket is scoped to file creation and Trunk build verification only. The CI/CD deploy
workflow is handled separately in ticket 5137d7.
</constraints>
<validation>
```sh
trunk build
ls dist/_redirects # must exist
cat dist/_redirects # must print: /* /index.html 200
```
</validation>
<domain>quotesdb/ui</domain>

@ -1,7 +1,74 @@
+++ +++
title = "Define Cloudflare Workers script resource — WASM artifact, D1 binding, environment variables" title = "Define Cloudflare Workers script resource — WASM artifact, D1 binding, environment variables"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "2d1371", "d0da0b", "07cafb", "efee79"] dependencies = ["2d1371", "d0da0b", "07cafb", "efee79"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider.
Trages resolved:
- 07cafb: D1 binding — use `cloudflare_d1_database.db.id` directly; OpenTofu dependency graph handles ordering. No two-phase apply or data source needed.
- efee79: Correct resource name — `cloudflare_workers_script` (plural, confirmed from provider v4 source).
</context>
<goal>
Define the Cloudflare Workers script resource in `infra/worker.tf`.
Every block must have a comment.
</goal>
<implementation>
```hcl
# infra/worker.tf
# Cloudflare Workers script for the quotesdb API.
# Compiled from the `api` binary targeting wasm32-unknown-unknown.
# The Wasm artifact must be built before running `tofu apply`:
# cargo build --release --target wasm32-unknown-unknown --bin api
resource "cloudflare_workers_script" "api" {
account_id = var.cloudflare_account_id
# Script name used in Cloudflare dashboard and for routing.
name = "quotesdb-api"
# Compiled Wasm binary content, base64-encoded.
# Path is relative to the infra/ directory.
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
# D1 database binding — referenced in workers-rs code as `env.DB`.
# `database_id` is resolved at apply time from the D1 resource output.
# OpenTofu automatically creates the D1 database before this script
# because of the attribute reference below (no explicit depends_on needed).
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.db.id
}
# Workers runtime compatibility date.
compatibility_date = "2024-09-23"
}
```
</implementation>
<notes>
- The `content` attribute expects base64-encoded script bytes. For a Wasm Worker, this is the raw compiled Wasm file, not a JS bundle.
- The binding `name = "DB"` must match exactly what the workers-rs API code uses (`env.DB`). Verify this in `src/bin/api/main.rs`.
- `(known after apply)` for `database_id` in `tofu plan` is expected and correct — OpenTofu resolves it at apply time.
- The Wasm binary must be compiled before `tofu apply`. This is handled by the Gitea Actions CI/CD workflow (ticket to be created; also see ticket 5137d7 for the UI workflow pattern).
</notes>
<validation>
Run from the `infra/` directory:
```sh
tofu validate
tofu plan
```
</validation>
<commit>
`feat(quotesdb): define Cloudflare Workers script resource in OpenTofu`
</commit>

@ -1,7 +1,70 @@
+++ +++
title = "Implement database connection module and SQLx migrations (quotes + quote_tags schema)" title = "Implement database connection module and SQLx migrations (quotes + quote_tags schema)"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "1f5bb5", "e8a330", "580e66", "33ed29"] dependencies = ["1f5bb5", "580e66", "33ed29"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
The database schema consists of two tables:
- `quotes` — stores id (NanoID), text, author, source, date, auth_code, created_at, updated_at
- `quote_tags` — join table for quote-to-tag relationships with cascade delete
</context>
<superseded-by>
**This ticket's SQLx-based goal has been superseded by ticket 00aff0.**
TRIAGE e8a330 concluded that SQLx is incompatible with workers-rs/D1. The new approach uses:
- workers-rs `D1Database` bindings for the WASM/production target
- `rusqlite` + `tokio-rusqlite` for the native/test target
- A `QuoteRepository` async trait as the shared interface
- `cfg(target_arch = "wasm32")` for compile-time target selection
See ticket 00aff0 for the full implementation plan.
This ticket remains open as tracking context but its implementation is covered by 00aff0.
</superseded-by>
<goal>
~~Implement `src/bin/api/db.rs` (or equivalent module) providing:~~
~~1. A database connection pool constructor (Turso/SQLite locally, D1 in production)~~
~~2. SQLx migrations that create the `quotes` and `quote_tags` tables if they don't exist~~
~~3. Re-export the pool type for use by handlers~~
**Updated goal (see ticket 00aff0):** Implement `src/bin/api/db/` module with:
1. `QuoteRepository` trait in `db/mod.rs`
2. `D1Repository` in `db/d1.rs` (`#[cfg(target_arch = "wasm32")]`)
3. `NativeRepository` in `db/native.rs` (`#[cfg(not(target_arch = "wasm32"))]`)
4. SQL migration strings in `db/migrations.rs`
</goal>
<constraints>
- TRIAGE 580e66 resolved (same decision as 5c0c64): D1 production schema is applied via `wrangler d1 execute` (separate CI step). The Workers fetch handler does NOT run migrations. Native `main()` calls `repo.run_migrations()` via rusqlite on startup.
- Schema must exactly match the design: NanoID primary key, `auth_code` stored plaintext, optional `source` and `date` fields, cascade delete on `quote_tags`.
- SQLx is NOT used. Use workers-rs D1 bindings (wasm32) and rusqlite (native). See 00aff0.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write a test that verifies migration runs and tables exist.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement database connection module and SQLx migrations`
</commit>

@ -0,0 +1,68 @@
+++
title = "quotesdb/api: enforce submission lock on PUT /api/quotes"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## Enforce submission lock on PUT /api/quotes
Modify the quote-creation handler to check the submissions lock before accepting a new quote. If locked, return `423 Locked` with a JSON error body.
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` to the `QuoteRepository` trait. Complete 35685a first.
---
## Files to modify
- `src/bin/api/handlers/mod.rs` — modify the `create_quote` handler (the handler for `PUT /api/quotes`) to add a lock pre-flight check
No new DB methods, routes, or types are needed.
---
## Change to create_quote handler
At the top of the handler body, before any other logic, add:
```rust
// Pre-flight: reject new submissions when locked.
match repo.get_submissions_locked().await {
Ok(true) => {
return (
StatusCode::LOCKED,
Json(json!({ "error": "submissions are closed" })),
).into_response();
}
Ok(false) => {}
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
```
HTTP 423 is `StatusCode::LOCKED` in axum/hyper.
---
## Tests
- `PUT /api/quotes` while `submissions_locked = false``201` (existing behaviour unchanged)
- `PUT /api/quotes` while `submissions_locked = true``423` with body `{ "error": "submissions are closed" }`
- After unlocking (`submissions_locked = false`), `PUT /api/quotes` succeeds again → `201`
Use the in-memory/mock repo already used by other handler tests; expose a method to toggle the lock state on the test double.
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): enforce submission lock on PUT /api/quotes
```

@ -0,0 +1,144 @@
+++
title = "Cloudflare Turnstile CAPTCHA on quote submission"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Feature
Add Cloudflare Turnstile CAPTCHA to protect the `PUT /api/quotes` endpoint (and the submit form in the UI) from bots and spam. This is a three-part change: infra, API, and UI.
---
## Part 1 — Infra: Turnstile widget resource
Create `infra/turnstile.tf` with a `cloudflare_turnstile_widget` resource.
```hcl
# Turnstile CAPTCHA widget protecting the quote submission form.
# Provides a site_key (public, embedded in the UI) and secret_key
# (private, used by the API to verify tokens server-side).
resource "cloudflare_turnstile_widget" "submit" {
account_id = var.cloudflare_account_id
name = "quotesdb-submit"
# "managed" mode: Turnstile decides whether to show a visible challenge.
mode = "managed"
# Restrict the widget to the production domain.
domains = [var.domain]
}
output "turnstile_site_key" {
description = "Turnstile site key — safe to embed in the UI."
value = cloudflare_turnstile_widget.submit.id
}
output "turnstile_secret_key" {
description = "Turnstile secret key — inject into Workers via wrangler secret."
value = cloudflare_turnstile_widget.submit.secret
sensitive = true
}
```
The `var.domain` variable should already exist or be added alongside `var.cloudflare_account_id` in `providers.tf` / `variables.tf`.
After `tofu apply`, inject the secret into the Worker:
```sh
wrangler secret put TURNSTILE_SECRET_KEY
# paste the value from `tofu output -raw turnstile_secret_key`
```
**Validate:**
```sh
# From infra/ directory
tofu validate
tofu plan
```
---
## Part 2 — API: Verify Turnstile token in create handler
The API must verify the Turnstile token before creating a quote.
### Changes
**`src/lib.rs` (or a new `turnstile` module in `src/bin/api/`):**
Add a `verify_turnstile(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool, Error>` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
**`quotesdb::CreateQuoteInput` in `src/lib.rs`:**
Add a `cf_turnstile_token: Option<String>` field. It is optional so that local/test environments can skip verification when no secret is configured.
**`src/bin/api/handlers/mod.rs` — `create_handler`:**
Before calling `repo.create_quote(input)`, check:
1. Read `TURNSTILE_SECRET_KEY` from the environment.
2. If the env var is set:
- Extract `cf_turnstile_token` from the request body.
- If the token is absent, return `400 Bad Request`.
- Call `verify_turnstile(token, secret, remote_ip)`.
- If verification fails, return `403 Forbidden` with `{"error": "CAPTCHA verification failed"}`.
3. If the env var is absent (local dev), skip verification.
**HTTP client:** Add `reqwest` (with `default-features = false, features = ["json"]`) as a non-wasm32 dependency for the Turnstile API call. On wasm32 the create handler does not exist, so no conflict.
**Important:** Strip `cf_turnstile_token` from the `CreateQuoteInput` before passing it to the repository — the DB doesn't store it.
**Validation:**
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Part 3 — UI: Embed Turnstile widget in submit form
### `index.html`
Add the Turnstile JS script tag to the `<head>`:
```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```
### `src/bin/ui/pages/submit.rs`
1. Add a `turnstile_token: UseStateHandle<Option<String>>` state handle.
2. Add the Turnstile widget div in the form, before the submit button:
```html
<div class="cf-turnstile"
data-sitekey="TURNSTILE_SITE_KEY_HERE"
data-callback="turnstile_callback">
</div>
```
The `data-callback` JS function name must be registered in `window`. Use `web_sys::window()` and `js_sys::Function` to expose a Rust closure that sets `turnstile_token` state.
3. Include the token in the `CreateQuoteInput` sent to the API.
**Site key:** The Turnstile site key is public and safe to hardcode in the UI source. Retrieve it from `tofu output -raw turnstile_site_key` after applying infra. Add a note in `docs/LOCAL_DEV.md` that local dev skips CAPTCHA (no env var set).
**Validation:**
```sh
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually verify: the submit form shows the Turnstile widget and submission is blocked if the challenge is not completed.
---
## Files touched
- `infra/turnstile.tf` (new)
- `src/lib.rs``CreateQuoteInput` + `verify_turnstile`
- `src/bin/api/handlers/mod.rs``create_handler`
- `src/bin/ui/pages/submit.rs` — widget embed + token state
- `index.html` — Turnstile JS script
- `Cargo.toml``reqwest` dependency (non-wasm32)
- `api/openapi.yaml` — document `cf_turnstile_token` field
- `docs/LOCAL_DEV.md` — note on local dev CAPTCHA bypass
## Commit scope
`feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit`

@ -1,7 +1,45 @@
+++ +++
title = "Write unit tests in api/src/tests.rs covering all handlers, auth logic, and pagination" title = "Write unit tests in api/src/tests.rs covering all handlers, auth logic, and pagination"
priority = 6 priority = 6
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"] dependencies = ["2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
</context>
<goal>
Write unit tests in `src/bin/api/tests.rs` (or a `#[cfg(test)]` module) covering all API handlers, the auth logic, and pagination calculations. Unit tests should test handler logic in isolation using mock or in-memory databases where possible.
</goal>
<constraints>
- Unit tests must run with `cargo test` on the host target — no WASM or browser context required.
- Test auth code matching logic (correct code → pass, wrong code → 403).
- Test pagination edge cases: page 1, last page, out-of-range page (empty array).
- Test tag insertion and replacement (correct rows added/removed).
- Aim for 80%+ code coverage of the API handler module.
</constraints>
<skills>
Use `superpowers:test-driven-development` throughout implementation — unit tests should already exist from prior tickets.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add unit tests for api handlers, auth logic, and pagination`
</commit>

@ -0,0 +1,46 @@
+++
title = "quotesdb/ui: collapsible filter panel on browse page"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Summary
On the Browse Quotes page (`/browse`), move all filter controls (author, tag, date range) behind a collapsible "Filters" button. Filters are hidden by default and expand when the button is clicked.
## Details
### Filter Button
- A single "Filters" button (or "Filters ▼" / "Filters ▲" to indicate state) sits above the quote list.
- Clicking it toggles the filter panel open or closed.
- The panel is collapsed by default when the page loads.
### Filter Panel Layout
Each filter occupies its own line inside the panel:
1. **Author** — a text input labelled "Author:"
2. **Tag** — a text input labelled "Tag:"
3. **Date range** — displayed as:
`Date: after [date input], before [date input]`
Both date inputs are on the same line, after the "Date:" label.
### Styling
- All three filter rows must be visually consistent with each other (same label width, same input style, same spacing).
- The panel and button should fit the existing site theme.
## Acceptance Criteria
- [ ] Filter controls are hidden by default on `/browse`
- [ ] A "Filters" button toggles the panel open/closed
- [ ] Author, tag, and date range each appear on their own line
- [ ] Date range is labelled `Date: after [input], before [input]`
- [ ] All three filter rows share consistent visual styling
- [ ] Existing filter functionality (querying the API with author/tag/date params) is unchanged
## Validation
Run from `quotesdb/` root:
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -1,7 +1,53 @@
+++ +++
title = "[TRIAGE] workers-rs compatibility with native Rust test binaries (may need separate native feature flag)" title = "[TRIAGE] workers-rs compatibility with native Rust test binaries (may need separate native feature flag)"
priority = 9 priority = 9
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
workers-rs compatibility with native Rust test binaries: the workers-rs crate targets the Cloudflare Workers runtime, not native Linux/macOS. Can the API code be compiled as a native binary for `cargo test`?
</question>
<options>
1. **Conditional compilation** — use `#[cfg(target_env = "worker")]` to switch between workers-rs entry point and a plain Axum server. The native build is used for testing.
2. **Feature flags** — add a `native` feature that enables the Axum server path. `cargo test` uses `--features native`.
3. **Separate test binary** — integration tests spawn a separately compiled native test server binary.
</options>
<decision>
**Option 1 variant: `cfg(target_arch = "wasm32")` — no feature flag needed.**
Note: `target_env = "worker"` is incorrect. The right discriminant is `target_arch = "wasm32"`.
The `cfg(target_arch)` split is cleaner than a feature flag because it is tied to the actual
build target, not an opt-in flag that could be forgotten:
- `[target.'cfg(target_arch = "wasm32")'.dependencies]` → workers-rs (pulled in only for WASM)
- `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` → tokio, axum, rusqlite (native only)
In `main.rs`:
```rust
#[cfg(target_arch = "wasm32")]
#[worker::event(fetch)]
pub async fn main(...) { /* workers-rs entry point */ }
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() { /* native Axum server on :8080 */ }
```
`cargo test` (native host) automatically compiles the native path. No special flags.
No feature flag pollution. Resolved as part of TRIAGE e8a330 (DB strategy decision).
See implementation ticket 00aff0 for full details.
</decision>
<commit>
`chore(quotesdb): resolve triage — workersrs-compatibility-with-native-rust-test-binaries-may-n`
</commit>

@ -1,7 +1,42 @@
+++ +++
title = "[TRIAGE] Local dev CORS and Trunk API proxy config (trunk serve proxying to api on different port)" title = "[TRIAGE] Local dev CORS and Trunk API proxy config (trunk serve proxying to api on different port)"
priority = 8 priority = 8
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b"] dependencies = []
+++ +++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
<question>
Local dev CORS and Trunk proxy config: during `trunk serve`, the UI runs on one port and the API on another. How do we handle cross-origin API calls in development?
</question>
<options>
1. **Trunk proxy** — configure Trunk to proxy `/api/*` requests to the API server. No CORS needed. Add to `Trunk.toml`.
2. **CORS middleware on API** — add `tower-http` CORS middleware to the Axum router, allowing localhost origins in development.
3. **Same-origin in production** — in production, both are served from the same Cloudflare account; in dev, use the Trunk proxy.
</options>
<resolution status="resolved">
**Chosen approach: Option 1 — Trunk proxy.**
Rationale:
- Mirrors the production architecture: Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site. No CORS configuration is needed in production either.
- Frontend uses **relative URLs** (`/api/quotes`, not `http://localhost:3000/api/quotes`). The same paths work in both dev (Trunk proxies them) and production (Cloudflare routes them).
- Zero CORS configuration: no `tower-http` CORS middleware, no `Access-Control-Allow-Origin` headers. Simpler API, smaller attack surface.
- Port: API runs on `localhost:3000` via `cargo run` (plain Axum/Tokio for local dev).
Implementation:
- `Trunk.toml` — add `[[proxy]] rewrite = "/api" backend = "http://localhost:3000"`. See ticket 00d6d7.
- `src/bin/ui/api.rs` — use relative URLs only. See ticket 1e6a09.
Updated tickets: dc3d2b (Trunk.toml setup), 1e6a09 (API client module).
Created ticket: 00d6d7 (dedicated implementation task for the proxy config).
</resolution>
<commit>
`chore(quotesdb): resolve triage — local-dev-cors-and-trunk-api-proxy-config-trunk-serve-proxyi`
</commit>

@ -1,7 +1,42 @@
+++ +++
title = "Test suite: GET /api/quotes/random — 200 with quote, 404 when database is empty" title = "Test suite: GET /api/quotes/random — 200 with quote, 404 when database is empty"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["ce1e4f", "9b581f", "2ce22e"] dependencies = ["9b581f", "2ce22e"]
+++ +++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
</context>
<goal>
Write the `GET /api/quotes/random` test suite in `tests/test_random_quote.rs` (or similar). Test cases:
1. 200 with a valid quote object when the database has quotes
2. 404 with `{"error": "..."}` when the database is empty
</goal>
<constraints>
- Use the shared test harness from ticket 9b581f.
- Test the 404 case against a fresh empty database (no seeded quotes).
- For the 200 case, seed at least one quote first.
</constraints>
<skills>
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`test(quotesdb): add GET /api/quotes/random test suite`
</commit>

@ -1,7 +1,40 @@
+++ +++
title = "Define Cloudflare Worker route/domain — worker.dev subdomain or custom route for API" title = "Define Cloudflare Worker route/domain — worker.dev subdomain or custom route for API"
priority = 6 priority = 6
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "a23489"] dependencies = ["a23489"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
The API Worker needs a publicly accessible route. This can be the default `*.workers.dev` subdomain or a custom route under `elijah.run`.
</context>
<goal>
Define the Cloudflare Worker route or subdomain in OpenTofu. Options:
1. Use the default `quotesdb.your-account.workers.dev` URL (no DNS record needed)
2. Define a `cloudflare_worker_route` resource for a custom subdomain (e.g. `api.quotes.elijah.run`)
Choose the simpler option first. Document the final API base URL in the project README.
</goal>
<constraints>
- The UI API client must know the API base URL — if a custom route is used, update the UI to point to it.
- If using a custom route, a `cloudflare_record` DNS entry may be needed.
- Every block must have a comment.
</constraints>
<validation>
Run from the `infra/` directory:
```sh
tofu validate
tofu plan
```
</validation>
<commit>
`feat(quotesdb): define Cloudflare Worker route/domain for API`
</commit>

@ -1,7 +1,68 @@
+++ +++
title = "Define Cloudflare Pages project resource — build config, output dir, git repo connection or artifact upload" title = "Define Cloudflare Pages project resource — build config, output dir, git repo connection or artifact upload"
priority = 7 priority = 7
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["25c413", "2d1371", "fc9bfd", "e2bd9b"] dependencies = ["2d1371", "fc9bfd"]
+++ +++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
Build strategy resolved in triage fc9bfd: **pre-built artifact + Gitea Actions + `wrangler pages deploy`**. Pages CI build is not used. The `cloudflare_pages_project` resource is configured for direct upload (no git connection). The actual artifact deployment is handled by `.gitea/workflows/deploy-ui.yml` (ticket 3781c9).
</context>
<goal>
Define the Cloudflare Pages project resource in `infra/pages.tf`:
1. `cloudflare_pages_project` resource named `quotesdb-ui`
2. Set `production_branch = "quotesdb"` (the integration branch)
3. Configure `deployment_configs` with `production.compatibility_date` and `production.d1_databases` if needed
4. Do NOT configure a git source block — this project uses direct upload
Every block must have a comment explaining its purpose.
</goal>
<constraints>
- Do NOT add a `source` block to the Pages project (no git-connected build — direct upload only).
- SPA routing (triage e2bd9b) is resolved: a `_redirects` file (`/* /index.html 200`) is included
in the Trunk build output via `<link data-trunk rel="copy-file" href="_redirects"/>` (ticket 9ef703).
No changes are needed in the OpenTofu Pages resource — Cloudflare Pages processes `_redirects`
automatically from the uploaded `dist/` directory.
- The output directory (`dist/`) is a Trunk convention; it is documented here for reference but not configured in OpenTofu (wrangler handles it at deploy time).
- The Pages project name `quotesdb-ui` must match the name used in `wrangler pages deploy --project-name quotesdb-ui`.
</constraints>
<reference>
```hcl
# infra/pages.tf
# Cloudflare Pages project for the quotesdb Yew/Wasm frontend.
# Uses direct upload — artifacts are deployed via wrangler in Gitea Actions (ticket 3781c9).
resource "cloudflare_pages_project" "ui" {
account_id = var.cloudflare_account_id
name = "quotesdb-ui"
production_branch = "quotesdb"
# Deployment configuration for the production environment.
deployment_configs {
production {
compatibility_date = "2024-01-01"
# SPA routing: handled by dist/_redirects (/* /index.html 200) — see ticket 9ef703.
}
}
}
```
</reference>
<validation>
Run from the `infra/` directory:
```sh
tofu validate
tofu plan
```
</validation>
<commit>
`feat(quotesdb): define Cloudflare Pages project resource in OpenTofu`
</commit>

@ -1,7 +1,69 @@
+++ +++
title = "Document local dev environment — Turso/SQLite instead of D1, any wrangler.toml config required" title = "Write docs/LOCAL_DEV.md — local dev quickstart (cargo run + trunk serve, rusqlite, DATABASE_URL)"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "33ed29"] dependencies = ["33ed29"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file (development).
TRIAGE 33ed29 resolved the local dev database strategy: **plain rusqlite with a local SQLite file**.
No Turso, no wrangler, no Cloudflare account required for local development.
Selection is **compile-time** via `cfg(target_arch = "wasm32")`:
- `wasm32` → workers-rs D1 bindings (production)
- native → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file (dev/test)
The native `main()` (see ticket 6e829e) reads `DATABASE_URL` from the environment, defaulting to
`./quotesdb.sqlite`, and calls `repo.run_migrations()` on startup to create tables if they don't exist.
</context>
<goal>
Write `docs/LOCAL_DEV.md` explaining how to set up and run the quotesdb project locally:
1. **Prerequisites** — Rust (via Nix flake), no Cloudflare account needed
2. **Running the API**:
- `cargo run` from the `quotesdb/` directory
- Listens on `localhost:3000`
- Creates `./quotesdb.sqlite` automatically on first run
- Override DB path: `DATABASE_URL=/path/to/db.sqlite cargo run`
3. **Running the UI**:
- `trunk serve` from the `quotesdb/` directory
- Listens on `localhost:8080`
- Proxies `/api/*` to `localhost:3000` (see Trunk.toml `[[proxy]]` block)
4. **Environment variables**:
- `DATABASE_URL` — path to SQLite file (optional, default: `./quotesdb.sqlite`)
- No other variables required for local dev
5. **Local dev workflow** (two terminals):
```sh
# Terminal 1 — API
cargo run
# Terminal 2 — UI
trunk serve
# Open http://localhost:8080
```
6. **No wrangler required**`cargo run` uses the native Axum server with rusqlite directly.
Wrangler is only needed for Workers deployment (handled by CI/infra).
7. **Database notes**:
- Schema is applied automatically via `run_migrations()` on first `cargo run`
- Delete `./quotesdb.sqlite` to start fresh
- `sqlite3 ./quotesdb.sqlite` for manual inspection
</goal>
<decisions-reflected>
- TRIAGE 33ed29: rusqlite + local SQLite file (not Turso, not wrangler dev)
- TRIAGE a9534d: Trunk proxy for `/api/*` (not CORS middleware)
- TRIAGE e8a330: no SQLx, `cfg(target_arch)` split
</decisions-reflected>
<constraints>
- Do not document `.env` files directly — list the env vars and their defaults, but note that `.env` is gitignored.
- Cross-reference tickets 6e829e (api main.rs), dc3d2b (Trunk.toml), and 00aff0 (DB abstraction).
- Keep it concise — it's a quickstart, not exhaustive reference docs.
</constraints>
<commit>
`docs(quotesdb): write LOCAL_DEV.md — local dev quickstart for api and ui`
</commit>

@ -0,0 +1,62 @@
+++
title = "Fix compiler warnings in api and ui binaries"
priority = 7
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
Running `cargo build --target wasm32-unknown-unknown` (and `trunk build`) produces compiler warnings in both the `api` and `ui` binaries. All warnings should be resolved so the build is clean.
## Warnings
Reproduce with:
```sh
# From quotesdb/ root
cargo build --target wasm32-unknown-unknown 2>&1 | grep -E 'warning\[|warning: (fields|unused|duplicated)'
```
### UI binary (1 warning)
**`src/bin/ui/api.rs:21:9`** — `fields page and total_count are never read`
The `QuotesResponse` struct (or equivalent) has `page` and `total_count` fields that are deserialized from the API but never read by any UI code. Either:
- Remove the fields if they are genuinely unused, or
- Add `#[allow(dead_code)]` with a comment explaining they are reserved for future pagination UI, or
- Actually use them (e.g. pass `total_count` to the browse page for "X quotes total" display)
Preferred fix: use them or remove them. Avoid bare `#[allow(dead_code)]` without justification.
### API binary (2 warnings)
**`src/bin/api/db/d1.rs:9:8`** — `duplicated attribute`
A `#[cfg(...)]` or other attribute is duplicated on the same item. Remove the duplicate.
**`src/bin/api/db/mod.rs:27:9`** — `unused import: d1::D1Repository`
`D1Repository` is imported but never used in this module. Remove the import.
## Files
- `src/bin/ui/api.rs` (line 21)
- `src/bin/api/db/d1.rs` (line 9)
- `src/bin/api/db/mod.rs` (line 27)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
# Confirm zero warnings for ui binary
cargo build --target wasm32-unknown-unknown 2>&1 | grep 'warning:.*generated'
# Expected: no output (or "0 warnings")
```
Also run `trunk build` and confirm no warnings are emitted for the `quotesdb` crate (dependency warnings from third-party crates are acceptable).
## Commit scope
`fix(quotesdb): resolve compiler warnings in api and ui`

@ -1,7 +1,50 @@
+++ +++
title = "Implement DELETE /api/quotes/:id — verify X-Auth-Code, cascade delete quote and tags, return 204" title = "Implement DELETE /api/quotes/:id — verify X-Auth-Code, cascade delete quote and tags, return 204"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["f3dc74", "a5049d", "d792e2"] dependencies = ["a5049d", "d792e2"]
+++ +++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
`DELETE /api/quotes/:id` permanently deletes a quote. The caller must provide the correct auth code via the `X-Auth-Code` header. On success, returns 204 No Content. The `quote_tags` rows cascade-delete automatically via the foreign key constraint.
</context>
<goal>
Implement the `DELETE /api/quotes/:id` handler:
1. Extract `:id` from the path
2. Verify the `X-Auth-Code` header matches the stored `auth_code` — return 403 on mismatch
3. DELETE the quote row (cascade handles tag deletion)
4. Return 204 No Content on success
</goal>
<constraints>
- Return 404 if the quote ID does not exist.
- Return 403 on auth code mismatch.
- No response body on 204.
- The `quote_tags` cascade delete is handled by the schema — do not manually delete tags.
</constraints>
<skills>
Use `superpowers:test-driven-development` — write tests for: valid auth 204, wrong auth 403, not found 404, verify cascade deletes tags.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
Run in order from the `quotesdb/` directory:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
</validation>
<commit>
`feat(quotesdb): implement DELETE /api/quotes/:id with auth verification`
</commit>

@ -0,0 +1,27 @@
+++
title = "quotesdb/ui: add footer with contact email"
priority = 3
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Summary
Add a footer to every page in the quotesdb UI displaying a contact email address.
## Details
- Footer text: "Contact: quotes@elijah.run"
- Should appear at the bottom of every page/route
- Style consistently with the rest of the site (minimal, unobtrusive)
- Make the email a clickable mailto: link
## Acceptance Criteria
- [ ] Footer is visible on all routes (/, /browse, /quotes/:id, /author/:name, /submit, /admin)
- [ ] Email is a mailto: link
- [ ] Styling is consistent with site theme
## Validation
Run from quotesdb/ root:
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -0,0 +1,224 @@
+++
title = "Refactor to single-crate with api and ui binaries"
priority = 8
status = "done"
ticket_type = "task"
dependencies = ["ec118c"]
+++
<goal>
Collapse the three separate sub-crates (`api/`, `ui/`, `tests/`) into a single Cargo crate rooted at `quotesdb/`. This simplifies the project structure, enables direct code sharing between the api and ui via `src/lib.rs`, and makes `cargo test` run all tests (unit + integration) in a single invocation.
**Status: done.** This ticket is kept for historical reference.
</goal>
<current-state>
```
quotesdb/
├── api/ # independent crate "quotesdb-api"
│ ├── Cargo.toml
│ ├── src/main.rs
│ ├── src/tests.rs
│ ├── docs/
│ └── README.md
├── ui/ # independent crate "quotesdb-ui"
│ ├── Cargo.toml
│ ├── src/main.rs
│ ├── src/tests.rs
│ ├── index.html
│ ├── Trunk.toml
│ ├── docs/
│ └── README.md
├── tests/ # independent crate "quotesdb-tests"
│ ├── Cargo.toml
│ ├── docs/
│ └── README.md
├── infra/
└── docs/
```
Problems with the old structure:
- Shared types/logic must go through `../../common` — no quotesdb-specific shared code.
- Running tests requires `cd`ing into each sub-crate separately.
- Three `Cargo.toml` files to maintain, three `cargo fmt/check/clippy` invocations.
- Trunk must be run from `ui/`, not the project root.
</current-state>
<target-state>
```
quotesdb/
├── Cargo.toml # single crate "quotesdb", default-run = "api"
├── src/
│ ├── lib.rs # shared code (types, models, auth logic, etc.)
│ └── bin/
│ ├── api/
│ │ └── main.rs # api binary entrypoint
│ └── ui/
│ └── main.rs # ui binary entrypoint (for Trunk)
├── tests/ # integration tests — run by `cargo test`
│ └── (*.rs files)
├── index.html # Trunk HTML entry (moved from ui/)
├── Trunk.toml # updated to point to ui binary
├── infra/
└── docs/
├── PLANNING.md
├── ARCHITECTURE.md
└── plans/
└── 2026-02-27-quotesdb-design.md
```
Developer workflow after refactor (unchanged from user perspective):
- `cargo run` — starts the API server (default binary is `api`)
- `trunk serve` — compiles ui to Wasm and serves it
- `cargo test` — runs unit tests + integration tests
</target-state>
<changes>
### 1. Create `quotesdb/Cargo.toml`
Single crate manifest with:
- `name = "quotesdb"`
- `default-run = "api"` — ensures `cargo run` launches the api
- `edition = "2021"`, `license = "MIT OR Apache-2.0"`
- `[profile.release]` block (opt-level z, lto, strip, codegen-units 1)
- All api dependencies (axum, tokio, workers-rs, sqlx, nanoid, etc.)
- All ui dependencies (yew, wasm-bindgen, web-sys, etc.)
- All test dependencies (reqwest, tokio, etc.) under `[dev-dependencies]`
- Two `[[bin]]` entries:
```toml
[[bin]]
name = "api"
path = "src/bin/api/main.rs"
[[bin]]
name = "ui"
path = "src/bin/ui/main.rs"
```
### 2. Create `src/lib.rs`
Shared module for code used by both binaries:
- Domain types: `Quote`, `QuoteTag`, pagination structs, request/response shapes
- Auth code generation (4-word passphrase) — shared so ui can display it and api generates it
- NanoID generation utility
- Any other shared logic
### 3. Move api source
- `api/src/main.rs``src/bin/api/main.rs`
- `api/src/tests.rs``src/bin/api/tests.rs` (or inline unit tests within the binary module)
- Delete `api/Cargo.toml`
### 4. Move ui source
- `ui/src/main.rs``src/bin/ui/main.rs`
- `ui/src/tests.rs``src/bin/ui/tests.rs`
- Delete `ui/Cargo.toml`
### 5. Move Trunk files
- `ui/index.html``quotesdb/index.html`
- `ui/Trunk.toml``quotesdb/Trunk.toml`
Update `Trunk.toml` to explicitly name the ui binary:
```toml
[build]
target = "index.html"
[build.cargo]
args = ["--bin", "ui"]
```
### 6. Move integration tests
- Content from `tests/` sub-crate moves into `quotesdb/tests/` as `.rs` files (standard Cargo integration test layout).
- Delete `tests/Cargo.toml`.
- Integration tests import from the crate root (`use quotesdb::...`) and from dev-dependencies.
- They run with `cargo test` automatically — no separate crate needed.
### 7. Consolidate docs
Merge per-sub-crate docs into the project-level `docs/` directory:
- `api/docs/PLANNING.md` and `ui/docs/PLANNING.md` → merge into `docs/PLANNING.md`
- `api/docs/ARCHITECTURE.md` and `ui/docs/ARCHITECTURE.md` → merge into `docs/ARCHITECTURE.md`
- `api/README.md` and `ui/README.md` and `tests/README.md` → consolidate into `README.md`
- Delete the now-empty `api/docs/`, `ui/docs/`, `tests/docs/` directories.
### 8. Update `CLAUDE.md`
Update `quotesdb/CLAUDE.md` to reflect:
- New directory structure (single crate, not three sub-crates)
- New validation commands run from `quotesdb/` root, not from sub-directories
- Updated branch naming and ticket hierarchy (the sub-project split is now logical, not a file-system split)
- Updated agent dispatch instructions (agents work in `src/bin/api/` or `src/bin/ui/`, not separate crates)
### 9. Delete orphaned sub-crate roots
After moving all contents:
- Delete `api/` directory entirely
- Delete `ui/` directory entirely
- Delete `tests/` old sub-crate directory (but `quotesdb/tests/` integration test files stay)
</changes>
<constraints>
### Compilation targets
The `api` binary compiles for the **host** target during local dev (`cargo run`). The `ui` binary compiles for `wasm32-unknown-unknown` via Trunk. These are separate compilation invocations — they don't conflict in a single Cargo crate.
**Shared code in `src/lib.rs` must compile for both targets.** Avoid host-only APIs (threading, filesystem) in `lib.rs`. Use `#[cfg(target_arch = "wasm32")]` and `#[cfg(not(target_arch = "wasm32"))]` guards where needed.
### `cargo test` and Wasm
`cargo test` runs on the host target. The `ui` binary's tests cannot use DOM/browser APIs directly. Yew component tests that require a browser context must use `wasm-bindgen-test` and `wasm-pack test` — these cannot be run by `cargo test`. Therefore:
- Unit tests in `src/bin/ui/` must be limited to pure logic (routing, data transformations, API client request construction) guarded with `#[cfg(test)]`.
- Browser-only tests (component rendering) are out of scope for `cargo test` and remain a future concern.
- Integration tests in `tests/` exercise the **api** only and run on the host — these work fine with `cargo test`.
### workers-rs and local dev
The api uses `workers-rs` for Cloudflare Workers deployment. For local development `cargo run`, the api should either:
- Use a plain Axum server (conditional compilation: `#[cfg(not(target_env = "worker"))]`), OR
- Use the workers-rs local dev entrypoint.
### Dependency conflicts
Some dependencies may not compile for all targets. Use `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` for api-only deps and `[target.'cfg(target_arch = "wasm32")'.dependencies]` for ui-only deps in `Cargo.toml` where needed.
</constraints>
<validation>
From `quotesdb/` root:
```sh
cargo fmt # must pass cleanly
cargo check # must pass for host target
cargo clippy # must pass with no warnings
cargo test # must run and pass all tests (unit + integration)
trunk build # must successfully compile the ui binary to wasm
```
</validation>
<summary>
| Action | Path |
|--------|------|
| CREATE | `quotesdb/Cargo.toml` |
| CREATE | `quotesdb/src/lib.rs` |
| MOVE | `api/src/main.rs``src/bin/api/main.rs` |
| MOVE | `api/src/tests.rs``src/bin/api/tests.rs` |
| MOVE | `ui/src/main.rs``src/bin/ui/main.rs` |
| MOVE | `ui/src/tests.rs``src/bin/ui/tests.rs` |
| MOVE | `ui/index.html``index.html` |
| MOVE | `ui/Trunk.toml``Trunk.toml` (update `--bin ui`) |
| MERGE | `api/docs/` + `ui/docs/` + `tests/docs/``docs/` |
| MERGE | `api/README.md`, `ui/README.md`, `tests/README.md``README.md` |
| DELETE | `api/Cargo.toml` |
| DELETE | `ui/Cargo.toml` |
| DELETE | `tests/Cargo.toml` |
| DELETE | `api/` (after moving contents) |
| DELETE | `ui/` (after moving contents) |
| UPDATE | `Trunk.toml` (add `[build.cargo] args = ["--bin", "ui"]`) |
| UPDATE | `quotesdb/CLAUDE.md` (structure, validation paths, agent instructions) |
</summary>
<commit>
`refactor(quotesdb): collapse to single crate with api and ui binaries`
</commit>

@ -1,7 +1,40 @@
+++ +++
title = "Implement Author page (/author/:name) — paginated list of quotes by a single author" title = "Implement Author page (/author/:name) — paginated list of quotes by a single author"
priority = 5 priority = 5
status = "todo" status = "done"
ticket_type = "task" ticket_type = "task"
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"] dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
+++ +++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
The Author page (`/author/:name`) shows all quotes by a specific author, paginated.
</context>
<goal>
Implement the Author page component (`src/bin/ui/pages/author.rs`):
1. Extract `:name` from the route
2. Fetch quotes from `GET /api/quotes?author=:name&page=N`
3. Render the author name as a heading
4. Render each quote with `QuoteCard`
5. Render `Pagination` for prev/next navigation
6. Render `ErrorDisplay` on error
</goal>
<constraints>
- Author name in the URL may be URL-encoded — decode it before using in the API call and heading.
- Page is tracked in the URL query string (`?page=N`).
</constraints>
<validation>
From the `quotesdb/` directory:
```sh
trunk build
```
</validation>
<commit>
`feat(quotesdb): implement Author page — paginated quotes by author`
</commit>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save