chore(quotesdb): resolve all triage tickets and create implementation tickets

- All 21 TRIAGE decision tickets resolved with chosen approaches documented
- This session: e2bd9b (SPA routing → _redirects), 2ec8b1 (OpenAPI → build.rs),
  0d84fa (HTTP client → reqwest), 0bc655 (auth code → session storage)
- New implementation tickets created: 9ef703, 8892d5, 5379eb
- Downstream tickets updated with resolved approaches and correct dependencies
- ARCHITECTURE.md updated with pinned WASM dependency versions (yew 0.22,
  yew-router 0.19, wasm-bindgen 0.2)
- XML tags added to all tickets for improved LLM guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent 895b63a77c
commit fc89180b82

@ -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": {

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

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

@ -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"]
+++
<context>
@ -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.
</context>
<goal>
Implement a `generate_auth_code() -> String` function in `src/lib.rs` that produces a random 4-word passphrase. Place it in shared lib code so both the API (generation) and UI (display) can reference 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.
</goal>
<implementation>
## 1. Cargo.toml changes (covered by ticket 1f5bb5, listed here for reference)
```toml
[dependencies]
rand = "0.10"
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues())
# Required by both rand (OsRng) and uuid (v4) on wasm32 targets
getrandom = { version = "0.4", features = ["wasm_js"] }
```
## 2. Embed the EFF Short Word List 1 in src/lib.rs
The EFF Short Word List 1 contains 1296 common English words designed for memorable passphrases.
Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt
Generate the Rust const array (run from shell, paste output into src/lib.rs):
```sh
curl -s 'https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt' \
| awk '{print $2}' \
| awk 'NR % 8 == 1 {printf " "} {printf "\"%s\", ", $0} NR % 8 == 0 {print ""}' \
| sed '$a\' | tr -d '\n' | sed 's/, $//'
```
Place the word list as a module-level constant:
```rust
/// EFF Short Word List 1 — 1296 common English words designed for memorable passphrases.
/// Source: <https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt>
const WORDS: &[&str] = &[
"acid", "acorn", "acre", "acts", "afar", "affix", "aged", "agent",
"agile", "aging", "agony", "ahead", "aide", "aids", "aim", "ajar",
// ... (full 1296-word list, generated via shell command above)
];
```
## 3. Implement generate_auth_code() in src/lib.rs
```rust
use rand::rngs::OsRng;
use rand::seq::SliceRandom;
/// Generates a random 4-word passphrase in the format `word-word-word-word`.
///
/// Words are drawn from the EFF Short Word List 1 (1296 common English words).
/// The passphrase is used as an `auth_code` to authorize quote edits and deletes.
///
/// Uses `rand::rngs::OsRng` for entropy, which is safe on both native and
/// `wasm32-unknown-unknown` targets. On WASM (Cloudflare Workers), entropy
/// is sourced via `crypto.getRandomValues()` through `getrandom`'s `wasm_js` feature.
///
/// # Examples
///
/// ```
/// let code = quotesdb::generate_auth_code();
/// let words: Vec<&str> = code.split('-').collect();
/// assert_eq!(words.len(), 4);
/// assert!(words.iter().all(|w| !w.is_empty()));
/// ```
pub fn generate_auth_code() -> String {
WORDS
.choose_multiple(&mut OsRng, 4)
.copied()
.collect::<Vec<_>>()
.join("-")
}
```
## 4. Unit tests (src/lib.rs tests module or src/tests.rs)
```rust
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Verify format: exactly 4 non-empty words from WORDS, joined by hyphens.
#[test]
fn auth_code_has_four_valid_words() {
for _ in 0..100 {
let code = generate_auth_code();
let parts: Vec<&str> = code.split('-').collect();
assert_eq!(parts.len(), 4, "expected 4 words, got: {code}");
for word in &parts {
assert!(!word.is_empty(), "empty word in code: {code}");
assert!(
WORDS.contains(word),
"word '{word}' not in word list, code: {code}"
);
}
}
}
/// Verify randomness: 20 samples should produce at least 10 distinct codes.
#[test]
fn auth_codes_are_varied() {
let codes: HashSet<String> = (0..20).map(|_| generate_auth_code()).collect();
assert!(
codes.len() > 10,
"expected >10 unique codes in 20 samples, got {}",
codes.len()
);
}
}
```
</implementation>
<constraints>
- 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
</constraints>
<skills>
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.
</skills>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
@ -20,7 +20,7 @@ Response 201: `{ quote: {...}, auth_code: "word-word-word-word" }`
<goal>
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:
<constraints>
- 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).
</constraints>
@ -51,5 +51,5 @@ cargo test
</validation>
<commit>
`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`
</commit>

@ -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 = []
+++
<context>
@ -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.
</options>
<decision>
**Option 3 (attribute reference) — and there is no chicken-and-egg problem.**
This is a common Terraform/OpenTofu misconception. Writing `database_id = cloudflare_d1_database.db.id` in the Worker resource creates an **implicit dependency** via the attribute reference. OpenTofu:
1. Sees that `cloudflare_workers_script.api` depends on `cloudflare_d1_database.db` (via the `.id` reference)
2. Plans D1 creation first; shows Worker `database_id` as `(known after apply)` — this is **expected and correct**
3. During `tofu apply`: creates D1 first → gets its ID from state → creates Worker with that ID
No two-phase apply, no `data` source, no explicit `depends_on`. A single `tofu apply` provisions both resources in the correct order.
Confirmed from Cloudflare provider v4 source:
- D1 resource: `cloudflare_d1_database` — outputs `id` (String)
- Worker resource: `cloudflare_workers_script` (plural) — `d1_database_binding` block with `database_id` and `name` fields
- This also confirms the answer to TRIAGE efee79: resource name is `cloudflare_workers_script`
Concrete HCL:
```hcl
resource "cloudflare_d1_database" "db" {
account_id = var.cloudflare_account_id
name = "quotesdb"
}
resource "cloudflare_workers_script" "api" {
account_id = var.cloudflare_account_id
name = "quotesdb-api"
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.db.id # (known after apply) — resolved automatically
}
}
```
API Worker CI/CD deploy ticket: 57fe5e
</decision>
<resolution>
1. Research the options above and choose the best approach for this project.
2. Update the `infra/worker.tf` and `infra/d1.tf` resources with the chosen approach. Update ticket a23489 and d0da0b with any constraints.
@ -27,5 +64,5 @@ D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu ap
</resolution>
<commit>
`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`
</commit>

@ -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 = []
+++
<context>
@ -21,9 +21,19 @@ OpenTofu state backend: should the `.tfstate` file be stored locally (gitignored
</options>
<resolution>
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.
</resolution>
<commit>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -21,9 +21,33 @@ Auth code storage strategy for the UI: should the auth code be stored in localSt
</options>
<resolution>
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<String>` prop addition to `AuthModal` and the parent-component
integration pattern (read on modal open, write on success, clear on 403 or DELETE).
Tickets updated:
- **f850c6** (AuthModal): triage dependency replaced with 5379eb; goal updated with
`initial_value` prop; constraints updated with resolved storage approach.
- **c3503b** (UI sub-project): 0bc655 removed, 5379eb added.
</resolution>
<commit>

