diff --git a/flake.lock b/flake.lock
index 32e04e8..0197e99 100644
--- a/flake.lock
+++ b/flake.lock
@@ -51,11 +51,11 @@
},
"nixpkgs_2": {
"locked": {
- "lastModified": 1772173633,
- "narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=",
+ "lastModified": 1772419343,
+ "narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6",
+ "rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9",
"type": "github"
},
"original": {
diff --git a/quotesdb/.nbd/tickets/00aff0.md b/quotesdb/.nbd/tickets/00aff0.md
new file mode 100644
index 0000000..4455782
--- /dev/null
+++ b/quotesdb/.nbd/tickets/00aff0.md
@@ -0,0 +1,343 @@
++++
+title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls"
+priority = 8
+status = "todo"
+ticket_type = "task"
+dependencies = []
++++
+
+
+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.
+
+
+
+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
+
+
+
+
+## 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(...) — 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,
+ 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;
+
+ async fn get_quote(&self, id: &str) -> Result
+
+
+- `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.
+
+
+
+- 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)
+
+
+
+Use `superpowers:test-driven-development` — write `NativeRepository` tests before implementing query methods.
+Use `superpowers:verification-before-completion` before closing.
+
+
+
+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
+```
+
+
+
+`feat(quotesdb): implement QuoteRepository trait and cfg-split D1/rusqlite DB abstraction`
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/00d6d7.md b/quotesdb/.nbd/tickets/00d6d7.md
new file mode 100644
index 0000000..9247c7d
--- /dev/null
+++ b/quotesdb/.nbd/tickets/00d6d7.md
@@ -0,0 +1,95 @@
++++
+title = "Add Trunk proxy config to Trunk.toml: forward /api/* to local API server"
+priority = 7
+status = "todo"
+ticket_type = "task"
+dependencies = ["a9534d"]
++++
+
+
+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.
+
+
+
+**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.
+
+
+
+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).
+
+
+
+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.
+
+
+
+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.
+
+
+
+- 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.
+
+
+
+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
+```
+
+
+
+`chore(quotesdb): add Trunk proxy config to forward /api/* to local API server`
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/03bb91.md b/quotesdb/.nbd/tickets/03bb91.md
index 8773597..a13b2d9 100644
--- a/quotesdb/.nbd/tickets/03bb91.md
+++ b/quotesdb/.nbd/tickets/03bb91.md
@@ -3,7 +3,7 @@ title = "Implement 4-word passphrase auth_code generator (must work in WASM/work
priority = 7
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "1f5bb5", "6ed325"]
+dependencies = ["1f5bb5", "6ed325"]
+++
@@ -11,21 +11,138 @@ The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via
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. The generator must compile and run in both the native host environment and the `wasm32-unknown-unknown` target (workers-rs).
+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.
-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 the type. The chosen word list crate must support `no_std` or at minimum compile for `wasm32-unknown-unknown`.
+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.
+
+
+## 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:
+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::>()
+ .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 = (0..20).map(|_| generate_auth_code()).collect();
+ assert!(
+ codes.len() > 10,
+ "expected >10 unique codes in 20 samples, got {}",
+ codes.len()
+ );
+ }
+}
+```
+
+
+
-- Resolve TRIAGE ticket 6ed325 (passphrase crate selection) before choosing the dependency.
-- Must compile for both host (`cargo check`) and `wasm32` (`trunk build`).
-- Do not use `std::fs` or thread-based RNG in shared code — use a WASM-compatible RNG (e.g. `getrandom` with the `js` feature).
+- `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
-Use `superpowers:test-driven-development` — write a unit test that generates 100 codes and verifies each matches `word-word-word-word` format.
+Use `superpowers:test-driven-development` — write the unit tests (step 4) before implementing (step 3).
Use `superpowers:verification-before-completion` before closing.
diff --git a/quotesdb/.nbd/tickets/04f865.md b/quotesdb/.nbd/tickets/04f865.md
index f1db407..440d8bb 100644
--- a/quotesdb/.nbd/tickets/04f865.md
+++ b/quotesdb/.nbd/tickets/04f865.md
@@ -3,7 +3,7 @@ title = "Implement ui/src/main.rs — Yew app shell, BrowserRouter, route defini
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e", "dc3d2b"]
+dependencies = ["93515e", "dc3d2b", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/05f8ae.md b/quotesdb/.nbd/tickets/05f8ae.md
index c5b2f75..9629cdc 100644
--- a/quotesdb/.nbd/tickets/05f8ae.md
+++ b/quotesdb/.nbd/tickets/05f8ae.md
@@ -1,9 +1,9 @@
+++
-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
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d", "d792e2", "03bb91", "175382", "6f2e18"]
+dependencies = ["a5049d", "d792e2", "03bb91", "175382", "7a0d9f"]
+++
@@ -20,7 +20,7 @@ Response 201: `{ quote: {...}, auth_code: "word-word-word-word" }`
Implement the `PUT /api/quotes` handler:
1. Deserialise and validate the request body (text and author are required)
-2. Generate a NanoID for the quote ID
+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`
@@ -29,7 +29,7 @@ Implement the `PUT /api/quotes` handler:
- Return 422 if `text` or `author` is missing or empty.
-- NanoID generation must be WASM-compatible (see TRIAGE ticket 6f2e18).
+- 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).
@@ -51,5 +51,5 @@ cargo test
-`feat(quotesdb): implement PUT /api/quotes — create quote with NanoID and auth_code`
+`feat(quotesdb): implement PUT /api/quotes — create quote with UUID v4 and auth_code`
diff --git a/quotesdb/.nbd/tickets/07cafb.md b/quotesdb/.nbd/tickets/07cafb.md
index f7e7c81..e20b86f 100644
--- a/quotesdb/.nbd/tickets/07cafb.md
+++ b/quotesdb/.nbd/tickets/07cafb.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] D1 binding chicken-and-egg — D1 ID not known until after apply, but Worker needs it at plan time"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["25c413"]
+dependencies = []
+++
@@ -20,6 +20,43 @@ D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu ap
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.
+
+**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
+
+
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.
@@ -27,5 +64,5 @@ D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu ap
-`chore(quotesdb): resolve triage — d1-binding-chickenandegg-d1-id-not-known-until-after-apply-b`
+`chore(quotesdb): resolve triage — d1-binding-standard-attribute-reference-no-chicken-and-egg`
diff --git a/quotesdb/.nbd/tickets/07feaa.md b/quotesdb/.nbd/tickets/07feaa.md
index a007a16..8dc9d00 100644
--- a/quotesdb/.nbd/tickets/07feaa.md
+++ b/quotesdb/.nbd/tickets/07feaa.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] OpenTofu state backend — local file (gitignored) vs Terraform Cloud vs Cloudflare R2?"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["25c413"]
+dependencies = []
+++
@@ -21,9 +21,19 @@ OpenTofu state backend: should the `.tfstate` file be stored locally (gitignored
-1. Research the options above and choose the best approach for this project.
-2. Set the chosen backend in `infra/terraform.tf`. Update `infra/.gitignore` if using local state. Document the decision in `infra/README.md`.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**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.
diff --git a/quotesdb/.nbd/tickets/08af7a.md b/quotesdb/.nbd/tickets/08af7a.md
index 3b39c45..17123c6 100644
--- a/quotesdb/.nbd/tickets/08af7a.md
+++ b/quotesdb/.nbd/tickets/08af7a.md
@@ -3,7 +3,7 @@ title = "Write api/README.md, api/docs/PLANNING.md, api/docs/ARCHITECTURE.md"
priority = 3
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a6bce1"]
+dependencies = ["a6bce1"]
+++
diff --git a/quotesdb/.nbd/tickets/0bc655.md b/quotesdb/.nbd/tickets/0bc655.md
index fb14d5c..8a9ce3c 100644
--- a/quotesdb/.nbd/tickets/0bc655.md
+++ b/quotesdb/.nbd/tickets/0bc655.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Auth code storage strategy — localStorage persistence vs component-only state?"
priority = 7
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["c3503b"]
+dependencies = []
+++
@@ -21,9 +21,33 @@ Auth code storage strategy for the UI: should the auth code be stored in localSt
-1. Research the options above and choose the best approach for this project.
-2. Update the `AuthModal` component (ticket f850c6) with the chosen strategy. If localStorage is chosen, implement a clear-on-delete path.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**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` 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.
diff --git a/quotesdb/.nbd/tickets/0d84fa.md b/quotesdb/.nbd/tickets/0d84fa.md
index 95eef9f..0b80486 100644
--- a/quotesdb/.nbd/tickets/0d84fa.md
+++ b/quotesdb/.nbd/tickets/0d84fa.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] HTTP client selection for integration tests — reqwest vs hyper vs ureq (tokio vs blocking)"
priority = 7
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["ce1e4f"]
+dependencies = []
+++
@@ -21,9 +21,22 @@ HTTP client for integration tests: should we use reqwest (async, tokio), hyper (
-1. Research the options above and choose the best approach for this project.
-2. Add the chosen crate to `[dev-dependencies]` in `Cargo.toml`. Update ticket 5f5ba0.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**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::()` 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.
diff --git a/quotesdb/.nbd/tickets/0d987f.md b/quotesdb/.nbd/tickets/0d987f.md
index 06ebf19..030abe2 100644
--- a/quotesdb/.nbd/tickets/0d987f.md
+++ b/quotesdb/.nbd/tickets/0d987f.md
@@ -3,7 +3,7 @@ title = "Implement shared QuoteCard component — displays text, author, source,
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e"]
+dependencies = ["93515e", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/0fbdd5.md b/quotesdb/.nbd/tickets/0fbdd5.md
new file mode 100644
index 0000000..7ada38f
--- /dev/null
+++ b/quotesdb/.nbd/tickets/0fbdd5.md
@@ -0,0 +1,89 @@
++++
+title = "Write src/bin/ui/style.css — full stylesheet for all UI pages and components"
+priority = 6
+status = "todo"
+ticket_type = "task"
+dependencies = ["dc3d2b"]
++++
+
+
+CSS approach resolved in triage 5e3e37: **plain CSS** — a single `src/bin/ui/style.css` file linked from `index.html` via ``.
+
+All UI component tickets use BEM-style class names defined in this file. No CSS-in-Rust crates, no CDN Tailwind.
+
+
+
+Write `src/bin/ui/style.css` covering all pages and components in the Yew UI.
+
+
+
+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` |
+
+
+
+
+- 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).
+
+
+
+In Yew components, use class names as string literals:
+
+```rust
+html! {
+
+
{ "e.text }
+ { "e.author }
+
+}
+```
+
+For conditional classes use the `classes!` macro:
+
+```rust
+html! {
+
+}
+```
+
+
+
+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.
+
+
+
+`style(quotesdb): add UI stylesheet with BEM component classes`
+
diff --git a/quotesdb/.nbd/tickets/166996.md b/quotesdb/.nbd/tickets/166996.md
index 4e3714b..7ea2ed2 100644
--- a/quotesdb/.nbd/tickets/166996.md
+++ b/quotesdb/.nbd/tickets/166996.md
@@ -1,11 +1,10 @@
+++
title = "[TRIAGE] Yew version selection and yew-router compatibility (0.21+?)"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["c3503b"]
+dependencies = []
+++
-
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
@@ -14,18 +13,20 @@ This is a triage decision ticket. It must be resolved before dependent implement
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?
-
-1. **Yew 0.21 + yew-router 0.18** — latest stable as of early 2026. Check crates.io for current versions.
-2. **Yew 0.20** — previous stable, more documentation available.
-3. **Check nixpkgs** — the Nix dev shell may pin a specific version via rust-overlay.
-
-
-1. Research the options above and choose the best approach for this project.
-2. Pin the chosen versions in `Cargo.toml`. Update ticket 93515e. Document the version in `docs/ARCHITECTURE.md`.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**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"
`chore(quotesdb): resolve triage — yew-version-selection-and-yewrouter-compatibility-021`
-
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/175382.md b/quotesdb/.nbd/tickets/175382.md
index a5508f6..18067fd 100644
--- a/quotesdb/.nbd/tickets/175382.md
+++ b/quotesdb/.nbd/tickets/175382.md
@@ -3,7 +3,7 @@ title = "Implement tag join logic — fetch tags per quote, insert/replace tags
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d"]
+dependencies = ["a5049d"]
+++
diff --git a/quotesdb/.nbd/tickets/182210.md b/quotesdb/.nbd/tickets/182210.md
index 907287d..e0d1910 100644
--- a/quotesdb/.nbd/tickets/182210.md
+++ b/quotesdb/.nbd/tickets/182210.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Cloudflare Workers WASM size limit — free tier 1MB limit may require paid plan for Rust binary"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -20,11 +20,25 @@ Cloudflare Workers WASM size limit: the free tier has a 1MB Worker script size l
3. **Split the Worker** — serve static assets from Pages and keep the Worker API-only (fewer dependencies).
-
-1. Research the options above and choose the best approach for this project.
-2. Check the compiled `api` binary size with `trunk build --release` and `ls -lh`. Update the infra plan accordingly.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
-
+
+**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.
+
`chore(quotesdb): resolve triage — cloudflare-workers-wasm-size-limit-free-tier-1mb-limit-may-r`
diff --git a/quotesdb/.nbd/tickets/1a274d.md b/quotesdb/.nbd/tickets/1a274d.md
index 1942b1f..38526b1 100644
--- a/quotesdb/.nbd/tickets/1a274d.md
+++ b/quotesdb/.nbd/tickets/1a274d.md
@@ -3,7 +3,7 @@ title = "Implement Home page (/) — fetch and display random quote, 'Browse all
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "fc2f51"]
+dependencies = ["04f865", "1e6a09", "0d987f", "fc2f51", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/1ba523.md b/quotesdb/.nbd/tickets/1ba523.md
index be433a8..cdf4858 100644
--- a/quotesdb/.nbd/tickets/1ba523.md
+++ b/quotesdb/.nbd/tickets/1ba523.md
@@ -3,7 +3,7 @@ title = "Implement Submit page (/submit) — quote creation form, display return
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "04f865", "1e6a09", "fc2f51"]
+dependencies = ["04f865", "1e6a09", "fc2f51", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/1e6a09.md b/quotesdb/.nbd/tickets/1e6a09.md
index f8070a0..f78ad1c 100644
--- a/quotesdb/.nbd/tickets/1e6a09.md
+++ b/quotesdb/.nbd/tickets/1e6a09.md
@@ -3,7 +3,7 @@ title = "Implement API client module — typed fetch wrappers for all quotesdb-a
priority = 7
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e"]
+dependencies = ["93515e"]
+++
@@ -24,11 +24,21 @@ Implement `src/bin/ui/api.rs` (or `src/bin/ui/client.rs`) with async functions f
Each function sets the appropriate headers (including `X-Auth-Code` where needed) and deserialises the response.
+
+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.
+
+
-- Use `gloo::net::http` or `web_sys::fetch` for HTTP requests (not reqwest — not available in WASM).
-- Resolve TRIAGE ticket a9534d (CORS and Trunk proxy config) — during `trunk serve`, the API URL may differ.
+- 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.
-- The base URL should be configurable (env var at compile time or from `window.location.origin`).
+- Do NOT configure a base URL — relative URLs are sufficient and correct.
@@ -41,4 +51,4 @@ trunk build
`feat(quotesdb): implement typed API client module for all quotesdb-api endpoints`
-
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/1f5bb5.md b/quotesdb/.nbd/tickets/1f5bb5.md
index 9775fcd..adfd191 100644
--- a/quotesdb/.nbd/tickets/1f5bb5.md
+++ b/quotesdb/.nbd/tickets/1f5bb5.md
@@ -1,9 +1,9 @@
+++
-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
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a91260"]
+dependencies = ["7a0d9f"]
+++
@@ -17,10 +17,20 @@ Create `Cargo.toml` for the `quotesdb` crate with all API-side dependencies. Inc
-- `workers-rs` and Axum are API-only — gate them under `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`
+- `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`
-- Resolve TRIAGE tickets 6ed325 (passphrase crate) and 6f2e18 (NanoID crate) before finalising those dependency choices
+- **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]`.
diff --git a/quotesdb/.nbd/tickets/25c413.md b/quotesdb/.nbd/tickets/25c413.md
index 239fc76..b5b3db9 100644
--- a/quotesdb/.nbd/tickets/25c413.md
+++ b/quotesdb/.nbd/tickets/25c413.md
@@ -3,7 +3,7 @@ title = "quotesdb/infra"
priority = 7
status = "todo"
ticket_type = "project"
-dependencies = []
+dependencies = ["07feaa", "5c0c64", "fc9bfd", "07cafb", "e2bd9b", "efee79", "2d1371", "d0da0b", "a23489", "ae886f", "ae6a82", "657836", "75489a", "71b1d4", "d5839a", "3781c9", "5137d7", "57fe5e"]
+++
diff --git a/quotesdb/.nbd/tickets/28e7d9.md b/quotesdb/.nbd/tickets/28e7d9.md
index 1c52efb..fbe66a3 100644
--- a/quotesdb/.nbd/tickets/28e7d9.md
+++ b/quotesdb/.nbd/tickets/28e7d9.md
@@ -3,7 +3,7 @@ title = "Implement GET /api/ — serve OpenAPI spec as JSON"
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "1f5bb5", "2ec8b1"]
+dependencies = ["1f5bb5", "8892d5"]
+++
@@ -15,13 +15,35 @@ The `GET /api/` endpoint serves the OpenAPI 3.1.0 specification as JSON. This en
-Implement the `GET /api/` handler that returns the OpenAPI spec as `application/json`. The spec can be embedded at compile time using `include_str!("../../../api/openapi.yaml")` (or equivalent path) and parsed/re-serialised as JSON, or generated programmatically.
+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/`.
-- Resolve TRIAGE ticket 2ec8b1 (OpenAPI spec serving strategy) before choosing compile-time embed vs runtime load.
- The response `Content-Type` must be `application/json`.
-- The spec at `api/openapi.yaml` is the source of truth — validate it with `redocly lint api/openapi.yaml` after any changes.
+- 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.
diff --git a/quotesdb/.nbd/tickets/2ab7a8.md b/quotesdb/.nbd/tickets/2ab7a8.md
index 79aac35..df89959 100644
--- a/quotesdb/.nbd/tickets/2ab7a8.md
+++ b/quotesdb/.nbd/tickets/2ab7a8.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Test harness: how to import and start quotesdb-api in tests (workers-rs vs native build target)"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["ce1e4f"]
+dependencies = []
+++
@@ -20,11 +20,26 @@ Test harness: how do we import and start the quotesdb-api binary in integration
3. **Wrangler dev** — run `wrangler dev` in the background and point tests at it. Complex setup, slower CI.
-
-1. Research the options above and choose the best approach for this project.
-2. Update ticket 9b581f (test harness) and ticket 6e829e (api main.rs) with the chosen approach.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
-
+
+**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.
+
`chore(quotesdb): resolve triage — test-harness-how-to-import-and-start-quotesdbapi-in-tests-wo`
diff --git a/quotesdb/.nbd/tickets/2c5a57.md b/quotesdb/.nbd/tickets/2c5a57.md
index d113892..3599526 100644
--- a/quotesdb/.nbd/tickets/2c5a57.md
+++ b/quotesdb/.nbd/tickets/2c5a57.md
@@ -3,7 +3,7 @@ title = "Implement pagination component — prev/next buttons, current page indi
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e"]
+dependencies = ["93515e", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/2ce22e.md b/quotesdb/.nbd/tickets/2ce22e.md
index 8d1db33..e55cb32 100644
--- a/quotesdb/.nbd/tickets/2ce22e.md
+++ b/quotesdb/.nbd/tickets/2ce22e.md
@@ -3,7 +3,7 @@ title = "Implement GET /api/quotes/random — random row query (must be register
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
+dependencies = ["a5049d", "d792e2", "175382"]
+++
diff --git a/quotesdb/.nbd/tickets/2d1371.md b/quotesdb/.nbd/tickets/2d1371.md
index d796935..5e14515 100644
--- a/quotesdb/.nbd/tickets/2d1371.md
+++ b/quotesdb/.nbd/tickets/2d1371.md
@@ -3,7 +3,7 @@ title = "Set up infra/ OpenTofu project — providers.tf, terraform.tf, .gitigno
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "07feaa"]
+dependencies = ["07feaa"]
+++
@@ -13,15 +13,16 @@ Infrastructure is managed with OpenTofu using the Cloudflare provider. Configura
Bootstrap the OpenTofu project in `infra/`:
1. Create `infra/providers.tf` — declare the Cloudflare provider with the required version
-2. Create `infra/terraform.tf` — configure the OpenTofu backend (resolve TRIAGE ticket 07feaa for state backend choice)
-3. Create `infra/.gitignore` — ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`
+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
-- Resolve TRIAGE ticket 07feaa (state backend: local file vs Terraform Cloud vs R2) before creating `terraform.tf`.
+- 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.
diff --git a/quotesdb/.nbd/tickets/2ec8b1.md b/quotesdb/.nbd/tickets/2ec8b1.md
index 954640c..84f3cff 100644
--- a/quotesdb/.nbd/tickets/2ec8b1.md
+++ b/quotesdb/.nbd/tickets/2ec8b1.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] OpenAPI spec serving strategy — embed YAML at compile time vs load at runtime"
priority = 7
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -21,9 +21,32 @@ OpenAPI spec serving strategy: should the spec be embedded at compile time (incl
-1. Research the options above and choose the best approach for this project.
-2. Update ticket 28e7d9 (GET /api/ handler) with the chosen approach. If using utoipa, update `Cargo.toml`.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**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.
diff --git a/quotesdb/.nbd/tickets/33ed29.md b/quotesdb/.nbd/tickets/33ed29.md
index c67fea0..c09a0c6 100644
--- a/quotesdb/.nbd/tickets/33ed29.md
+++ b/quotesdb/.nbd/tickets/33ed29.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Local dev config: Turso (file SQLite) vs D1 binding selection strategy"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -20,12 +20,37 @@ Local dev config: should the API use Turso (file-backed SQLite via libsql) or a
3. **Plain SQLite via sqlx** — use sqlx's SQLite driver with a local file. No Turso dependency needed for dev.
-
-1. Research the options above and choose the best approach for this project.
-2. Update ticket a5049d (database connection module) and ticket af56a7 (local dev docs) with the chosen strategy.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+
+**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`
`chore(quotesdb): resolve triage — local-dev-config-turso-file-sqlite-vs-d1-binding-selection-s`
-
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/372790.md b/quotesdb/.nbd/tickets/372790.md
index 6bbeaed..c16a757 100644
--- a/quotesdb/.nbd/tickets/372790.md
+++ b/quotesdb/.nbd/tickets/372790.md
@@ -3,7 +3,7 @@ title = "Write ui/README.md, ui/docs/PLANNING.md, ui/docs/ARCHITECTURE.md"
priority = 3
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"]
+dependencies = ["1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"]
+++
diff --git a/quotesdb/.nbd/tickets/3781c9.md b/quotesdb/.nbd/tickets/3781c9.md
new file mode 100644
index 0000000..46e3cb4
--- /dev/null
+++ b/quotesdb/.nbd/tickets/3781c9.md
@@ -0,0 +1,85 @@
++++
+title = "Verify API worker gzipped binary size is within CF Workers free tier (3 MB limit)"
+priority = 5
+status = "todo"
+ticket_type = "task"
+dependencies = ["1f5bb5"]
++++
+
+
+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
+
+
+
+
+## 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. |
+| 2–3 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.
+
+
+
+
+
+```sh
+# From quotesdb/ directory:
+wrangler deploy --outdir bundled/ --dry-run
+# Confirm "gzip: X KiB" is < 3 MB (3072 KiB)
+```
+
+
+
+
+`chore(quotesdb): verify api worker binary size within cf workers 3mb free tier limit`
+
diff --git a/quotesdb/.nbd/tickets/4a4c26.md b/quotesdb/.nbd/tickets/4a4c26.md
index 488f0ae..43054da 100644
--- a/quotesdb/.nbd/tickets/4a4c26.md
+++ b/quotesdb/.nbd/tickets/4a4c26.md
@@ -3,7 +3,7 @@ title = "Test suite: PUT /api/quotes — create (auto auth_code, custom auth_cod
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "05f8ae"]
+dependencies = ["9b581f", "05f8ae"]
+++
diff --git a/quotesdb/.nbd/tickets/5137d7.md b/quotesdb/.nbd/tickets/5137d7.md
new file mode 100644
index 0000000..8458a70
--- /dev/null
+++ b/quotesdb/.nbd/tickets/5137d7.md
@@ -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 = "todo"
+ticket_type = "task"
+dependencies = ["ae886f", "dc3d2b"]
++++
+
+
+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.
+
+
+
+Create `.gitea/workflows/deploy-ui.yml` at the repository root (not inside `quotesdb/`).
+
+
+
+```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
+```
+
+
+
+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.
+
+
+
+- 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).
+
+
+
+- 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.
+
+
+
+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`
+
+
+
+`ci(quotesdb): add Gitea Actions workflow to build and deploy UI to Cloudflare Pages`
+
diff --git a/quotesdb/.nbd/tickets/5379eb.md b/quotesdb/.nbd/tickets/5379eb.md
new file mode 100644
index 0000000..beda484
--- /dev/null
+++ b/quotesdb/.nbd/tickets/5379eb.md
@@ -0,0 +1,114 @@
++++
+title = "Implement auth code session storage — utility module and AuthModal pre-fill integration"
+priority = 7
+status = "todo"
+ticket_type = "task"
+dependencies = []
++++
+
+
+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.
+
+
+
+**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 {
+ 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`
+prop. Pre-populate the `` 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,
+ pub on_cancel: Callback<()>,
+ pub initial_value: Option, // 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("e.id)` and pass it as
+ `initial_value` to `AuthModal`.
+- After a successful **edit** (POST /api/quotes/:id returns 200): call
+ `storage::set_auth_code("e.id, &submitted_code)`.
+- After a successful **delete** (DELETE /api/quotes/:id returns 204): call
+ `storage::clear_auth_code("e.id)`.
+- If the API returns 403 (wrong code): do NOT store the code; clear any existing stored value
+ with `storage::clear_auth_code("e.id)` so a stale code is not re-offered.
+
+
+
+- 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.
+
+
+
+```sh
+trunk build
+```
+
+
+
+`feat(quotesdb): implement auth code session storage utility and AuthModal pre-fill`
+
+
+quotesdb/ui
diff --git a/quotesdb/.nbd/tickets/57fe5e.md b/quotesdb/.nbd/tickets/57fe5e.md
new file mode 100644
index 0000000..3a66e3a
--- /dev/null
+++ b/quotesdb/.nbd/tickets/57fe5e.md
@@ -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 = "todo"
+ticket_type = "task"
+dependencies = ["a23489", "2d1371"]
++++
+
+
+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).
+
+
+
+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.
+
+
+
+```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
+```
+
+
+
+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.
+
+
+
+- `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.
+
+
+
+- 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.
+
+
+
+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
+
+
+
+`ci(quotesdb): add Gitea Actions workflow to build and deploy API Worker via OpenTofu`
+
diff --git a/quotesdb/.nbd/tickets/580e66.md b/quotesdb/.nbd/tickets/580e66.md
index 736fa9b..98276e4 100644
--- a/quotesdb/.nbd/tickets/580e66.md
+++ b/quotesdb/.nbd/tickets/580e66.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Database migration strategy for Cloudflare Workers (startup vs wrangler d1 execute)"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -20,10 +20,25 @@ Database migration strategy for Cloudflare Workers: how should the `quotes` and
3. **SQLx migrate! macro** — embed migrations in the binary and run them at startup. Depends on SQLx compatibility with workers-rs (see TRIAGE e8a330).
+
+**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.
+
+
-1. Research the options above and choose the best approach for this project.
-2. Update ticket a5049d (database connection + migrations) with the chosen strategy.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+- 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.
diff --git a/quotesdb/.nbd/tickets/5c0c64.md b/quotesdb/.nbd/tickets/5c0c64.md
index 58457cc..c714484 100644
--- a/quotesdb/.nbd/tickets/5c0c64.md
+++ b/quotesdb/.nbd/tickets/5c0c64.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] D1 migrations in OpenTofu — null_resource local-exec vs separate wrangler step vs manual"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["25c413"]
+dependencies = []
+++
@@ -20,10 +20,28 @@ D1 migrations in OpenTofu: how do we apply the SQL schema to a newly created D1
3. **API startup migration** — the API runs `CREATE TABLE IF NOT EXISTS` on startup. Works but risks schema drift in production.
+
+**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.
+
+
-1. Research the options above and choose the best approach for this project.
-2. Update ticket d0da0b (D1 resource), ticket a5049d (migrations module), and ticket 75489a (migration workflow docs) with the chosen strategy.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+- 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`.
diff --git a/quotesdb/.nbd/tickets/5cdbd9.md b/quotesdb/.nbd/tickets/5cdbd9.md
index 3c2cbe9..3229d6c 100644
--- a/quotesdb/.nbd/tickets/5cdbd9.md
+++ b/quotesdb/.nbd/tickets/5cdbd9.md
@@ -3,7 +3,7 @@ title = "Implement Browse page (/browse) — paginated quote list with author/ta
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"]
+dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/5d9f5a.md b/quotesdb/.nbd/tickets/5d9f5a.md
index 960e067..b425539 100644
--- a/quotesdb/.nbd/tickets/5d9f5a.md
+++ b/quotesdb/.nbd/tickets/5d9f5a.md
@@ -3,7 +3,7 @@ title = "Implement POST /api/quotes/:id — partial update, verify X-Auth-Code h
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
+dependencies = ["a5049d", "d792e2", "175382"]
+++
diff --git a/quotesdb/.nbd/tickets/5dbb7d.md b/quotesdb/.nbd/tickets/5dbb7d.md
index 1d41f95..a330e7f 100644
--- a/quotesdb/.nbd/tickets/5dbb7d.md
+++ b/quotesdb/.nbd/tickets/5dbb7d.md
@@ -3,7 +3,7 @@ title = "Implement GET /api/quotes/:id — fetch by NanoID, return 404 if not fo
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
+dependencies = ["a5049d", "d792e2", "175382"]
+++
diff --git a/quotesdb/.nbd/tickets/5e3e37.md b/quotesdb/.nbd/tickets/5e3e37.md
index 7f53397..d134c87 100644
--- a/quotesdb/.nbd/tickets/5e3e37.md
+++ b/quotesdb/.nbd/tickets/5e3e37.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] CSS/styling approach for Wasm — plain CSS, CDN Tailwind, or Wasm-compatible crate?"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["c3503b"]
+dependencies = []
+++
@@ -20,6 +20,22 @@ CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Ta
3. **Stylist or yew-style** — Rust crates for CSS-in-Wasm. More idiomatic but less documentation.
+
+**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 `` 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: ``
+- 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
+
+
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.
@@ -27,5 +43,5 @@ CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Ta
-`chore(quotesdb): resolve triage — cssstyling-approach-for-wasm-plain-css-cdn-tailwind-or-wasmc`
+`chore(quotesdb): resolve triage — css-styling-approach-plain-css`
diff --git a/quotesdb/.nbd/tickets/5f1112.md b/quotesdb/.nbd/tickets/5f1112.md
index 0103316..038ef88 100644
--- a/quotesdb/.nbd/tickets/5f1112.md
+++ b/quotesdb/.nbd/tickets/5f1112.md
@@ -3,7 +3,7 @@ title = "Implement Quote detail page (/quotes/:id) — view, edit form with auth
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "f850c6", "fc2f51"]
+dependencies = ["04f865", "1e6a09", "0d987f", "f850c6", "fc2f51", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/5f5ba0.md b/quotesdb/.nbd/tickets/5f5ba0.md
index 297e341..5016f47 100644
--- a/quotesdb/.nbd/tickets/5f5ba0.md
+++ b/quotesdb/.nbd/tickets/5f5ba0.md
@@ -3,21 +3,36 @@ title = "Set up tests/Cargo.toml with integration test dependencies (reqwest/hyp
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "0d84fa"]
+dependencies = ["0d84fa", "fba598"]
+++
-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.
+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
-Add integration test dependencies to `Cargo.toml` under `[dev-dependencies]`. Resolve TRIAGE ticket 0d84fa (HTTP client selection) first, then add the chosen HTTP client, plus `tokio` (test runtime), `serde_json`, and any other test utilities.
+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.
-- Resolve TRIAGE ticket 0d84fa (HTTP client: reqwest vs hyper vs ureq) before adding the dependency.
-- Integration tests in `tests/` run on the host target only — dev-dependencies do not need WASM compatibility.
+- 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::()` response deserialization.
@@ -36,5 +51,5 @@ cargo test
-`chore(quotesdb): set up integration test dependencies in Cargo.toml`
+`chore(quotesdb): add integration test dev-dependencies (reqwest, tokio, serde_json, tempfile)`
diff --git a/quotesdb/.nbd/tickets/657836.md b/quotesdb/.nbd/tickets/657836.md
index 94a240e..1c3c7ff 100644
--- a/quotesdb/.nbd/tickets/657836.md
+++ b/quotesdb/.nbd/tickets/657836.md
@@ -3,7 +3,7 @@ title = "Configure custom domain quotes.elijah.run → Cloudflare Pages (DNS rec
priority = 6
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "ae886f"]
+dependencies = ["ae886f"]
+++
diff --git a/quotesdb/.nbd/tickets/6e829e.md b/quotesdb/.nbd/tickets/6e829e.md
index 017dfe9..3d05a61 100644
--- a/quotesdb/.nbd/tickets/6e829e.md
+++ b/quotesdb/.nbd/tickets/6e829e.md
@@ -3,7 +3,7 @@ title = "Set up api/src/main.rs — Cloudflare Workers entry point and Axum rout
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "1f5bb5"]
+dependencies = ["1f5bb5"]
+++
diff --git a/quotesdb/.nbd/tickets/6ed325.md b/quotesdb/.nbd/tickets/6ed325.md
index 6e9d707..5e6715f 100644
--- a/quotesdb/.nbd/tickets/6ed325.md
+++ b/quotesdb/.nbd/tickets/6ed325.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] 4-word passphrase crate selection for WASM target (no_std/wasm32 constraints)"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -26,6 +26,53 @@ This is a triage decision ticket. It must be resolved before dependent implement
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+
+
+## 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 | 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)
+
+
+
`chore(quotesdb): resolve triage — 4word-passphrase-crate-selection-for-wasm-target-nostdwasm32`
diff --git a/quotesdb/.nbd/tickets/6f2e18.md b/quotesdb/.nbd/tickets/6f2e18.md
index 5944fe6..5cdf6ed 100644
--- a/quotesdb/.nbd/tickets/6f2e18.md
+++ b/quotesdb/.nbd/tickets/6f2e18.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] NanoID crate WASM compatibility with workers-rs target"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -21,9 +21,31 @@ NanoID crate WASM compatibility: does the chosen nanoid crate compile for wasm32
-1. Research the options above and choose the best approach for this project.
-2. Update ticket 05f8ae (PUT /api/quotes) and `Cargo.toml` (ticket 1f5bb5) with the chosen approach.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**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
diff --git a/quotesdb/.nbd/tickets/71b1d4.md b/quotesdb/.nbd/tickets/71b1d4.md
index 3d688c8..5912bea 100644
--- a/quotesdb/.nbd/tickets/71b1d4.md
+++ b/quotesdb/.nbd/tickets/71b1d4.md
@@ -3,7 +3,7 @@ title = "Document secrets management — Cloudflare API token, account ID, how t
priority = 6
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "2d1371"]
+dependencies = ["2d1371"]
+++
diff --git a/quotesdb/.nbd/tickets/75489a.md b/quotesdb/.nbd/tickets/75489a.md
index 8156c5a..4d5b354 100644
--- a/quotesdb/.nbd/tickets/75489a.md
+++ b/quotesdb/.nbd/tickets/75489a.md
@@ -3,28 +3,37 @@ title = "Document D1 schema migration workflow — how to apply SQL schema chang
priority = 7
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "d0da0b"]
+dependencies = ["d0da0b", "bb1514"]
+++
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 (e.g. `wrangler d1 execute` or a startup script). This workflow must be documented.
+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.
-Document the D1 schema migration workflow in `infra/README.md` or `docs/MIGRATIONS.md`:
-1. How to apply the initial schema SQL to D1 (`wrangler d1 execute --file schema.sql`)
-2. How to apply incremental migrations
-3. How to apply migrations in CI/CD
-4. Where the schema SQL file lives (e.g. `infra/schema.sql` or `src/migrations/`)
-5. Cross-reference the TRIAGE decision from ticket 5c0c64 (D1 migrations strategy)
+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
-- Resolve TRIAGE ticket 5c0c64 before writing this doc — the strategy determines the workflow.
+- 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.
-`docs(quotesdb): document D1 schema migration workflow`
+`docs(quotesdb): document D1 schema migration workflow in infra/README.md`
diff --git a/quotesdb/.nbd/tickets/75e3f0.md b/quotesdb/.nbd/tickets/75e3f0.md
index c52485b..4c3bddc 100644
--- a/quotesdb/.nbd/tickets/75e3f0.md
+++ b/quotesdb/.nbd/tickets/75e3f0.md
@@ -3,7 +3,7 @@ title = "Write tests/README.md"
priority = 3
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f"]
+dependencies = ["9b581f"]
+++
diff --git a/quotesdb/.nbd/tickets/789d0f.md b/quotesdb/.nbd/tickets/789d0f.md
index 15f9dc2..bc165f7 100644
--- a/quotesdb/.nbd/tickets/789d0f.md
+++ b/quotesdb/.nbd/tickets/789d0f.md
@@ -3,7 +3,7 @@ title = "Test suite: GET /api/ — OpenAPI spec returned as valid JSON with expe
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "28e7d9"]
+dependencies = ["9b581f", "28e7d9"]
+++
diff --git a/quotesdb/.nbd/tickets/7a0d9f.md b/quotesdb/.nbd/tickets/7a0d9f.md
new file mode 100644
index 0000000..2d59f6d
--- /dev/null
+++ b/quotesdb/.nbd/tickets/7a0d9f.md
@@ -0,0 +1,123 @@
++++
+title = "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs"
+priority = 8
+status = "todo"
+ticket_type = "task"
+dependencies = []
++++
+
+
+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.
+
+
+
+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)
+
+
+
+
+## 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()
+```
+
+
+
+
+- `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)
+
+
+
+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.
+
+
+
+Run in order from the `quotesdb/` directory:
+
+```sh
+cargo fmt
+cargo check
+cargo clippy
+cargo test
+```
+
+
+
+`feat(quotesdb): add generate_id() using UUID v4 — WASM-compatible ID generation`
+
diff --git a/quotesdb/.nbd/tickets/886bfd.md b/quotesdb/.nbd/tickets/886bfd.md
index 707e306..62625cd 100644
--- a/quotesdb/.nbd/tickets/886bfd.md
+++ b/quotesdb/.nbd/tickets/886bfd.md
@@ -3,7 +3,7 @@ title = "Implement GET /api/quotes — paginated list with author filter (case-i
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
+dependencies = ["a5049d", "d792e2", "175382"]
+++
diff --git a/quotesdb/.nbd/tickets/8892d5.md b/quotesdb/.nbd/tickets/8892d5.md
new file mode 100644
index 0000000..c3e3fc4
--- /dev/null
+++ b/quotesdb/.nbd/tickets/8892d5.md
@@ -0,0 +1,102 @@
++++
+title = "Add build.rs — convert api/openapi.yaml to JSON at compile time for Workers embed"
+priority = 8
+status = "todo"
+ticket_type = "task"
+dependencies = []
++++
+
+
+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).
+
+
+
+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
+```
+
+
+
+- `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`.
+
+
+
+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.
+
+
+
+```sh
+cargo fmt
+cargo check
+cargo clippy
+cargo test
+```
+
+
+
+`chore(quotesdb): add build.rs to convert api/openapi.yaml to JSON at compile time`
+
+
+quotesdb/api
diff --git a/quotesdb/.nbd/tickets/893eba.md b/quotesdb/.nbd/tickets/893eba.md
index 3ab1985..416225d 100644
--- a/quotesdb/.nbd/tickets/893eba.md
+++ b/quotesdb/.nbd/tickets/893eba.md
@@ -3,7 +3,7 @@ title = "Test suite: tag operations — create with tags, list by tag filter, up
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "175382"]
+dependencies = ["9b581f", "175382"]
+++
diff --git a/quotesdb/.nbd/tickets/8c87db.md b/quotesdb/.nbd/tickets/8c87db.md
index e77bfb6..e14a872 100644
--- a/quotesdb/.nbd/tickets/8c87db.md
+++ b/quotesdb/.nbd/tickets/8c87db.md
@@ -3,7 +3,7 @@ title = "Test suite: DELETE /api/quotes/:id — valid auth 204 no body, wrong au
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "b20b5a"]
+dependencies = ["9b581f", "b20b5a"]
+++
diff --git a/quotesdb/.nbd/tickets/93515e.md b/quotesdb/.nbd/tickets/93515e.md
index a0b637a..3539e0a 100644
--- a/quotesdb/.nbd/tickets/93515e.md
+++ b/quotesdb/.nbd/tickets/93515e.md
@@ -3,21 +3,33 @@ title = "Set up ui/Cargo.toml with Yew/Wasm dependencies (yew, yew-router, gloo,
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "166996"]
+dependencies = ["166996"]
+++
-
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).
-Add UI-side Yew/Wasm dependencies to `Cargo.toml` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. Resolve TRIAGE ticket 166996 (Yew version selection) first, then add: `yew`, `yew-router`, `gloo` (timers, fetch), `wasm-bindgen`, `web-sys`, `serde`, `serde_json`, and `wasm-bindgen-futures`.
+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.
-- Resolve TRIAGE ticket 166996 (Yew + yew-router version compatibility) before pinning versions.
-- All UI dependencies must be scoped to the wasm32 target — they must not appear in host builds.
-- `wasm-bindgen` version must be compatible with the `wasm-bindgen-cli` version in the Nix dev shell.
+- 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.
@@ -36,4 +48,4 @@ trunk build
`chore(quotesdb): set up ui Cargo dependencies for Yew/Wasm`
-
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/93f1b6.md b/quotesdb/.nbd/tickets/93f1b6.md
index 5401a7c..77f45d9 100644
--- a/quotesdb/.nbd/tickets/93f1b6.md
+++ b/quotesdb/.nbd/tickets/93f1b6.md
@@ -3,7 +3,7 @@ title = "Test suite: GET /api/quotes — pagination (page=1, page=N, out-of-rang
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "886bfd"]
+dependencies = ["9b581f", "886bfd"]
+++
diff --git a/quotesdb/.nbd/tickets/9b581f.md b/quotesdb/.nbd/tickets/9b581f.md
index 1bc699c..e6832b1 100644
--- a/quotesdb/.nbd/tickets/9b581f.md
+++ b/quotesdb/.nbd/tickets/9b581f.md
@@ -3,29 +3,102 @@ title = "Implement test server harness — spawn quotesdb-api with temp SQLite D
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "5f5ba0", "2ab7a8", "fba598"]
+dependencies = ["5f5ba0", "2ab7a8", "fba598"]
+++
-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.
+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`
-Implement a test server harness in `tests/helpers.rs` (or similar) that:
-1. Creates a temporary SQLite database file (or in-memory DB)
-2. Runs migrations to initialise the schema
-3. Spawns the `quotesdb-api` binary on a random available port
-4. Returns the base URL (e.g. `http://127.0.0.1:PORT`) for use in test functions
-5. Cleans up (drops DB, stops server) when the test ends
+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
+
+```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);
+}
+```
+
+
-- Resolve TRIAGE ticket 2ab7a8 (workers-rs test binary compatibility) and 33ed29 (local dev config) before implementing.
-- Resolve TRIAGE ticket fba598 (test isolation strategy: per-test DB vs transaction rollback) before deciding on isolation approach.
-- The harness must be reusable across all test modules — import it as a shared helper.
-- Each test must get a clean, isolated database state (no cross-test pollution).
+- `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).
+
+In `[dev-dependencies]` (ticket 5f5ba0):
+- `tempfile = "3"`
+- `reqwest = { version = "0.12", features = ["json"] }`
+- `tokio = { version = "1", features = ["full"] }`
+- `serde_json = "1"`
+
+
Use `superpowers:test-driven-development` — the harness is itself tested by running `cargo test`.
Use `superpowers:verification-before-completion` before closing.
@@ -43,5 +116,5 @@ cargo test
-`test(quotesdb): implement test server harness with temp SQLite DB`
+`test(quotesdb): implement test server harness with per-test temp SQLite DB`
diff --git a/quotesdb/.nbd/tickets/9c9546.md b/quotesdb/.nbd/tickets/9c9546.md
new file mode 100644
index 0000000..e0f546d
--- /dev/null
+++ b/quotesdb/.nbd/tickets/9c9546.md
@@ -0,0 +1,66 @@
++++
+title = "Create .env.example documenting DATABASE_URL and all local dev environment variables"
+priority = 5
+status = "todo"
+ticket_type = "task"
+dependencies = ["33ed29"]
++++
+
+
+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.
+
+
+
+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
+```
+
+
+
+- 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.
+
+
+
+- `.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.
+
+
+
+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
+```
+
+
+
+`chore(quotesdb): add .env.example documenting DATABASE_URL for local dev`
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/9ef703.md b/quotesdb/.nbd/tickets/9ef703.md
new file mode 100644
index 0000000..66e3d9b
--- /dev/null
+++ b/quotesdb/.nbd/tickets/9ef703.md
@@ -0,0 +1,64 @@
++++
+title = "Add `_redirects` SPA fallback — create file and include in Trunk build via copy-file"
+priority = 8
+status = "todo"
+ticket_type = "task"
+dependencies = []
++++
+
+
+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 `` 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.
+
+
+
+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 ``, alongside the other `data-trunk` links:
+
+ ```html
+
+ ```
+
+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
+ ```
+
+
+
+- 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.
+
+
+
+```sh
+trunk build
+ls dist/_redirects # must exist
+cat dist/_redirects # must print: /* /index.html 200
+```
+
+
+quotesdb/ui
diff --git a/quotesdb/.nbd/tickets/a23489.md b/quotesdb/.nbd/tickets/a23489.md
index d157f26..afe8b5e 100644
--- a/quotesdb/.nbd/tickets/a23489.md
+++ b/quotesdb/.nbd/tickets/a23489.md
@@ -3,30 +3,62 @@ title = "Define Cloudflare Workers script resource — WASM artifact, D1 binding
priority = 7
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "2d1371", "d0da0b", "07cafb", "efee79"]
+dependencies = ["2d1371", "d0da0b", "07cafb", "efee79"]
+++
-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).
+Infrastructure is managed with OpenTofu using the Cloudflare provider.
-The Cloudflare Worker hosts the `quotesdb-api` binary compiled for the Workers runtime. It is bound to the D1 database and deployed via OpenTofu.
+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).
-Define the Cloudflare Workers script resource in `infra/worker.tf`:
-1. `cloudflare_workers_script` resource (resolve TRIAGE ticket efee79 for correct resource name in current provider version)
-2. Set the WASM artifact path (the compiled `api` binary)
-3. Bind the D1 database (name must match what workers-rs expects — resolve TRIAGE ticket 07cafb)
-4. Set required environment variables
+Define the Cloudflare Workers script resource in `infra/worker.tf`.
Every block must have a comment.
-
-- Resolve TRIAGE ticket efee79 (correct Workers resource name) before writing the resource.
-- Resolve TRIAGE ticket 07cafb (D1 chicken-and-egg) before wiring the D1 binding.
-- The D1 binding name in the Worker must match the binding name in the workers-rs code.
-
+
+```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"
+}
+```
+
+
+
+- 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).
+
Run from the `infra/` directory:
diff --git a/quotesdb/.nbd/tickets/a5049d.md b/quotesdb/.nbd/tickets/a5049d.md
index 44c0d08..a192e63 100644
--- a/quotesdb/.nbd/tickets/a5049d.md
+++ b/quotesdb/.nbd/tickets/a5049d.md
@@ -3,7 +3,7 @@ title = "Implement database connection module and SQLx migrations (quotes + quot
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "1f5bb5", "e8a330", "580e66", "33ed29"]
+dependencies = ["1f5bb5", "580e66", "33ed29"]
+++
@@ -16,17 +16,37 @@ The database schema consists of two tables:
- `quote_tags` — join table for quote-to-tag relationships with cascade delete
+
+**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.
+
+
-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
+~~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`
-- Migration strategy depends on TRIAGE ticket 580e66 (DB migration strategy for Workers) — resolve that first.
+- 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 compatibility with workers-rs is tracked in TRIAGE ticket e8a330 — check that first.
+- SQLx is NOT used. Use workers-rs D1 bindings (wasm32) and rusqlite (native). See 00aff0.
diff --git a/quotesdb/.nbd/tickets/a6bce1.md b/quotesdb/.nbd/tickets/a6bce1.md
index a112c31..a19f61c 100644
--- a/quotesdb/.nbd/tickets/a6bce1.md
+++ b/quotesdb/.nbd/tickets/a6bce1.md
@@ -3,7 +3,7 @@ title = "Write unit tests in api/src/tests.rs covering all handlers, auth logic,
priority = 6
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"]
+dependencies = ["2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"]
+++
diff --git a/quotesdb/.nbd/tickets/a91260.md b/quotesdb/.nbd/tickets/a91260.md
index 6a53ee6..8c3d8cf 100644
--- a/quotesdb/.nbd/tickets/a91260.md
+++ b/quotesdb/.nbd/tickets/a91260.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] workers-rs compatibility with native Rust test binaries (may need separate native feature flag)"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -20,11 +20,33 @@ workers-rs compatibility with native Rust test binaries: the workers-rs crate ta
3. **Separate test binary** — integration tests spawn a separately compiled native test server binary.
-
-1. Research the options above and choose the best approach for this project.
-2. Update ticket 6e829e (api main.rs) and ticket 9b581f (test harness) with the chosen approach.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
-
+
+**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.
+
`chore(quotesdb): resolve triage — workersrs-compatibility-with-native-rust-test-binaries-may-n`
diff --git a/quotesdb/.nbd/tickets/a9534d.md b/quotesdb/.nbd/tickets/a9534d.md
index e023817..4fee687 100644
--- a/quotesdb/.nbd/tickets/a9534d.md
+++ b/quotesdb/.nbd/tickets/a9534d.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Local dev CORS and Trunk API proxy config (trunk serve proxying to api on different port)"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["c3503b"]
+dependencies = []
+++
@@ -20,12 +20,23 @@ Local dev CORS and Trunk proxy config: during `trunk serve`, the UI runs on one
3. **Same-origin in production** — in production, both are served from the same Cloudflare account; in dev, use the Trunk proxy.
-
-1. Research the options above and choose the best approach for this project.
-2. Update ticket dc3d2b (Trunk.toml) and ticket 1e6a09 (API client module) with the chosen approach.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+
+**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).
`chore(quotesdb): resolve triage — local-dev-cors-and-trunk-api-proxy-config-trunk-serve-proxyi`
-
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/aa0eab.md b/quotesdb/.nbd/tickets/aa0eab.md
index 62856b9..d2c681e 100644
--- a/quotesdb/.nbd/tickets/aa0eab.md
+++ b/quotesdb/.nbd/tickets/aa0eab.md
@@ -3,7 +3,7 @@ title = "Test suite: GET /api/quotes/random — 200 with quote, 404 when databas
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "2ce22e"]
+dependencies = ["9b581f", "2ce22e"]
+++
diff --git a/quotesdb/.nbd/tickets/ae6a82.md b/quotesdb/.nbd/tickets/ae6a82.md
index 19d5938..de0ac97 100644
--- a/quotesdb/.nbd/tickets/ae6a82.md
+++ b/quotesdb/.nbd/tickets/ae6a82.md
@@ -3,7 +3,7 @@ title = "Define Cloudflare Worker route/domain — worker.dev subdomain or custo
priority = 6
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "a23489"]
+dependencies = ["a23489"]
+++
diff --git a/quotesdb/.nbd/tickets/ae886f.md b/quotesdb/.nbd/tickets/ae886f.md
index 60e4795..8363e9c 100644
--- a/quotesdb/.nbd/tickets/ae886f.md
+++ b/quotesdb/.nbd/tickets/ae886f.md
@@ -3,30 +3,57 @@ title = "Define Cloudflare Pages project resource — build config, output dir,
priority = 7
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "2d1371", "fc9bfd", "e2bd9b"]
+dependencies = ["2d1371", "fc9bfd"]
+++
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 Pages hosts the Yew/Wasm frontend. Resolve TRIAGE ticket fc9bfd (Pages build strategy: CI build vs pre-built artifact upload) before implementing this resource.
+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).
Define the Cloudflare Pages project resource in `infra/pages.tf`:
-1. `cloudflare_pages_project` resource for the `quotesdb-ui` project
-2. Configure the build settings (build command: `trunk build`, output directory: `dist/`)
-3. Connect to the git repository or configure for direct artifact upload (per TRIAGE fc9bfd)
+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.
+Every block must have a comment explaining its purpose.
-- Resolve TRIAGE ticket fc9bfd (Pages build strategy) before choosing git-connected vs artifact upload.
-- Resolve TRIAGE ticket e2bd9b (SPA routing — 404 fallback) before finalising Pages config.
-- The output directory must match Trunk's `dist/` output.
+- 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 `` (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`.
+
+```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.
+ }
+ }
+}
+```
+
+
Run from the `infra/` directory:
diff --git a/quotesdb/.nbd/tickets/af56a7.md b/quotesdb/.nbd/tickets/af56a7.md
index 265d789..29100a2 100644
--- a/quotesdb/.nbd/tickets/af56a7.md
+++ b/quotesdb/.nbd/tickets/af56a7.md
@@ -1,33 +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
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "33ed29"]
+dependencies = ["33ed29"]
+++
-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/`.
+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).
-Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
+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.
-Local development uses Turso (file-backed SQLite) instead of Cloudflare D1. The API reads the database connection string from an environment variable. There may also be `wrangler.toml` configuration needed for `wrangler dev`.
+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.
-Write documentation (in `docs/PLANNING.md` or a dedicated `docs/LOCAL_DEV.md`) explaining how to set up and run the API locally:
-1. How to install/run Turso for a local SQLite file
-2. What environment variables to set (database URL, etc.)
-3. How to run `cargo run` to start the API server
-4. Any `wrangler.toml` configuration needed for `wrangler dev` (if applicable)
-5. How the D1 vs Turso selection is made at runtime
+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
+
+- 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
+
+
-- Do not commit any `.env` files — document the variables, not the values.
-- Cross-reference the TRIAGE ticket 33ed29 decision on Turso vs D1 local selection strategy.
+- 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.
-`docs(quotesdb): document local dev environment setup for api`
-
+`docs(quotesdb): write LOCAL_DEV.md — local dev quickstart for api and ui`
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/b20b5a.md b/quotesdb/.nbd/tickets/b20b5a.md
index 11a88df..381aaaa 100644
--- a/quotesdb/.nbd/tickets/b20b5a.md
+++ b/quotesdb/.nbd/tickets/b20b5a.md
@@ -3,7 +3,7 @@ title = "Implement DELETE /api/quotes/:id — verify X-Auth-Code, cascade delete
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "a5049d", "d792e2"]
+dependencies = ["a5049d", "d792e2"]
+++
diff --git a/quotesdb/.nbd/tickets/b3ef98.md b/quotesdb/.nbd/tickets/b3ef98.md
index f5cacc2..0b8e9db 100644
--- a/quotesdb/.nbd/tickets/b3ef98.md
+++ b/quotesdb/.nbd/tickets/b3ef98.md
@@ -3,7 +3,7 @@ title = "Implement Author page (/author/:name) — paginated list of quotes by a
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"]
+dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/bb1514.md b/quotesdb/.nbd/tickets/bb1514.md
new file mode 100644
index 0000000..33dd3e2
--- /dev/null
+++ b/quotesdb/.nbd/tickets/bb1514.md
@@ -0,0 +1,163 @@
++++
+title = "Create infra/schema.sql — idempotent D1 schema for quotes and quote_tags"
+priority = 7
+status = "todo"
+ticket_type = "task"
+dependencies = ["d0da0b", "5c0c64"]
++++
+
+
+TRIAGE 5c0c64 resolved: the chosen D1 migration strategy is a **separate wrangler step**.
+
+Production schema is applied once after `tofu apply` using:
+
+```sh
+wrangler d1 execute quotesdb --file infra/schema.sql --remote
+```
+
+For local dev, the native `main()` calls `repo.run_migrations()` (rusqlite `execute_batch`).
+For tests, test setup calls `NativeRepository::run_migrations()` directly.
+
+The D1 `run_migrations()` method in `D1Repository` (wasm32 path, ticket 00aff0) may still call
+`CREATE TABLE IF NOT EXISTS` defensively on startup — but the canonical provisioning path for
+production is the wrangler CLI command above, not a startup handler.
+
+`infra/schema.sql` is the single source of truth for the SQL that wrangler applies. The Rust
+constants in `db/migrations.rs` (ticket 00aff0) contain the same SQL in split form suitable for
+the D1 `prepare().run()` API and rusqlite `execute_batch`.
+
+
+
+Create `infra/schema.sql` — a self-contained, idempotent SQL file that provisions the full
+`quotesdb` schema on a blank D1 database in a single wrangler command.
+
+
+
+
+## 1. Create `infra/schema.sql`
+
+```sql
+-- quotesdb D1 schema
+-- =============================================================================
+-- Apply to production D1:
+-- wrangler d1 execute quotesdb --file infra/schema.sql --remote
+--
+-- Apply locally (wrangler dev):
+-- wrangler d1 execute quotesdb --local --file infra/schema.sql
+--
+-- For native dev/test builds, NativeRepository::run_migrations() applies
+-- equivalent SQL automatically via rusqlite on startup.
+-- =============================================================================
+
+-- Stores individual quotes. auth_code is the 4-word passphrase for edit/delete.
+CREATE TABLE IF NOT EXISTS quotes (
+ id TEXT PRIMARY KEY, -- NanoID (~21 chars)
+ text TEXT NOT NULL,
+ author TEXT NOT NULL,
+ source TEXT, -- optional: book, speech, etc.
+ date TEXT, -- optional: ISO date YYYY-MM-DD
+ auth_code TEXT NOT NULL, -- 4-word passphrase, stored plaintext
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Join table linking quotes to zero or more tags. Cascades on quote deletion.
+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)
+);
+```
+
+## 2. Incremental migration convention (document in infra/README.md)
+
+For future schema changes, create numbered migration files:
+
+```
+infra/migrations/
+ 001_initial.sql -- (retroactive, same content as schema.sql)
+ 002_add_index.sql -- future: e.g. CREATE INDEX IF NOT EXISTS ...
+```
+
+Apply individually:
+```sh
+wrangler d1 execute quotesdb --file infra/migrations/002_add_index.sql --remote
+```
+
+No automated migration tracking is needed at this project's scale.
+
+## 3. Full deployment workflow to document in `infra/README.md`
+
+```sh
+# Step 1 — provision infrastructure (creates Worker, D1 database, Pages project)
+cd infra/
+tofu apply
+
+# Step 2 — apply initial schema to D1 (run once after first apply)
+cd ..
+wrangler d1 execute quotesdb --file infra/schema.sql --remote
+
+# Re-running step 2 is safe (CREATE TABLE IF NOT EXISTS).
+```
+
+## 4. Local dev workflow
+
+```sh
+# Start local API (applies schema automatically via NativeRepository::run_migrations())
+cargo run
+# or with a specific DB file:
+DATABASE_URL=./dev.sqlite cargo run
+```
+
+No manual wrangler step needed for local dev.
+
+
+
+
+**null_resource local-exec (rejected):** Provisioners are an OpenTofu anti-pattern. They don't
+re-run unless the resource is tainted, aren't tracked in state, are OS-dependent (requires
+wrangler installed on the CI runner at apply time), and hard to test. Breaking `tofu apply`
+idempotency is not worth the single-command convenience.
+
+**API startup migration for D1 (rejected):** Cloudflare Workers spin up per-request via V8
+isolates. Calling DDL (`CREATE TABLE IF NOT EXISTS`) on every request is wasteful and fragile.
+The native `main()` calls `run_migrations()` at startup because it runs as a real server, but
+the Workers handler does NOT. The D1 provisioning path must be a separate step.
+
+
+
+
+- `infra/schema.sql` must use `CREATE TABLE IF NOT EXISTS` for idempotency — safe to re-run.
+- Schema must exactly match the design doc: NanoID PK, `auth_code` plaintext, optional `source`
+ and `date`, CASCADE delete on `quote_tags`.
+- Do NOT run `wrangler d1 execute` inside OpenTofu (no `null_resource`).
+- `db/migrations.rs` (ticket 00aff0) contains equivalent SQL as Rust constants — keep in sync
+ with `infra/schema.sql` manually when schema changes.
+
+
+
+This ticket has no Rust compilation artifact. Validate that the SQL is correct:
+
+```sh
+# Smoke-test the schema against a local SQLite file
+sqlite3 /tmp/test_quotesdb.sqlite < infra/schema.sql
+sqlite3 /tmp/test_quotesdb.sqlite ".tables"
+# Should output: quote_tags quotes
+
+# Clean up
+rm /tmp/test_quotesdb.sqlite
+```
+
+
+
+- Resolves dependency: TRIAGE 5c0c64 (D1 migrations strategy — wrangler step chosen)
+- Resolves dependency: TRIAGE 580e66 (DB migration strategy for Workers — same decision)
+- Blocked by: d0da0b (D1 resource — need database name confirmed as "quotesdb")
+- Informs: 75489a (migration workflow docs — documents the wrangler command from this ticket)
+- Informs: 00aff0 (DB abstraction — migrations.rs constants mirror this file's SQL)
+- Sub-project: quotesdb/infra
+
+
+
+`feat(quotesdb): add infra/schema.sql with idempotent D1 schema for quotes and quote_tags`
+
diff --git a/quotesdb/.nbd/tickets/c3503b.md b/quotesdb/.nbd/tickets/c3503b.md
index 4c7179f..8c4e8f3 100644
--- a/quotesdb/.nbd/tickets/c3503b.md
+++ b/quotesdb/.nbd/tickets/c3503b.md
@@ -3,7 +3,7 @@ title = "quotesdb/ui"
priority = 7
status = "todo"
ticket_type = "project"
-dependencies = []
+dependencies = ["166996", "5e3e37", "a9534d", "93515e", "dc3d2b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "f850c6", "1a274d", "1ba523", "5f1112", "5cdbd9", "b3ef98", "372790", "0fbdd5", "00d6d7", "9ef703", "5379eb"]
+++
diff --git a/quotesdb/.nbd/tickets/ce1e4f.md b/quotesdb/.nbd/tickets/ce1e4f.md
index 531ac71..0c90b28 100644
--- a/quotesdb/.nbd/tickets/ce1e4f.md
+++ b/quotesdb/.nbd/tickets/ce1e4f.md
@@ -3,7 +3,7 @@ title = "quotesdb/qa"
priority = 7
status = "todo"
ticket_type = "project"
-dependencies = []
+dependencies = ["2ab7a8", "fba598", "0d84fa", "5f5ba0", "9b581f", "e8f5cf", "789d0f", "4a4c26", "aa0eab", "93f1b6", "f9f448", "fae330", "8c87db", "893eba", "75e3f0"]
+++
diff --git a/quotesdb/.nbd/tickets/d0da0b.md b/quotesdb/.nbd/tickets/d0da0b.md
index ab13c6c..2cc342a 100644
--- a/quotesdb/.nbd/tickets/d0da0b.md
+++ b/quotesdb/.nbd/tickets/d0da0b.md
@@ -3,28 +3,45 @@ title = "Define Cloudflare D1 database resource and document binding name for th
priority = 7
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "2d1371", "5c0c64"]
+dependencies = ["2d1371", "5c0c64"]
+++
-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).
+Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. The D1 database must be provisioned before the Worker can bind to it — OpenTofu handles this automatically via the attribute reference in the Worker resource (see triage 07cafb).
-Cloudflare D1 is the production SQLite-compatible database. It must be provisioned before the Worker can bind to it.
+Triage 5c0c64 resolved: schema is applied via `wrangler d1 execute quotesdb --file infra/schema.sql --remote` as a separate step after `tofu apply`. Do NOT use `null_resource` local-exec.
-Define the Cloudflare D1 database resource in `infra/d1.tf`:
-1. `cloudflare_d1_database` resource for the `quotesdb` database
-2. Output the D1 database ID so it can be referenced in the Worker binding
-3. Document the binding name (e.g. `DB`) that the Worker expects — this must match the workers-rs binding name in the API code
-
-Every block must have a comment.
+Define the Cloudflare D1 database resource in `infra/d1.tf`.
-
-- Resolve TRIAGE ticket 5c0c64 (D1 migrations in OpenTofu) for how to apply the schema after provisioning.
-- Document the D1 database ID output — needed for the Worker binding (ticket 07cafb).
-
+
+```hcl
+# infra/d1.tf
+
+# Cloudflare D1 database for the quotesdb application.
+# SQLite-compatible, bound to the API Worker under the binding name "DB".
+resource "cloudflare_d1_database" "db" {
+ account_id = var.cloudflare_account_id
+ name = "quotesdb"
+}
+
+# Export the D1 database ID so it can be referenced in worker.tf and
+# used as an argument to `wrangler d1 execute` for schema migrations.
+output "d1_database_id" {
+ description = "D1 database ID — referenced by the Worker binding and schema migration commands."
+ value = cloudflare_d1_database.db.id
+}
+```
+
+
+
+- `cloudflare_d1_database` outputs `id` (String) — the identifier used in Worker bindings.
+- The binding name `"DB"` must match what the workers-rs code uses to access the database (set in the API source, not here).
+- After `tofu apply`, apply the schema: `wrangler d1 execute quotesdb --file infra/schema.sql --remote` (see ticket bb1514 for schema.sql).
+- The D1 ID showing as `(known after apply)` in `tofu plan` is expected; the Worker binding resolves it at apply time automatically.
+
Run from the `infra/` directory:
diff --git a/quotesdb/.nbd/tickets/d3d502.md b/quotesdb/.nbd/tickets/d3d502.md
index badcbfa..d46a5ad 100644
--- a/quotesdb/.nbd/tickets/d3d502.md
+++ b/quotesdb/.nbd/tickets/d3d502.md
@@ -3,7 +3,7 @@ title = "Implement tag filter component — tag input/select for browse and auth
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e"]
+dependencies = ["93515e", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/d5839a.md b/quotesdb/.nbd/tickets/d5839a.md
index ec0f621..9aa3ffe 100644
--- a/quotesdb/.nbd/tickets/d5839a.md
+++ b/quotesdb/.nbd/tickets/d5839a.md
@@ -3,7 +3,7 @@ title = "Write infra/README.md — setup, apply, destroy instructions and requir
priority = 3
status = "todo"
ticket_type = "task"
-dependencies = ["25c413", "2d1371", "d0da0b", "a23489", "ae886f", "ae6a82"]
+dependencies = ["2d1371", "d0da0b", "a23489", "ae886f", "ae6a82"]
+++
diff --git a/quotesdb/.nbd/tickets/d792e2.md b/quotesdb/.nbd/tickets/d792e2.md
index 9de82be..7e2dc0d 100644
--- a/quotesdb/.nbd/tickets/d792e2.md
+++ b/quotesdb/.nbd/tickets/d792e2.md
@@ -3,7 +3,7 @@ title = 'Implement error handling — consistent {"error": "..."} envelope for 4
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["f3dc74", "1f5bb5", "6e829e"]
+dependencies = ["1f5bb5", "6e829e"]
+++
diff --git a/quotesdb/.nbd/tickets/dc3d2b.md b/quotesdb/.nbd/tickets/dc3d2b.md
index 5255064..36a325f 100644
--- a/quotesdb/.nbd/tickets/dc3d2b.md
+++ b/quotesdb/.nbd/tickets/dc3d2b.md
@@ -3,7 +3,7 @@ title = "Set up ui/Trunk.toml and ui/index.html — build configuration and Wasm
priority = 8
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "a9534d"]
+dependencies = ["a9534d", "9ef703"]
+++
@@ -11,17 +11,54 @@ The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and
-Create `Trunk.toml` and `index.html` in the `quotesdb/` root (not `src/bin/ui/`):
-1. `Trunk.toml` must specify `--bin ui` so Trunk compiles the `ui` binary, not the default `api` binary
-2. `index.html` is the Trunk HTML entry point — it should `` to compiled CSS and include the Wasm loader script tag
+Update `Trunk.toml` and `index.html` in the `quotesdb/` root:
+1. `Trunk.toml` must specify the target index.html so Trunk uses the right entry point.
+2. `Trunk.toml` must include a `[[proxy]]` block to forward `/api/*` requests to the local API server (see proxy-config section below).
+3. `index.html` must:
+ - Include `` (plain CSS — see triage 5e3e37)
+ - Include `` so Trunk compiles the `ui` binary
+ - Include `` as the Yew mount point
-Verify `trunk serve` starts successfully.
+Verify `trunk build` succeeds.
+
+CSS approach resolved in triage 5e3e37: **plain CSS**.
+
+- CSS file lives at `src/bin/ui/style.css`
+- Linked in index.html as: ``
+- Naming convention: BEM-style — `quote-card`, `quote-card__text`, `page-browse`, etc.
+- No CDN Tailwind (incompatible with Wasm class-name scanning), no Rust CSS-in-Wasm crate.
+- The actual CSS content is implemented in ticket bb1514.
+
+
+
+CORS/proxy approach resolved in triage a9534d: **Trunk proxy**.
+
+During `trunk serve`, Trunk's built-in proxy forwards all `/api/*` requests to the local API server.
+The API server runs on `localhost:3000` during local development (plain Axum/Tokio — not wrangler dev).
+No CORS headers are needed anywhere; the proxy makes API calls appear same-origin.
+
+Add this block to `Trunk.toml`:
+
+```toml
+[[proxy]]
+rewrite = "/api"
+backend = "http://localhost:3000"
+```
+
+This means:
+- `trunk serve` listens on `localhost:8080` (default)
+- Browser requests to `http://localhost:8080/api/quotes` are transparently proxied to `http://localhost:3000/api/quotes`
+- In production (Cloudflare Pages + Workers), `/api/*` routes to the Worker via Cloudflare's routing — same path prefix, no code change needed
+
+
-- Resolve TRIAGE ticket 5e3e37 (CSS approach) before adding stylesheet links.
-- The `Trunk.toml` `[build.cargo]` section must set `args = ["--bin", "ui"]`.
- `index.html` must include a `
` mount point for the Yew app.
+- Trunk.toml `target` must point to `index.html`.
+- The `[[proxy]]` block must appear in `Trunk.toml` (not `Trunk.toml.dev` or elsewhere).
+- Do NOT remove or overwrite the `` line
+ added by ticket 9ef703 — that line must remain in `index.html`.
@@ -34,4 +71,4 @@ trunk build
`chore(quotesdb): set up Trunk.toml and index.html for UI build`
-
+
\ No newline at end of file
diff --git a/quotesdb/.nbd/tickets/e2bd9b.md b/quotesdb/.nbd/tickets/e2bd9b.md
index 0cd8781..061de17 100644
--- a/quotesdb/.nbd/tickets/e2bd9b.md
+++ b/quotesdb/.nbd/tickets/e2bd9b.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Cloudflare Pages SPA routing — 404 fallback config for client-side Yew router"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["25c413"]
+dependencies = []
+++
@@ -21,9 +21,27 @@ Cloudflare Pages SPA routing: Yew uses client-side routing. A direct URL to `/br
-1. Research the options above and choose the best approach for this project.
-2. Add a `_redirects` file (or equivalent) to the Trunk build output. Update ticket ae886f (Pages resource) and ticket dc3d2b (Trunk.toml) if the file needs to be included in the build.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+**Chosen approach: Option 1 — `_redirects` file.**
+
+- File content: `/* /index.html 200`
+- The `200` code is a Cloudflare Pages "proxy" (not a redirect): it serves `index.html` content
+ while preserving the original URL in the browser. This is required for Yew's BrowserRouter to
+ function correctly on direct navigation and page reloads.
+- The file lives at the `quotesdb/` project root and is copied into `dist/` by Trunk via
+ `` in `index.html`.
+- Cloudflare Pages processes `_redirects` automatically from the uploaded artifact — no
+ OpenTofu changes are needed in `ae886f`.
+- The `/api/*` Worker routes are claimed at the Cloudflare routing layer before Pages processes
+ requests, so the `/* /index.html 200` catch-all does not interfere with the API.
+
+Options 2 (`_headers`) and 3 (duplicate `404.html`) were ruled out:
+- `_headers` does not fix routing — it only sets response headers.
+- A `404.html` copy of `index.html` returns HTTP 404 status, which is incorrect for valid SPA
+ routes and can harm SEO and caching behaviour.
+
+Implementation ticket: **9ef703** — creates the `_redirects` file and adds the Trunk copy-file
+directive to `index.html`. `dc3d2b` (Trunk.toml) depends on 9ef703 to ensure the copy-file
+line is not accidentally overwritten. `ae886f` (Pages resource) updated with resolved notes.
diff --git a/quotesdb/.nbd/tickets/e8a330.md b/quotesdb/.nbd/tickets/e8a330.md
index f9ab42f..d59a4df 100644
--- a/quotesdb/.nbd/tickets/e8a330.md
+++ b/quotesdb/.nbd/tickets/e8a330.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] SQLx + workers-rs + Cloudflare D1 compatibility (known issues?)"
priority = 9
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["f3dc74"]
+dependencies = []
+++
@@ -20,10 +20,32 @@ SQLx + workers-rs + Cloudflare D1 compatibility: does SQLx work with the Cloudfl
3. **SQLite over HTTP (Turso)** — use Turso in both dev and production (Turso cloud instead of D1). Avoids D1 entirely.
+
+**Option 2: workers-rs D1 bindings, extended with a `cfg(target_arch)`-based native path.**
+
+SQLx is fundamentally incompatible with Cloudflare Workers/D1. D1 is accessed through the
+workers-rs JavaScript binding layer (`worker::d1::D1Database`), not a TCP connection.
+SQLx requires a TCP-based connection and its compile-time query macros cannot run in the
+Workers/WASM build environment.
+
+**Chosen architecture:**
+- `#[cfg(target_arch = "wasm32")]` → workers-rs `D1Database` bindings (production)
+- `#[cfg(not(target_arch = "wasm32"))]` → `rusqlite` + `tokio-rusqlite` (native dev/tests)
+
+A `QuoteRepository` async trait provides a unified interface. Concrete type aliases
+(`AppRepo`) avoid trait-object Send/Sync constraints in Axum handlers.
+
+`cargo test` on the native host automatically selects the rusqlite path — no wrangler dev
+or feature flags needed for integration tests.
+
+The design doc's "Query layer: SQLx" is superseded. See implementation ticket 00aff0.
+
+
-1. Research the options above and choose the best approach for this project.
-2. Update ticket a5049d (database connection module) and ticket 1f5bb5 (Cargo.toml) with the chosen database access strategy.
-3. Mark this ticket done with a note on the chosen approach in the body or a comment.
+- Implementation ticket created: **00aff0** — full plan for Repository trait + D1/rusqlite impls.
+- Ticket a5049d (database connection module) updated — SQLx goal superseded.
+- Ticket 1f5bb5 (Cargo.toml) updated — cfg-split dependency constraints corrected.
+- Tickets a91260 and 2ab7a8 also resolved by this decision (see those tickets).
diff --git a/quotesdb/.nbd/tickets/e8f5cf.md b/quotesdb/.nbd/tickets/e8f5cf.md
index 5db7815..afefd41 100644
--- a/quotesdb/.nbd/tickets/e8f5cf.md
+++ b/quotesdb/.nbd/tickets/e8f5cf.md
@@ -3,7 +3,7 @@ title = "Test suite: router ordering — verify /api/quotes/random is not matche
priority = 6
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "6e829e"]
+dependencies = ["9b581f", "6e829e"]
+++
diff --git a/quotesdb/.nbd/tickets/efee79.md b/quotesdb/.nbd/tickets/efee79.md
index 90797b0..0fe57cc 100644
--- a/quotesdb/.nbd/tickets/efee79.md
+++ b/quotesdb/.nbd/tickets/efee79.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Correct cloudflare_workers_script resource name in current Cloudflare provider version"
priority = 7
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["25c413"]
+dependencies = []
+++
@@ -20,12 +20,22 @@ Correct Cloudflare Workers script resource name: the Cloudflare OpenTofu provide
3. **Check provider changelog** — run `tofu providers schema` after `tofu init` to see available resources.
+
+**`cloudflare_workers_script`** (Option 1 — plural "workers").
+
+Confirmed directly from the Cloudflare provider v4 GitHub source (`docs/resources/workers_script.md`). The resource is documented as `cloudflare_workers_script` with a `d1_database_binding` block. The singular form `cloudflare_worker_script` does not exist in v4.
+
+Also confirmed alongside triage 07cafb: the `d1_database_binding` block has required fields `name` (binding variable name) and `database_id` (the D1 ID, referenced as `cloudflare_d1_database.db.id`).
+
+Ticket a23489 updated with this resource name.
+
+
1. Research the options above and choose the best approach for this project.
-2. Run `tofu providers schema | jq '.provider_schemas[].resource_schemas | keys | map(select(contains("worker")))'` to find the correct resource name. Update ticket a23489.
+2. Run `tofu providers schema | jq` to find the correct resource name. Update ticket a23489.
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
-`chore(quotesdb): resolve triage — correct-cloudflareworkersscript-resource-name-in-current-clo`
+`chore(quotesdb): resolve triage — cloudflare_workers_script-confirmed-plural-provider-v4`
diff --git a/quotesdb/.nbd/tickets/f3dc74.md b/quotesdb/.nbd/tickets/f3dc74.md
index 0a78b44..c5a3a82 100644
--- a/quotesdb/.nbd/tickets/f3dc74.md
+++ b/quotesdb/.nbd/tickets/f3dc74.md
@@ -3,7 +3,7 @@ title = "quotesdb/api"
priority = 7
status = "todo"
ticket_type = "project"
-dependencies = []
+dependencies = ["00aff0", "af56a7", "9c9546", "8892d5"]
+++
diff --git a/quotesdb/.nbd/tickets/f850c6.md b/quotesdb/.nbd/tickets/f850c6.md
index a4b0458..d4130d9 100644
--- a/quotesdb/.nbd/tickets/f850c6.md
+++ b/quotesdb/.nbd/tickets/f850c6.md
@@ -3,7 +3,7 @@ title = "Implement auth code modal/prompt component — dialog requesting X-Auth
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e", "0bc655"]
+dependencies = ["93515e", "5379eb", "0fbdd5"]
+++
@@ -15,15 +15,21 @@ The auth code modal prompts the user to enter their `X-Auth-Code` (4-word passph
Implement an `AuthModal` Yew component (`src/bin/ui/components/auth_modal.rs`) that:
1. Shows a dialog overlay prompting for the auth code
-2. Accepts `on_submit: Callback` and `on_cancel: Callback<()>` props
-3. Renders an `` for the auth code and Submit/Cancel buttons
-4. Calls `on_submit` with the entered code when submitted
+2. Accepts `on_submit: Callback`, `on_cancel: Callback<()>`, and
+ `initial_value: Option` props
+3. Renders an `` for the auth code, pre-populated from `initial_value`
+ if provided (supplied by the parent from session storage — see ticket 5379eb)
+4. Renders Submit and Cancel buttons
+5. Calls `on_submit` with the entered code when submitted
-- Resolve TRIAGE ticket 0bc655 (auth code storage strategy) before deciding whether to persist the code in `localStorage` or keep it in component-only state.
-- The modal must be accessible (label the input, support keyboard dismiss).
-- Do not persist the auth code across sessions unless explicitly decided in ticket 0bc655.
+- Storage strategy resolved in TRIAGE 0bc655: **session storage per quote ID** (ticket 5379eb).
+ The `AuthModal` itself does NOT read or write storage — it receives `initial_value` from
+ the parent component, which handles storage via `storage::get_auth_code`.
+- The modal must be accessible: label the `` with `
diff --git a/quotesdb/.nbd/tickets/f9f448.md b/quotesdb/.nbd/tickets/f9f448.md
index 4b4f4eb..96c2058 100644
--- a/quotesdb/.nbd/tickets/f9f448.md
+++ b/quotesdb/.nbd/tickets/f9f448.md
@@ -3,7 +3,7 @@ title = "Test suite: GET /api/quotes/:id — 200 with quote, 404 not found, sche
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "5dbb7d"]
+dependencies = ["9b581f", "5dbb7d"]
+++
diff --git a/quotesdb/.nbd/tickets/fae330.md b/quotesdb/.nbd/tickets/fae330.md
index 0de8225..d89b1ea 100644
--- a/quotesdb/.nbd/tickets/fae330.md
+++ b/quotesdb/.nbd/tickets/fae330.md
@@ -3,7 +3,7 @@ title = "Test suite: POST /api/quotes/:id — valid auth 200, wrong auth 403, no
priority = 5
status = "todo"
ticket_type = "task"
-dependencies = ["ce1e4f", "9b581f", "5d9f5a"]
+dependencies = ["9b581f", "5d9f5a"]
+++
diff --git a/quotesdb/.nbd/tickets/fba598.md b/quotesdb/.nbd/tickets/fba598.md
index 9cad171..ee69d1d 100644
--- a/quotesdb/.nbd/tickets/fba598.md
+++ b/quotesdb/.nbd/tickets/fba598.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Integration test isolation strategy — per-test temp DB vs shared DB with transaction rollback?"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["ce1e4f"]
+dependencies = []
+++
@@ -20,6 +20,49 @@ Integration test isolation strategy: should each test get its own temporary data
3. **Per-test in-memory SQLite** — SQLite `:memory:` database per test. Fast (no file I/O) and fully isolated. May require `--test-threads=1` if the server shares state.
+
+**Per-test temp SQLite file** (Option 1 variant), using the `tempfile` crate for RAII cleanup.
+
+Rationale:
+
+- **Transaction rollback (Option 2) is not viable** for HTTP integration tests. The test harness spawns a real Axum server as a tokio task. That server manages its own SQLx connection pool and commits transactions independently. The test client cannot intercept or roll back those server-side transactions.
+
+- **In-memory SQLite (Option 3) conflicts with SQLx connection pools.** Each connection in a SQLx pool that opens `sqlite::memory:` gets its own isolated empty database. The test server and the test client would be talking to different databases. Named shared-cache URIs (`file:test_N?mode=memory&cache=shared`) work around this but are less-known, have edge cases in SQLx, and offer no meaningful speed advantage over a temp file on a tmpfs.
+
+- **Per-test temp file (chosen)** works correctly with SQLx pools because all pool connections share the same file path. Migration runs once per test (≈ milliseconds for 2 tables). `TempDir` from the `tempfile` crate provides RAII cleanup — the file is deleted when the `TestContext` is dropped. Tests run in parallel safely (each has a unique path).
+
+Implementation in test harness (`tests/helpers.rs`):
+```rust
+pub struct TestContext {
+ _db_dir: TempDir, // keeps temp file alive; deleted on drop
+ pub base_url: String,
+ _shutdown: tokio::task::JoinHandle<()>,
+}
+
+pub async fn spawn_test_server() -> TestContext {
+ let db_dir = TempDir::new().unwrap();
+ let db_path = db_dir.path().join("test.sqlite");
+ let pool = SqlitePool::connect(&format!("sqlite:{}", db_path.display()))
+ .await
+ .unwrap();
+ sqlx::migrate!("./migrations").run(&pool).await.unwrap();
+
+ let app = build_router(Arc::new(NativeRepository::new(pool)));
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ let handle = tokio::spawn(axum::serve(listener, app).into_future());
+
+ TestContext {
+ _db_dir: db_dir,
+ base_url: format!("http://127.0.0.1:{port}"),
+ _shutdown: handle,
+ }
+}
+```
+
+Dev-dependency added: `tempfile = "3"` (tracked in ticket 5f5ba0).
+
+
1. Research the options above and choose the best approach for this project.
2. Update ticket 9b581f (test harness) with the chosen isolation strategy.
@@ -27,5 +70,5 @@ Integration test isolation strategy: should each test get its own temporary data
-`chore(quotesdb): resolve triage — integration-test-isolation-strategy-pertest-temp-db-vs-share`
+`chore(quotesdb): resolve triage — integration-test-isolation-per-test-tempfile-sqlite`
diff --git a/quotesdb/.nbd/tickets/fc2f51.md b/quotesdb/.nbd/tickets/fc2f51.md
index d6799ec..dd521b7 100644
--- a/quotesdb/.nbd/tickets/fc2f51.md
+++ b/quotesdb/.nbd/tickets/fc2f51.md
@@ -3,7 +3,7 @@ title = "Implement error display component — consistent error state UI across
priority = 4
status = "todo"
ticket_type = "task"
-dependencies = ["c3503b", "93515e"]
+dependencies = ["93515e", "0fbdd5"]
+++
diff --git a/quotesdb/.nbd/tickets/fc9bfd.md b/quotesdb/.nbd/tickets/fc9bfd.md
index f3a6328..1a686d7 100644
--- a/quotesdb/.nbd/tickets/fc9bfd.md
+++ b/quotesdb/.nbd/tickets/fc9bfd.md
@@ -1,9 +1,9 @@
+++
title = "[TRIAGE] Cloudflare Pages build strategy — Pages CI build vs pre-built trunk artifact upload"
priority = 8
-status = "todo"
+status = "done"
ticket_type = "task"
-dependencies = ["25c413"]
+dependencies = []
+++
@@ -20,6 +20,21 @@ Cloudflare Pages build strategy: should the Trunk build run in Cloudflare Pages
3. **Wrangler Pages deploy** — use `wrangler pages deploy dist/` in CI after `trunk build`.
+
+**Pre-built artifact + Gitea Actions + `wrangler pages deploy`** (Options 2 + 3 combined).
+
+Rationale:
+- **Pages CI build is not viable**: Cloudflare Pages CI supports Node.js, Python, Ruby, Go — not Rust or Nix. Installing `rustup` + `wasm32` target + `trunk` in a Pages build script is slow (~3–5 min per build), non-reproducible, and fragile. Nix flakes are not available in the Pages build sandbox at all.
+- **OpenTofu does not upload artifacts**: The `cloudflare_pages_project` resource creates and configures the project, but Terraform/OpenTofu is not designed to upload build artifacts — that is a CD concern, not infrastructure state.
+- **Gitea Actions + wrangler is the standard pattern**: Gitea Actions uses GitHub Actions-compatible YAML syntax. The workflow builds the Wasm artifact with `trunk build --release` then deploys with `wrangler pages deploy dist/`.
+
+Split of responsibilities:
+- **OpenTofu** (`infra/pages.tf`): create `cloudflare_pages_project` with direct-upload config (no git connection), configure SPA routing, bind custom domain. Run once to provision.
+- **Gitea Actions** (`.gitea/workflows/deploy-ui.yml`): on push to `quotesdb` branch, build and deploy. Secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`.
+
+Implementation ticket: 5137d7
+
+
1. Research the options above and choose the best approach for this project.
2. Update ticket ae886f (Pages project resource) with the chosen strategy. Document the CI/CD flow in `infra/README.md`.
@@ -27,5 +42,5 @@ Cloudflare Pages build strategy: should the Trunk build run in Cloudflare Pages
-`chore(quotesdb): resolve triage — cloudflare-pages-build-strategy-pages-ci-build-vs-prebuilt-t`
+`chore(quotesdb): resolve triage — pages-build-strategy-gitea-actions-wrangler-deploy`
diff --git a/quotesdb/CLAUDE.md b/quotesdb/CLAUDE.md
index 0668cfe..daac4d1 100644
--- a/quotesdb/CLAUDE.md
+++ b/quotesdb/CLAUDE.md
@@ -68,8 +68,9 @@ quotesdb (root project ticket, ec118c)
Rules:
- Create a ticket **before** starting any non-trivial work.
-- Each work ticket must list its sub-project ticket as a dependency (`--deps `).
-- Each sub-project ticket must list the root project ticket (`ec118c`) as a dependency.
+- Work tickets are **dependencies of** their sub-project ticket — add them with `nbd update --deps "...existing...,"`. Work tickets do **not** list the sub-project in their own `--deps`.
+- Sub-project tickets are **dependencies of** the root project ticket (`ec118c`) — they do **not** list `ec118c` in their own `--deps`.
+- The dependency flows upward: `work → sub-project → root`. Each level can only close after all tickets below it are done.
- **Only close a ticket after its work has been validated** (all `cargo fmt/check/clippy/test` pass, or equivalent for infra).
diff --git a/quotesdb/docs/ARCHITECTURE.md b/quotesdb/docs/ARCHITECTURE.md
index 2e7c409..5ddf05d 100644
--- a/quotesdb/docs/ARCHITECTURE.md
+++ b/quotesdb/docs/ARCHITECTURE.md
@@ -39,3 +39,15 @@ Integration tests bypass the UI and talk directly to the API over HTTP, using a
- Any API that is not available in a Wasm environment
Use `#[cfg(not(target_arch = "wasm32"))]` and `#[cfg(target_arch = "wasm32")]` guards where needed.
+
+## Key Dependency Versions
+
+Resolved versions for the UI Wasm target (scoped to `[target.'cfg(target_arch = "wasm32")'.dependencies]`):
+
+| Crate | Version | Notes |
+|-------|---------|-------|
+| `yew` | `"0.22"` | Latest stable (0.22.1). |
+| `yew-router` | `"0.19"` | Latest stable (0.19.0). Requires `yew ^0.22.0` — compatible. |
+| `wasm-bindgen` | `"0.2"` | Compatible with `wasm-bindgen-cli 0.2.108` in the Nix dev shell. |
+
+Rationale: yew-router 0.19 explicitly requires `yew ^0.22.0`, making this the only correct combination with the latest stable Yew. The `^0.2` wasm-bindgen constraint in both crates is satisfied by the Nix-pinned `0.2.108`.