@ -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 = []
+++
<context>
@ -21,9 +21,22 @@ HTTP client for integration tests: should we use reqwest (async, tokio), hyper (
</options>
<resolution>
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::<T>()` response
deserialization — essential for testing JSON API endpoints.
- Dev-dependency only: the weight of the crate does not affect the production binary size.
Options 2 (hyper) and 3 (ureq) were ruled out:
- hyper 1.x has a complex, low-level API that adds boilerplate with no test-writing benefit.
- ureq is synchronous; using it with an async Axum server would require spawning a background
thread for the server in every test, adding avoidable setup complexity.
Implementation ticket **5f5ba0** (already exists and is correctly specified) captures all
necessary work: adds `reqwest = { version = "0.12", features = ["json"] }`, `tokio`, `serde_json`,
and `tempfile` to `[dev-dependencies]` in `Cargo.toml`. No new ticket is required.
</resolution>
<commit>

@ -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"]
+++
<context>

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

@ -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 = []
+++
<context>
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
</context>
@ -14,16 +13,18 @@ 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?
</question>
<options>
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.
</options>
<resolution>
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"
</resolution>
<commit>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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).
</options>
<resolution>
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.
</resolution>
<decision>
**The 1 MB limit in this ticket is outdated.** The current Cloudflare Workers limits (as of 2026) are:
- Workers Free: **3 MB after gzip compression**, 64 MB before compression
- Workers Paid: **10 MB after gzip**, 64 MB before compression
**Chosen approach: Free tier + binary size optimisation (no paid plan required).**
Rationale:
- The API Worker only handles API routes — no Yew/UI code is bundled into it (UI is on Pages).
- The database layer uses `workers-rs` native D1 bindings (not SQLx) per ticket e8a330 — this eliminates a heavy dependency.
- The `Cargo.toml` release profile already has `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`.
- `wrangler` applies `wasm-opt` automatically during deploy, further reducing WASM size.
- A simple CRUD API with these optimisations gzips well under 3 MB.
- Verification command: `wrangler deploy --outdir bundled/ --dry-run` (shows `gzip: X KiB`).
**Implementation ticket created: see ticket for binary size verification after Cargo.toml dependencies are added (1f5bb5).**
If the binary somehow exceeds 3 MB, fallback options are: further dependency pruning, or Workers Paid at $5/month.
</decision>
<commit>
`chore(quotesdb): resolve triage — cloudflare-workers-wasm-size-limit-free-tier-1mb-limit-may-r`

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
@ -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.
</goal>
<base-url-approach>
CORS/proxy resolved in triage a9534d: **Trunk proxy, relative URLs**.
- Use **relative URLs** (`/api/quotes`, `/api/quotes/random`, etc.) — no base URL configuration needed.
- In local dev, Trunk's `[[proxy]]` in `Trunk.toml` forwards `/api/*` to `localhost:3000` transparently.
- In production, Cloudflare routes `/api/*` to the Worker at the same domain.
- Do NOT use `window.location.origin` as a base URL — relative paths work everywhere.
- Do NOT add any CORS headers in the frontend — no cross-origin requests occur.
</base-url-approach>
<constraints>
- Use `gloo::net::http` 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.
</constraints>
<validation>

@ -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"]
+++
<context>
@ -17,10 +17,20 @@ Create `Cargo.toml` for the `quotesdb` crate with all API-side dependencies. Inc
</goal>
<constraints>
- `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]`.
</constraints>
<skills>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
@ -15,13 +15,35 @@ The `GET /api/` endpoint serves the OpenAPI 3.1.0 specification as JSON. This en
</context>
<goal>
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/`.
</goal>
<constraints>
- 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.
</constraints>
<skills>

@ -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 = []
+++
<context>
@ -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.
</options>
<resolution>
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.
</resolution>
<decision>
**Option 1 variant: `cfg(target_arch = "wasm32")` split — no feature flag, no separate binary.**
The `cfg(target_arch)` approach in Cargo.toml means that when `cargo test` runs on the native
host, the workers-rs crate is never pulled in (it is a `[target.'cfg(target_arch = "wasm32")'.dependencies]`
entry). The native Axum server path compiles automatically.
Integration tests in `tests/` start the server by calling a `spawn_test_server()` helper that:
1. Opens an in-memory or temp-file rusqlite DB (via `NativeRepository`)
2. Calls `router::build_router(repo)` to get the Axum `Router`
3. Binds to a random port with `tokio::net::TcpListener::bind("127.0.0.1:0")`
4. Spawns the server with `tokio::spawn(axum::serve(listener, app))`
5. Returns `(base_url, shutdown_handle)`
No wrangler dev, no separate binary, no feature flags. Standard `cargo test` workflow.
Resolved as part of TRIAGE e8a330 and a91260 (cfg-split architecture decision).
See implementation ticket 00aff0 for the DB abstraction details and ticket 9b581f for the
test harness implementation.
</decision>
<commit>
`chore(quotesdb): resolve triage — test-harness-how-to-import-and-start-quotesdbapi-in-tests-wo`

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
@ -13,15 +13,16 @@ Infrastructure is managed with OpenTofu using the Cloudflare provider. Configura
<goal>
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
</goal>
<constraints>
- 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.
</constraints>
<validation>

@ -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 = []
+++
<context>
@ -21,9 +21,32 @@ OpenAPI spec serving strategy: should the spec be embedded at compile time (incl
</options>
<resolution>
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.
</resolution>
<commit>

@ -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 = []
+++
<context>
@ -20,10 +20,35 @@ 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.
</options>
<resolution>
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.
<resolution status="resolved">
**Chosen approach: rusqlite with a local SQLite file — a variant of Option 3, but using rusqlite instead of sqlx.**
This decision is a direct consequence of TRIAGE e8a330 (already resolved): SQLx is NOT compatible
with Cloudflare Workers/D1 at all. The chosen architecture is `cfg(target_arch = "wasm32")` compile-time split:
- `wasm32` (production) → workers-rs `D1Database` bindings
- native (local dev + tests) → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file
Rationale for rusqlite over Turso:
- No additional dependency or service (Turso = libsql client + server/cloud)
- `rusqlite` with `features = ["bundled"]` compiles SQLite in — zero system dependencies
- `cargo run` just works without any account, credentials, or external tooling
- `cargo test` works the same way — tests use the rusqlite path automatically
Rationale for rusqlite over wrangler D1 local:
- No wrangler, no Cloudflare account required for local dev or CI
- Eliminates a major developer friction point
- Integration tests use `NativeRepository` (rusqlite) directly without spawning wrangler
Selection mechanism: **compile-time** via `cfg(target_arch = "wasm32")`, not runtime env var.
The `DATABASE_URL` env var controls the SQLite file path (default: `./quotesdb.sqlite`).
Port note: Native API server binds to `localhost:3000` (Trunk UI dev server uses `localhost:8080`).
Port conflict found and fixed in ticket 00aff0 (was 8080, corrected to 3000).
Updated:
- Ticket 00aff0 (DB abstraction): corrected native server port 8080 → 3000
- Ticket af56a7 (local dev docs): updated title and body to reflect rusqlite approach
- Ticket 9c9546 (new): create `.env.example` documenting `DATABASE_URL`
</resolution>
<commit>

@ -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"]
+++
<context>

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

@ -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"]
+++
<context>

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

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

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

@ -1,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 = []
+++
<context>
@ -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).
</options>
<decision>
**Option 2: `wrangler d1 execute` as a separate CI/CD step.**
- Option 3 (SQLx) is ruled out — TRIAGE e8a330 established that SQLx is incompatible with workers-rs/D1.
- Option 1 (startup migration from the Workers handler) is impractical: Workers spin up per-request via V8 isolates. Running DDL before every request adds latency and is fragile.
- Option 2 is the canonical Cloudflare-recommended approach. It is idempotent (`CREATE TABLE IF NOT EXISTS`), keeps the Workers handler free of DDL overhead, and integrates cleanly into CI/CD as a post-`tofu apply` step.
**Production:** `wrangler d1 execute quotesdb --file infra/schema.sql --remote` — run once after first `tofu apply`, and again for each incremental migration file.
**Local dev / tests:** `NativeRepository::run_migrations()` (ticket 00aff0) runs `execute_batch` via rusqlite on native startup. No manual wrangler step needed.
This decision is co-resolved with TRIAGE 5c0c64, which asked the same question from the OpenTofu angle. Both arrive at the same answer.
</decision>
<resolution>
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.
</resolution>
<commit>

@ -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 = []
+++
<context>
@ -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.
</options>
<decision>
**Option 2: Separate wrangler step.**
- **null_resource local-exec rejected:** `null_resource` provisioners are an OpenTofu anti-pattern. They don't re-run unless tainted, aren't tracked in state, require wrangler installed on the CI runner at `tofu apply` time, and break idempotency. The convenience of a single command is not worth the coupling.
- **API startup migration rejected:** Cloudflare Workers spin up per-request via V8 isolates. There is no persistent startup phase. Running DDL (`CREATE TABLE IF NOT EXISTS`) before every request adds latency and is fragile. The Workers fetch handler (D1Repository, wasm32 path) does NOT run migrations. This is only viable for the native/local dev path (rusqlite), where `NativeRepository::run_migrations()` is called once at `main()` startup.
- **Separate wrangler step chosen:** This is Cloudflare's canonical approach. The schema SQL lives at `infra/schema.sql` (ticket bb1514). After `tofu apply`, run once:
```sh
wrangler d1 execute quotesdb --file infra/schema.sql --remote
```
Idempotent with `CREATE TABLE IF NOT EXISTS`. Integrates cleanly into CI/CD as a post-apply step. Keeps OpenTofu focused on infrastructure, not data.
**Note:** TRIAGE 580e66 asks the same question from the Workers runtime angle and arrives at the same answer. Both are now resolved together.
</decision>
<resolution>
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`.
</resolution>
<commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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.
</options>
<decision>
**Plain CSS** — Option 1.
Rationale:
- CDN Tailwind's JIT scanner reads DOM/HTML for class names to generate utility CSS on-the-fly. In a Yew Wasm app, class names are Rust strings compiled into the Wasm binary — they are never present in the HTML that Tailwind's scanner reads. The result is a non-functional Tailwind build with no utility classes.
- Stylist/yew-style add a Wasm dependency, sparse documentation, and binary bloat for a 5-page app where co-location of styles provides no real benefit.
- Plain CSS + Trunk: Trunk natively bundles CSS via `<link data-trunk rel="css" href="src/bin/ui/style.css"/>` in `index.html`. Zero additional dependencies, no build complexity, easy maintainability.
Implementation:
- CSS file: `src/bin/ui/style.css` (Trunk discovers files relative to `index.html`)
- index.html link: `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`
- Naming convention: BEM-style semantic names — `quote-card`, `quote-card__text`, `quote-card__author`, `page-browse`, etc.
- Yew usage: `class={"quote-card"}` or `classes!["quote-card", conditional]`
- Dedicated implementation ticket: 0fbdd5
</decision>
<resolution>
1. Research the options above and choose the best approach for this project.
2. Update ticket dc3d2b (Trunk.toml + index.html) and all UI component tickets with the chosen CSS class strategy.
@ -27,5 +43,5 @@ CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Ta
</resolution>
<commit>
`chore(quotesdb): resolve triage — cssstyling-approach-for-wasm-plain-css-cdn-tailwind-or-wasmc`
`chore(quotesdb): resolve triage — css-styling-approach-plain-css`
</commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment.
Triage decisions:
- 0d84fa: HTTP client → `reqwest` with `tokio::test`
- fba598: Isolation strategy → per-test temp SQLite file via `tempfile` crate
</context>
<goal>
Add integration test 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.
</goal>
<constraints>
- 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::<T>()` response deserialization.
</constraints>
<skills>
@ -36,5 +51,5 @@ cargo test
</validation>
<commit>
`chore(quotesdb): set up integration test dependencies in Cargo.toml`
`chore(quotesdb): add integration test dev-dependencies (reqwest, tokio, serde_json, tempfile)`
</commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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.
</resolution>
<decision>
## Chosen: Option 3 — Custom embedded word list + `rand 0.10` + `getrandom 0.4` (wasm_js)
### Research findings
| Option | Verdict |
|---|---|
| `passphrase-wordlist` | **Does not exist** on crates.io. Eliminated. |
| `bip39` | WASM-compatible (8.8M downloads, used in web crypto wallets). Rejected: carries BIP-39 cryptocurrency semantics; word list (2048 entries) is tuned for phonetic distinctness, not general memorability; introduces unnecessary complexity. |
| Custom word list | **Chosen.** Minimal deps, full control, idiomatic Rust. |
### Implementation approach
**Word list:** EFF Short Word List 1 — 1296 common English words designed for memorable passphrases.
Source: `https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt`
Generate the Rust array: `curl -s <url> | awk '{print $2}'`
**RNG:** `rand::rngs::OsRng` from `rand = "0.10"`.
- Does NOT use thread-local storage (safe for wasm32)
- Uses `getrandom` as its entropy backend
- For WASM targets: add `getrandom = { version = "0.4", features = ["wasm_js"] }` in the wasm32 cfg section so Cloudflare Workers (which expose `crypto.getRandomValues()`) can seed the RNG
**Version note:** `rand 0.10` requires `getrandom ^0.4`. The `wasm_js` feature in `getrandom 0.4` replaces the old `js` feature from `getrandom 0.2`. The latest `uuid 1.21.0` also requires `getrandom ^0.4`, so both deps share one getrandom version in the dependency graph.
### Cargo.toml changes (update ticket 1f5bb5)
```toml
[dependencies]
rand = "0.10"
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues())
# Required by both rand (OsRng) and uuid (v4) on wasm32 targets
getrandom = { version = "0.4", features = ["wasm_js"] }
```
**Correction:** Tickets 7a0d9f and 1f5bb5 previously referenced `getrandom = "0.2", features = ["js"]` — this is outdated. uuid 1.21 and rand 0.10 both require getrandom ^0.4, which renamed the feature to `wasm_js`. Both those tickets have been updated.
### Updated tickets
- **03bb91** — updated with full implementation plan (code, word list steps, tests)
- **1f5bb5** — corrected getrandom version to 0.4/wasm_js; added rand 0.10 dep
- **7a0d9f** — corrected getrandom version to 0.4/wasm_js (was 0.2/js)
</decision>
<commit>
`chore(quotesdb): resolve triage — 4word-passphrase-crate-selection-for-wasm-target-nostdwasm32`
</commit>

@ -1,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 = []
+++
<context>
@ -21,9 +21,31 @@ NanoID crate WASM compatibility: does the chosen nanoid crate compile for wasm32
</options>
<resolution>
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
</resolution>
<commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
Cloudflare D1 uses SQL migrations. Because the Worker runs in the Cloudflare runtime (not a standard server), migrations must be applied via a separate mechanism (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.
</context>
<goal>
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
</goal>
<constraints>
- 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.
</constraints>
<commit>
`docs(quotesdb): document D1 schema migration workflow`
`docs(quotesdb): document D1 schema migration workflow in infra/README.md`
</commit>

@ -3,7 +3,7 @@ title = "Write tests/README.md"
priority = 3
status = "todo"
ticket_type = "task"
dependencies = ["ce1e4f", "9b581f"]
dependencies = ["9b581f"]
+++
<context>

@ -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"]
+++
<context>

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

@ -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"]
+++
<context>

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

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
TRIAGE resolved (ticket 166996): use yew = "0.22", yew-router = "0.19", wasm-bindgen = "0.2" (compatible with wasm-bindgen-cli 0.2.108 in the Nix dev shell).
</context>
<goal>
Add UI-side Yew/Wasm dependencies to `Cargo.toml` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. 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.
</goal>
<constraints>
- 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.
</constraints>
<skills>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment.
Architecture decided in triage:
- 2ab7a8: Server is spawned as a tokio task using the native Axum path (cfg-split, no workers-rs on host)
- fba598: Isolation strategy is **per-test temp SQLite file** via `tempfile` crate (transaction rollback cannot intercept server-side pool commits; in-memory SQLite is incompatible with multi-connection SQLx pools)
- 0d84fa: HTTP client for tests is `reqwest` with `tokio::test`
</context>
<goal>
Implement 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
</goal>
<implementation>
```rust
// tests/helpers.rs
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
use sqlx::SqlitePool;
pub struct TestContext {
_db_dir: TempDir, // deleted on drop
pub base_url: String,
_server: tokio::task::JoinHandle<()>,
}
pub async fn spawn_test_server() -> TestContext {
let db_dir = TempDir::new().expect("temp dir");
let db_path = db_dir.path().join("test.sqlite");
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&db_url).await.expect("pool");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrations");
let repo = Arc::new(NativeRepository::new(pool));
let app = build_router(repo);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestContext {
_db_dir: db_dir,
base_url: format!("http://127.0.0.1:{port}"),
_server: server,
}
}
```
Usage in a test:
```rust
#[tokio::test]
async fn test_create_quote() {
let ctx = spawn_test_server().await;
let client = reqwest::Client::new();
let res = client
.put(format!("{}/api/quotes", ctx.base_url))
.json(&serde_json::json!({"text": "hello", "author": "world"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
}
```
</implementation>
<constraints>
- 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).
</constraints>
<dependencies-needed>
In `[dev-dependencies]` (ticket 5f5ba0):
- `tempfile = "3"`
- `reqwest = { version = "0.12", features = ["json"] }`
- `tokio = { version = "1", features = ["full"] }`
- `serde_json = "1"`
</dependencies-needed>
<skills>
Use `superpowers:test-driven-development` — the harness is itself tested by running `cargo test`.
Use `superpowers:verification-before-completion` before closing.
@ -43,5 +116,5 @@ cargo test
</validation>
<commit>
`test(quotesdb): implement test server harness with temp SQLite DB`
`test(quotesdb): implement test server harness with per-test temp SQLite DB`
</commit>

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

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

@ -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"]
+++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
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).
</context>
<goal>
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.
</goal>
<constraints>
- 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.
</constraints>
<implementation>
```hcl
# infra/worker.tf
# Cloudflare Workers script for the quotesdb API.
# Compiled from the `api` binary targeting wasm32-unknown-unknown.
# The Wasm artifact must be built before running `tofu apply`:
# cargo build --release --target wasm32-unknown-unknown --bin api
resource "cloudflare_workers_script" "api" {
account_id = var.cloudflare_account_id
# Script name used in Cloudflare dashboard and for routing.
name = "quotesdb-api"
# Compiled Wasm binary content, base64-encoded.
# Path is relative to the infra/ directory.
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
# D1 database binding — referenced in workers-rs code as `env.DB`.
# `database_id` is resolved at apply time from the D1 resource output.
# OpenTofu automatically creates the D1 database before this script
# because of the attribute reference below (no explicit depends_on needed).
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.db.id
}
# Workers runtime compatibility date.
compatibility_date = "2024-09-23"
}
```
</implementation>
<notes>
- The `content` attribute expects base64-encoded script bytes. For a Wasm Worker, this is the raw compiled Wasm file, not a JS bundle.
- The binding `name = "DB"` must match exactly what the workers-rs API code uses (`env.DB`). Verify this in `src/bin/api/main.rs`.
- `(known after apply)` for `database_id` in `tofu plan` is expected and correct — OpenTofu resolves it at apply time.
- The Wasm binary must be compiled before `tofu apply`. This is handled by the Gitea Actions CI/CD workflow (ticket to be created; also see ticket 5137d7 for the UI workflow pattern).
</notes>
<validation>
Run from the `infra/` directory:

@ -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"]
+++
<context>
@ -16,17 +16,37 @@ The database schema consists of two tables:
- `quote_tags` — join table for quote-to-tag relationships with cascade delete
</context>
<superseded-by>
**This ticket's SQLx-based goal has been superseded by ticket 00aff0.**
TRIAGE e8a330 concluded that SQLx is incompatible with workers-rs/D1. The new approach uses:
- workers-rs `D1Database` bindings for the WASM/production target
- `rusqlite` + `tokio-rusqlite` for the native/test target
- A `QuoteRepository` async trait as the shared interface
- `cfg(target_arch = "wasm32")` for compile-time target selection
See ticket 00aff0 for the full implementation plan.
This ticket remains open as tracking context but its implementation is covered by 00aff0.
</superseded-by>
<goal>
Implement `src/bin/api/db.rs` (or equivalent module) providing:
1. A database connection pool constructor (Turso/SQLite locally, D1 in production)
2. SQLx migrations that create the `quotes` and `quote_tags` tables if they don't exist
3. Re-export the pool type for use by handlers
~~Implement `src/bin/api/db.rs` (or equivalent module) providing:~~
~~1. A database connection pool constructor (Turso/SQLite locally, D1 in production)~~
~~2. SQLx migrations that create the `quotes` and `quote_tags` tables if they don't exist~~
~~3. Re-export the pool type for use by handlers~~
**Updated goal (see ticket 00aff0):** Implement `src/bin/api/db/` module with:
1. `QuoteRepository` trait in `db/mod.rs`
2. `D1Repository` in `db/d1.rs` (`#[cfg(target_arch = "wasm32")]`)
3. `NativeRepository` in `db/native.rs` (`#[cfg(not(target_arch = "wasm32"))]`)
4. SQL migration strings in `db/migrations.rs`
</goal>
<constraints>
- 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.
</constraints>
<skills>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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.
</options>
<resolution>
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.
</resolution>
<decision>
**Option 1 variant: `cfg(target_arch = "wasm32")` — no feature flag needed.**
Note: `target_env = "worker"` is incorrect. The right discriminant is `target_arch = "wasm32"`.
The `cfg(target_arch)` split is cleaner than a feature flag because it is tied to the actual
build target, not an opt-in flag that could be forgotten:
- `[target.'cfg(target_arch = "wasm32")'.dependencies]` → workers-rs (pulled in only for WASM)
- `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` → tokio, axum, rusqlite (native only)
In `main.rs`:
```rust
#[cfg(target_arch = "wasm32")]
#[worker::event(fetch)]
pub async fn main(...) { /* workers-rs entry point */ }
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() { /* native Axum server on :8080 */ }
```
`cargo test` (native host) automatically compiles the native path. No special flags.
No feature flag pollution. Resolved as part of TRIAGE e8a330 (DB strategy decision).
See implementation ticket 00aff0 for full details.
</decision>
<commit>
`chore(quotesdb): resolve triage — workersrs-compatibility-with-native-rust-test-binaries-may-n`

@ -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 = []
+++
<context>
@ -20,10 +20,21 @@ 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.
</options>
<resolution>
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.
<resolution status="resolved">
**Chosen approach: Option 1 — Trunk proxy.**
Rationale:
- Mirrors the production architecture: Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site. No CORS configuration is needed in production either.
- Frontend uses **relative URLs** (`/api/quotes`, not `http://localhost:3000/api/quotes`). The same paths work in both dev (Trunk proxies them) and production (Cloudflare routes them).
- Zero CORS configuration: no `tower-http` CORS middleware, no `Access-Control-Allow-Origin` headers. Simpler API, smaller attack surface.
- Port: API runs on `localhost:3000` via `cargo run` (plain Axum/Tokio for local dev).
Implementation:
- `Trunk.toml` — add `[[proxy]] rewrite = "/api" backend = "http://localhost:3000"`. See ticket 00d6d7.
- `src/bin/ui/api.rs` — use relative URLs only. See ticket 1e6a09.
Updated tickets: dc3d2b (Trunk.toml setup), 1e6a09 (API client module).
Created ticket: 00d6d7 (dedicated implementation task for the proxy config).
</resolution>
<commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
Cloudflare 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).
</context>
<goal>
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.
</goal>
<constraints>
- 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 `<link data-trunk rel="copy-file" href="_redirects"/>` (ticket 9ef703).
No changes are needed in the OpenTofu Pages resource — Cloudflare Pages processes `_redirects`
automatically from the uploaded `dist/` directory.
- The output directory (`dist/`) is a Trunk convention; it is documented here for reference but not configured in OpenTofu (wrangler handles it at deploy time).
- The Pages project name `quotesdb-ui` must match the name used in `wrangler pages deploy --project-name quotesdb-ui`.
</constraints>
<reference>
```hcl
# infra/pages.tf
# Cloudflare Pages project for the quotesdb Yew/Wasm frontend.
# Uses direct upload — artifacts are deployed via wrangler in Gitea Actions (ticket 3781c9).
resource "cloudflare_pages_project" "ui" {
account_id = var.cloudflare_account_id
name = "quotesdb-ui"
production_branch = "quotesdb"
# Deployment configuration for the production environment.
deployment_configs {
production {
compatibility_date = "2024-01-01"
# SPA routing: handled by dist/_redirects (/* /index.html 200) — see ticket 9ef703.
}
}
}
```
</reference>
<validation>
Run from the `infra/` directory:

@ -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"]
+++
<context>
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
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.
</context>
<goal>
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
</goal>
<decisions-reflected>
- TRIAGE 33ed29: rusqlite + local SQLite file (not Turso, not wrangler dev)
- TRIAGE a9534d: Trunk proxy for `/api/*` (not CORS middleware)
- TRIAGE e8a330: no SQLx, `cfg(target_arch)` split
</decisions-reflected>
<constraints>
- Do not 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.
</constraints>
<commit>
`docs(quotesdb): document local dev environment setup for api`
`docs(quotesdb): write LOCAL_DEV.md — local dev quickstart for api and ui`
</commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
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`.
</context>
<goal>
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.
</goal>
<implementation>
## 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.
</implementation>
<why-not-the-other-options>
**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.
</why-not-the-other-options>
<constraints>
- `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.
</constraints>
<validation>
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
```
</validation>
<related-tickets>
- 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
</related-tickets>
<commit>
`feat(quotesdb): add infra/schema.sql with idempotent D1 schema for quotes and quote_tags`
</commit>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
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.
</context>
<goal>
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`.
</goal>
<constraints>
- 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).
</constraints>
<implementation>
```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
}
```
</implementation>
<notes>
- `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.
</notes>
<validation>
Run from the `infra/` directory:

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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"]
+++
<context>
@ -11,17 +11,54 @@ The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and
</context>
<goal>
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 `<link>` 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 `<link data-trunk rel="css" href="src/bin/ui/style.css"/>` (plain CSS — see triage 5e3e37)
- Include `<link data-trunk rel="rust" data-bin="ui"/>` so Trunk compiles the `ui` binary
- Include `<div id="app"></div>` as the Yew mount point
Verify `trunk serve` starts successfully.
Verify `trunk build` succeeds.
</goal>
<css-approach>
CSS approach resolved in triage 5e3e37: **plain CSS**.
- CSS file lives at `src/bin/ui/style.css`
- Linked in index.html as: `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`
- 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.
</css-approach>
<proxy-config>
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
</proxy-config>
<constraints>
- 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 `<div id="app">` 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 `<link data-trunk rel="copy-file" href="_redirects"/>` line
added by ticket 9ef703 — that line must remain in `index.html`.
</constraints>
<validation>

@ -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 = []
+++
<context>
@ -21,9 +21,27 @@ Cloudflare Pages SPA routing: Yew uses client-side routing. A direct URL to `/br
</options>
<resolution>
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
`<link data-trunk rel="copy-file" href="_redirects"/>` 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.
</resolution>
<commit>

@ -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 = []
+++
<context>
@ -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.
</options>
<decision>
**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.
</decision>
<resolution>
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).
</resolution>
<commit>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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.
</options>
<decision>
**`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.
</decision>
<resolution>
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.
</resolution>
<commit>
`chore(quotesdb): resolve triage — correct-cloudflareworkersscript-resource-name-in-current-clo`
`chore(quotesdb): resolve triage — cloudflare_workers_script-confirmed-plural-provider-v4`
</commit>

@ -3,7 +3,7 @@ title = "quotesdb/api"
priority = 7
status = "todo"
ticket_type = "project"
dependencies = []
dependencies = ["00aff0", "af56a7", "9c9546", "8892d5"]
+++
<context>

@ -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"]
+++
<context>
@ -15,15 +15,21 @@ The auth code modal prompts the user to enter their `X-Auth-Code` (4-word passph
<goal>
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<String>` and `on_cancel: Callback<()>` props
3. Renders an `<input type="text">` for the auth code and Submit/Cancel buttons
4. Calls `on_submit` with the entered code when submitted
2. Accepts `on_submit: Callback<String>`, `on_cancel: Callback<()>`, and
`initial_value: Option<String>` props
3. Renders an `<input type="text">` 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
</goal>
<constraints>
- 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 `<input>` with `<label>`, support Escape key to
cancel, and focus the input when the modal opens.
- Do NOT persist the auth code in `localStorage` — session storage only (cleared on tab close).
</constraints>
<validation>

@ -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"]
+++
<context>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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.
</options>
<decision>
**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).
</decision>
<resolution>
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
</resolution>
<commit>
`chore(quotesdb): resolve triage — integration-test-isolation-strategy-pertest-temp-db-vs-share`
`chore(quotesdb): resolve triage — integration-test-isolation-per-test-tempfile-sqlite`
</commit>

@ -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"]
+++
<context>

@ -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 = []
+++
<context>
@ -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`.
</options>
<decision>
**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 (~35 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
</decision>
<resolution>
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
</resolution>
<commit>
`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`
</commit>

@ -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 <sub-project-id>`).
- 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 <sub-project-id> --deps "...existing...,<new-ticket-id>"`. 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).
</ticket-hierarchy>

@ -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`.

Loading…
Cancel
Save