Compare commits
77 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
dc7cfec897 | 3 months ago |
|
|
9418bd4b0b | 3 months ago |
|
|
dcbc659ec1 | 3 months ago |
|
|
bac6696c4b | 3 months ago |
|
|
deb3ec40f6 | 3 months ago |
|
|
b00f24ae85 | 3 months ago |
|
|
66cbe67100 | 3 months ago |
|
|
1728141517 | 3 months ago |
|
|
5dadc23246 | 3 months ago |
|
|
25adf3897f | 3 months ago |
|
|
00a9a36510 | 3 months ago |
|
|
77c131c08a | 3 months ago |
|
|
01cddd6e95 | 3 months ago |
|
|
b6f03fd967 | 3 months ago |
|
|
872ac9592c | 3 months ago |
|
|
f90dc2dc5e | 3 months ago |
|
|
e169d8b2cc | 3 months ago |
|
|
684c58fdfe | 3 months ago |
|
|
df3a288c9f | 3 months ago |
|
|
db71399b2f | 3 months ago |
|
|
ab76d35bd5 | 3 months ago |
|
|
4d4edae841 | 3 months ago |
|
|
9827dcc5b9 | 3 months ago |
|
|
995fff4046 | 3 months ago |
|
|
eecdbba9d7 | 3 months ago |
|
|
14cc879743 | 3 months ago |
|
|
86c5e4990d | 3 months ago |
|
|
c59efdc373 | 3 months ago |
|
|
549accded0 | 3 months ago |
|
|
00d195c86f | 3 months ago |
|
|
c4a59ec9ad | 3 months ago |
|
|
511c9fbf54 | 3 months ago |
|
|
5d2780a72a | 3 months ago |
|
|
49f70cc5e8 | 3 months ago |
|
|
a4d59b4371 | 3 months ago |
|
|
ab398b690c | 3 months ago |
|
|
80b998c192 | 3 months ago |
|
|
3684e196dd | 3 months ago |
|
|
401a4f45a5 | 3 months ago |
|
|
f6f652ef3e | 3 months ago |
|
|
6b90f34ccf | 3 months ago |
|
|
b0cb813740 | 3 months ago |
|
|
585f4b2f02 | 3 months ago |
|
|
c9142edbbf | 3 months ago |
|
|
15d9de3947 | 3 months ago |
|
|
5dcbb334fa | 3 months ago |
|
|
014dc39ea4 | 3 months ago |
|
|
267a95aa13 | 3 months ago |
|
|
bdf99b32c4 | 3 months ago |
|
|
d9f14bfc53 | 3 months ago |
|
|
7e2f01e822 | 3 months ago |
|
|
b48f56712e | 3 months ago |
|
|
ad06c8befb | 3 months ago |
|
|
e3fe2253f9 | 3 months ago |
|
|
d8560ef3d5 | 3 months ago |
|
|
4f1aa5560a | 3 months ago |
|
|
7529b43845 | 3 months ago |
|
|
c436ba07c7 | 3 months ago |
|
|
d317648d2f | 3 months ago |
|
|
183994b3dc | 3 months ago |
|
|
bc48924d16 | 3 months ago |
|
|
52e771e9c4 | 3 months ago |
|
|
6f7614c0c8 | 3 months ago |
|
|
a7b2d6fd4e | 3 months ago |
|
|
83f4aacdf5 | 3 months ago |
|
|
8da0d754a0 | 3 months ago |
|
|
af8c476305 | 3 months ago |
|
|
9f28d99a93 | 3 months ago |
|
|
b695cb5b6d | 3 months ago |
|
|
7bd2bf4ed5 | 3 months ago |
|
|
b0cf17ec83 | 3 months ago |
|
|
dc73fc8f69 | 3 months ago |
|
|
5926d33bcc | 3 months ago |
|
|
ec2a4055ca | 3 months ago |
|
|
65b0fdf1e2 | 3 months ago |
|
|
91ecd91176 | 3 months ago |
|
|
de99f1c047 | 3 months ago |
@ -0,0 +1,63 @@
|
||||
# Builds the quotesdb API Worker Wasm binary and applies OpenTofu infrastructure.
|
||||
# Triggered on push to the quotesdb branch when API or infra files change.
|
||||
# Required secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ZONE_ID
|
||||
|
||||
name: Deploy quotesdb API
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- quotesdb
|
||||
paths:
|
||||
- "quotesdb/src/bin/api/**"
|
||||
- "quotesdb/src/lib.rs"
|
||||
- "quotesdb/infra/**"
|
||||
- "quotesdb/Cargo.toml"
|
||||
- "quotesdb/Cargo.lock"
|
||||
|
||||
jobs:
|
||||
deploy-api:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: quotesdb
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain with wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
quotesdb/target
|
||||
key: ${{ runner.os }}-cargo-api-${{ hashFiles('quotesdb/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-api-
|
||||
|
||||
- name: Build API Wasm binary
|
||||
run: cargo build --release --bin api --target wasm32-unknown-unknown
|
||||
|
||||
- name: Setup OpenTofu
|
||||
uses: opentofu/setup-opentofu@v1
|
||||
|
||||
- name: Initialise and apply infrastructure
|
||||
working-directory: quotesdb/infra
|
||||
env:
|
||||
TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
TF_VAR_cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
TF_VAR_cloudflare_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
run: |
|
||||
tofu init
|
||||
tofu apply -auto-approve
|
||||
|
||||
- name: Apply D1 schema migrations
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: wrangler d1 execute quotesdb --file infra/schema.sql --remote
|
||||
@ -0,0 +1,57 @@
|
||||
# Builds the quotesdb Yew/Wasm UI with Trunk and deploys to Cloudflare Pages.
|
||||
# Triggered on push to the quotesdb branch when UI files change.
|
||||
# Required secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
|
||||
|
||||
name: Deploy quotesdb UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- quotesdb
|
||||
paths:
|
||||
- "quotesdb/src/bin/ui/**"
|
||||
- "quotesdb/index.html"
|
||||
- "quotesdb/Trunk.toml"
|
||||
- "quotesdb/_redirects"
|
||||
- "quotesdb/src/lib.rs"
|
||||
- "quotesdb/Cargo.toml"
|
||||
- "quotesdb/Cargo.lock"
|
||||
|
||||
jobs:
|
||||
deploy-ui:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: quotesdb
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain with wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
quotesdb/target
|
||||
key: ${{ runner.os }}-cargo-ui-${{ hashFiles('quotesdb/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-ui-
|
||||
|
||||
- name: Install Trunk
|
||||
run: cargo install trunk
|
||||
|
||||
- name: Build UI with Trunk
|
||||
run: trunk build --release
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy dist/ --project-name quotesdb-ui --branch quotesdb
|
||||
@ -0,0 +1,9 @@
|
||||
# quotesdb local development environment variables
|
||||
# Copy to .env and customise. The .env file is gitignored — never commit it.
|
||||
#
|
||||
# All variables below are optional for local development.
|
||||
# In production, the Workers runtime uses the D1 binding — DATABASE_URL is unused.
|
||||
|
||||
# Path to the local SQLite database file used by `cargo run` (native API server).
|
||||
# The file is created automatically on first run; migrations run on startup.
|
||||
DATABASE_URL=./quotesdb.sqlite
|
||||
@ -0,0 +1,7 @@
|
||||
dist/
|
||||
|
||||
# Binary database file
|
||||
quotesdb.sqlite*
|
||||
|
||||
# compiled files
|
||||
*.wasm
|
||||
@ -0,0 +1,343 @@
|
||||
+++
|
||||
title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
Resolution of TRIAGE ticket e8a330: **SQLx is NOT compatible with Cloudflare Workers/D1.**
|
||||
D1 is accessed through the workers-rs JavaScript binding layer, not a TCP connection.
|
||||
SQLx relies on TCP connections (Postgres, MySQL, SQLite file) and cannot work in the Workers runtime.
|
||||
|
||||
**Chosen approach: `cfg(target_arch)`-based split**
|
||||
|
||||
- `#[cfg(target_arch = "wasm32")]` → workers-rs D1 bindings (`worker::d1::D1Database`)
|
||||
- `#[cfg(not(target_arch = "wasm32"))]` → `rusqlite` + `tokio-rusqlite` (native dev/test)
|
||||
|
||||
`cargo test` on the native host automatically selects the rusqlite path. No feature flags,
|
||||
no wrangler dev required for integration tests. The design doc's "Query layer: SQLx" is
|
||||
superseded by this approach.
|
||||
|
||||
This decision also resolves TRIAGE tickets a91260 and 2ab7a8 (workers-rs native test binaries):
|
||||
the `cfg(target_arch)` split handles the test environment automatically.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the database abstraction layer for `quotesdb-api`:
|
||||
|
||||
1. **`src/bin/api/db/mod.rs`** — `QuoteRepository` async trait + shared result types
|
||||
2. **`src/bin/api/db/d1.rs`** — `D1Repository` using workers-rs D1 bindings (`wasm32` only)
|
||||
3. **`src/bin/api/db/native.rs`** — `NativeRepository` using `rusqlite`/`tokio-rusqlite` (native only)
|
||||
4. **`src/bin/api/db/migrations.rs`** — SQL migration strings (`CREATE TABLE IF NOT EXISTS`)
|
||||
5. **`Cargo.toml`** — wire cfg-split dependencies for workers-rs and rusqlite
|
||||
</goal>
|
||||
|
||||
<implementation-plan>
|
||||
|
||||
## 1. Cargo.toml dependency additions
|
||||
|
||||
```toml
|
||||
# Dependencies always present (both targets)
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# API only — WASM/Workers target
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
worker = { version = "0.7", features = ["d1", "axum"] }
|
||||
|
||||
# API only — native target (local dev and cargo test)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.8"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tokio-rusqlite = "0.5"
|
||||
```
|
||||
|
||||
`rusqlite` with `features = ["bundled"]` compiles SQLite in — no system SQLite dependency.
|
||||
|
||||
## 2. Module file layout
|
||||
|
||||
```
|
||||
src/bin/api/
|
||||
├── main.rs # cfg-split: workers-rs event handler OR native tokio::main
|
||||
├── router.rs # build_router<R: QuoteRepository>(...) — shared for both targets
|
||||
├── handlers/ # Axum route handlers — generic over repo type
|
||||
└── db/
|
||||
├── mod.rs # QuoteRepository trait + shared types (DbError, ListResult, etc.)
|
||||
├── d1.rs # D1Repository — cfg(target_arch = "wasm32")
|
||||
├── native.rs # NativeRepository — cfg(not(target_arch = "wasm32"))
|
||||
└── migrations.rs # SQL migration strings
|
||||
```
|
||||
|
||||
## 3. QuoteRepository trait (`src/bin/api/db/mod.rs`)
|
||||
|
||||
```rust
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod d1;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
pub mod migrations;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use d1::D1Repository;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::NativeRepository;
|
||||
|
||||
pub struct ListResult {
|
||||
pub quotes: Vec<crate::Quote>,
|
||||
pub page: u32,
|
||||
pub total_pages: u32,
|
||||
pub total_count: u32,
|
||||
}
|
||||
|
||||
pub enum DeleteResult { Deleted, NotFound, Forbidden }
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("database error: {0}")]
|
||||
Internal(String),
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
/// Async repository interface for all quote operations.
|
||||
///
|
||||
/// `?Send` is required because `D1Database` wraps JS values and is not `Send`.
|
||||
/// Both implementations satisfy this bound.
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait QuoteRepository {
|
||||
/// Run CREATE TABLE IF NOT EXISTS migrations. Call once on startup.
|
||||
async fn run_migrations(&self) -> Result<(), DbError>;
|
||||
|
||||
async fn list_quotes(
|
||||
&self, page: u32, author: Option<&str>, tag: Option<&str>,
|
||||
) -> Result<ListResult, DbError>;
|
||||
|
||||
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
|
||||
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
|
||||
|
||||
/// Creates a quote. Generates an auth_code if not provided.
|
||||
/// Returns (quote, auth_code).
|
||||
async fn create_quote(&self, input: crate::CreateQuoteInput) -> Result<(crate::Quote, String), DbError>;
|
||||
|
||||
async fn update_quote(
|
||||
&self, id: &str, auth_code: &str, input: crate::UpdateQuoteInput,
|
||||
) -> Result<Option<crate::Quote>, DbError>;
|
||||
|
||||
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
|
||||
}
|
||||
```
|
||||
|
||||
**Note on `?Send`**: Axum's `State<Arc<dyn QuoteRepository>>` requires `Send + Sync` on native
|
||||
(tokio multi-threaded). Since the trait is `?Send`, use a concrete type alias instead of
|
||||
a trait object:
|
||||
|
||||
```rust
|
||||
// In router.rs — concrete type alias, no dyn needed
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type AppRepo = db::D1Repository;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub type AppRepo = db::NativeRepository;
|
||||
|
||||
pub fn build_router(repo: Arc<AppRepo>) -> Router { ... }
|
||||
```
|
||||
|
||||
This avoids the trait-object/Send complexity entirely. Handlers receive `State<Arc<AppRepo>>`.
|
||||
|
||||
## 4. D1Repository (`src/bin/api/db/d1.rs`)
|
||||
|
||||
```rust
|
||||
// Only compiled for wasm32 — imports from worker crate are safe here
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
use worker::d1::D1Database;
|
||||
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
|
||||
|
||||
pub struct D1Repository { db: D1Database }
|
||||
|
||||
impl D1Repository {
|
||||
pub fn new(db: D1Database) -> Self { Self { db } }
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl QuoteRepository for D1Repository {
|
||||
async fn run_migrations(&self) -> Result<(), DbError> {
|
||||
self.db.prepare(super::migrations::CREATE_QUOTES)
|
||||
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||
self.db.prepare(super::migrations::CREATE_QUOTE_TAGS)
|
||||
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
|
||||
let row = self.db
|
||||
.prepare("SELECT q.id, q.text, q.author, q.source, q.date, q.created_at, q.updated_at \
|
||||
FROM quotes q WHERE q.id = ?1")
|
||||
.bind(&[id.into()])
|
||||
.map_err(|e| DbError::Internal(e.to_string()))?
|
||||
.first::<serde_json::Value>(None).await
|
||||
.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||
// Deserialize and attach tags via separate query
|
||||
// ...
|
||||
Ok(row.map(|v| serde_json::from_value(v).unwrap()))
|
||||
}
|
||||
// ... remaining methods
|
||||
}
|
||||
```
|
||||
|
||||
Tags require a separate query per quote (or a GROUP_CONCAT aggregation):
|
||||
```sql
|
||||
SELECT tag FROM quote_tags WHERE quote_id = ?1
|
||||
```
|
||||
|
||||
## 5. NativeRepository (`src/bin/api/db/native.rs`)
|
||||
|
||||
```rust
|
||||
// Only compiled for non-wasm32
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
use tokio_rusqlite::Connection;
|
||||
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
|
||||
|
||||
pub struct NativeRepository { conn: Connection }
|
||||
|
||||
impl NativeRepository {
|
||||
pub async fn new(db_path: &str) -> Result<Self, DbError> {
|
||||
let conn = Connection::open(db_path).await
|
||||
.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl QuoteRepository for NativeRepository {
|
||||
async fn run_migrations(&self) -> Result<(), DbError> {
|
||||
self.conn.call(|conn| {
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA foreign_keys = ON; {}; {};",
|
||||
super::migrations::CREATE_QUOTES,
|
||||
super::migrations::CREATE_QUOTE_TAGS,
|
||||
))?;
|
||||
Ok(())
|
||||
}).await.map_err(|e| DbError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
|
||||
let id = id.to_string();
|
||||
self.conn.call(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, text, author, source, date, created_at, updated_at \
|
||||
FROM quotes WHERE id = ?1")?;
|
||||
// map_row to Quote struct, then fetch tags separately
|
||||
// ...
|
||||
Ok(None) // placeholder
|
||||
}).await.map_err(|e| DbError::Internal(e.to_string()))
|
||||
}
|
||||
// ... remaining methods
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Migrations (`src/bin/api/db/migrations.rs`)
|
||||
|
||||
```rust
|
||||
/// Creates the quotes table if it does not already exist.
|
||||
pub const CREATE_QUOTES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
source TEXT,
|
||||
date TEXT,
|
||||
auth_code TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)";
|
||||
|
||||
/// Creates the quote_tags join table with cascade delete.
|
||||
pub const CREATE_QUOTE_TAGS: &str = "
|
||||
CREATE TABLE IF NOT EXISTS quote_tags (
|
||||
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (quote_id, tag)
|
||||
)";
|
||||
```
|
||||
|
||||
## 7. main.rs cfg-split entry points
|
||||
|
||||
```rust
|
||||
// src/bin/api/main.rs
|
||||
|
||||
// ── WASM / Workers entry point ──────────────────────────────────────────────
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use worker::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[event(fetch)]
|
||||
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
let db = env.d1("DB")?;
|
||||
let repo = std::sync::Arc::new(db::D1Repository::new(db));
|
||||
router::build_router(repo).call(req, env, ctx).await
|
||||
}
|
||||
|
||||
// ── Native entry point (local dev + cargo test server) ──────────────────────
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let db_path = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "./quotesdb.sqlite".to_string());
|
||||
let repo = std::sync::Arc::new(
|
||||
db::NativeRepository::new(&db_path).await.expect("failed to open DB")
|
||||
);
|
||||
repo.run_migrations().await.expect("failed to run migrations");
|
||||
let app = router::build_router(repo);
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
</implementation-plan>
|
||||
|
||||
<constraints>
|
||||
- `async_trait::async_trait(?Send)` required — `D1Database` wraps JS values and is NOT `Send`.
|
||||
- Use concrete type alias (`AppRepo`) in handlers/router instead of `dyn QuoteRepository` to avoid
|
||||
the Send + Sync trait object constraint on native Axum.
|
||||
- `rusqlite` must use `features = ["bundled"]` — no system SQLite dependency.
|
||||
- Tags are stored in a separate table; always fetch them with a second query per quote.
|
||||
- The `auth_code` column must be included in DB SELECT for update/delete auth checks but
|
||||
NEVER returned in public GET responses.
|
||||
- Foreign keys must be explicitly enabled in rusqlite: `PRAGMA foreign_keys = ON`.
|
||||
- `tokio-rusqlite` v0.5 uses `spawn_blocking` internally — safe to use from async handlers.
|
||||
</constraints>
|
||||
|
||||
<related-tickets>
|
||||
- Resolves TRIAGE: e8a330 (SQLx + workers-rs + D1 compatibility)
|
||||
- Also resolves TRIAGE: a91260 (workers-rs native test binaries) and 2ab7a8 (test harness approach)
|
||||
- Supersedes: a5049d (DB connection module — SQLx approach invalidated)
|
||||
- Informs: 1f5bb5 (Cargo.toml — cfg-split deps), 6e829e (api main.rs — cfg-split entry point), 9b581f (test harness)
|
||||
</related-tickets>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write `NativeRepository` tests before implementing query methods.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check # verifies native build (rusqlite path)
|
||||
cargo clippy
|
||||
cargo test # tests use NativeRepository automatically
|
||||
|
||||
# Also verify WASM target compiles (workers-rs D1 path):
|
||||
cargo check --target wasm32-unknown-unknown
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement QuoteRepository trait and cfg-split D1/rusqlite DB abstraction`
|
||||
</commit>
|
||||
@ -0,0 +1,95 @@
|
||||
+++
|
||||
title = "Add Trunk proxy config to Trunk.toml: forward /api/* to local API server"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["a9534d"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` project uses Trunk to build and serve the Yew (Wasm) frontend. During `trunk serve`, the UI
|
||||
runs on `localhost:8080` while the API runs separately on `localhost:3000`. Without a proxy, the browser
|
||||
would make cross-origin requests from `:8080` to `:3000`, requiring CORS headers.
|
||||
|
||||
Triage a9534d resolved this: use Trunk's built-in `[[proxy]]` to forward `/api/*` requests to the API server.
|
||||
No CORS configuration is required anywhere — the proxy makes all API calls appear same-origin to the browser.
|
||||
</context>
|
||||
|
||||
<decision>
|
||||
**Chosen approach (triage a9534d):** Trunk proxy.
|
||||
|
||||
Rationale:
|
||||
- Mirrors the production architecture: in production, Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site.
|
||||
- Frontend uses relative URLs (`/api/quotes`, not `http://localhost:3000/api/quotes`) — the same URLs work in both dev and production without any configuration.
|
||||
- Zero CORS configuration needed in the API or the frontend. Tower-http CORS middleware is not required.
|
||||
- Standard, well-supported pattern for SPA development with a separate API backend.
|
||||
</decision>
|
||||
|
||||
<goal>
|
||||
Add a `[[proxy]]` section to `Trunk.toml` in the `quotesdb/` root:
|
||||
|
||||
```toml
|
||||
[build]
|
||||
target = "index.html"
|
||||
|
||||
[[proxy]]
|
||||
rewrite = "/api"
|
||||
backend = "http://localhost:3000"
|
||||
```
|
||||
|
||||
This configuration means:
|
||||
- Requests from the browser to `http://localhost:8080/api/quotes` are forwarded to `http://localhost:3000/api/quotes`.
|
||||
- The `/api` prefix is preserved in the forwarded URL (Trunk appends the matched path to `backend`).
|
||||
- `trunk serve` handles the proxying automatically — no manual setup required by developers.
|
||||
- The API server port `3000` matches the plain Axum `cargo run` dev server (see ticket 6e829e).
|
||||
</goal>
|
||||
|
||||
<local-dev-workflow>
|
||||
Local development workflow after this change:
|
||||
|
||||
```sh
|
||||
# Terminal 1 — start the API server
|
||||
cd quotesdb
|
||||
cargo run
|
||||
|
||||
# Terminal 2 — start the UI dev server with proxy
|
||||
cd quotesdb
|
||||
trunk serve
|
||||
# Browser opens at http://localhost:8080
|
||||
# API calls go to /api/* (proxied transparently to localhost:3000)
|
||||
```
|
||||
|
||||
No environment variables, no hardcoded URLs, no CORS headers needed.
|
||||
</local-dev-workflow>
|
||||
|
||||
<production-routing>
|
||||
In production (Cloudflare Pages + Workers), the same `/api/*` path prefix is used. Cloudflare
|
||||
can route `example.com/api/*` to the Worker and `example.com/*` to Pages via a Custom Domain
|
||||
or a Worker route rule. This is configured in infra/. The frontend code does not change.
|
||||
</production-routing>
|
||||
|
||||
<constraints>
|
||||
- API port must be `3000` — this must be consistent with however ticket 6e829e configures the local Axum server.
|
||||
- If the API port changes, update `Trunk.toml` accordingly and document the change.
|
||||
- Do not use `trunk.serve.proxy` (legacy format) — use `[[proxy]]` table array format.
|
||||
- This ticket's change is 3 lines in `Trunk.toml`. Keep it minimal.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory (requires `cargo run` running in another terminal):
|
||||
|
||||
```sh
|
||||
trunk serve &
|
||||
curl http://localhost:8080/api/quotes # should proxy to http://localhost:3000/api/quotes
|
||||
```
|
||||
|
||||
At minimum, verify `trunk build` succeeds:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): add Trunk proxy config to forward /api/* to local API server`
|
||||
</commit>
|
||||
@ -1,7 +1,162 @@
|
||||
+++
|
||||
title = "Implement 4-word passphrase auth_code generator (must work in WASM/workers-rs)"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "1f5bb5", "6ed325"]
|
||||
dependencies = ["1f5bb5", "6ed325"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
Auth codes are 4-word passphrases (e.g. `ocean-table-purple-storm`) assigned to quotes on creation. They are stored plaintext and used to authorise updates and deletes.
|
||||
|
||||
**TRIAGE 6ed325 resolved:** Use a custom embedded word list (EFF Short Word List 1) with `rand::rngs::OsRng` from `rand 0.10`. OsRng does not use thread-local storage and is safe on wasm32. Entropy on WASM comes from `getrandom 0.4` with the `wasm_js` feature, which calls `crypto.getRandomValues()` — available in both browsers and Cloudflare Workers.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement a `generate_auth_code() -> String` function in `src/lib.rs` that produces a random 4-word passphrase. Place it in shared lib code so both the API (generation) and UI (display) can reference it.
|
||||
</goal>
|
||||
|
||||
<implementation>
|
||||
|
||||
## 1. Cargo.toml changes (covered by ticket 1f5bb5, listed here for reference)
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
rand = "0.10"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues())
|
||||
# Required by both rand (OsRng) and uuid (v4) on wasm32 targets
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
```
|
||||
|
||||
## 2. Embed the EFF Short Word List 1 in src/lib.rs
|
||||
|
||||
The EFF Short Word List 1 contains 1296 common English words designed for memorable passphrases.
|
||||
Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt
|
||||
|
||||
Generate the Rust const array (run from shell, paste output into src/lib.rs):
|
||||
|
||||
```sh
|
||||
curl -s 'https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt' \
|
||||
| awk '{print $2}' \
|
||||
| awk 'NR % 8 == 1 {printf " "} {printf "\"%s\", ", $0} NR % 8 == 0 {print ""}' \
|
||||
| sed '$a\' | tr -d '\n' | sed 's/, $//'
|
||||
```
|
||||
|
||||
Place the word list as a module-level constant:
|
||||
|
||||
```rust
|
||||
/// EFF Short Word List 1 — 1296 common English words designed for memorable passphrases.
|
||||
/// Source: <https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt>
|
||||
const WORDS: &[&str] = &[
|
||||
"acid", "acorn", "acre", "acts", "afar", "affix", "aged", "agent",
|
||||
"agile", "aging", "agony", "ahead", "aide", "aids", "aim", "ajar",
|
||||
// ... (full 1296-word list, generated via shell command above)
|
||||
];
|
||||
```
|
||||
|
||||
## 3. Implement generate_auth_code() in src/lib.rs
|
||||
|
||||
```rust
|
||||
use rand::rngs::OsRng;
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
/// Generates a random 4-word passphrase in the format `word-word-word-word`.
|
||||
///
|
||||
/// Words are drawn from the EFF Short Word List 1 (1296 common English words).
|
||||
/// The passphrase is used as an `auth_code` to authorize quote edits and deletes.
|
||||
///
|
||||
/// Uses `rand::rngs::OsRng` for entropy, which is safe on both native and
|
||||
/// `wasm32-unknown-unknown` targets. On WASM (Cloudflare Workers), entropy
|
||||
/// is sourced via `crypto.getRandomValues()` through `getrandom`'s `wasm_js` feature.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let code = quotesdb::generate_auth_code();
|
||||
/// let words: Vec<&str> = code.split('-').collect();
|
||||
/// assert_eq!(words.len(), 4);
|
||||
/// assert!(words.iter().all(|w| !w.is_empty()));
|
||||
/// ```
|
||||
pub fn generate_auth_code() -> String {
|
||||
WORDS
|
||||
.choose_multiple(&mut OsRng, 4)
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Unit tests (src/lib.rs tests module or src/tests.rs)
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Verify format: exactly 4 non-empty words from WORDS, joined by hyphens.
|
||||
#[test]
|
||||
fn auth_code_has_four_valid_words() {
|
||||
for _ in 0..100 {
|
||||
let code = generate_auth_code();
|
||||
let parts: Vec<&str> = code.split('-').collect();
|
||||
assert_eq!(parts.len(), 4, "expected 4 words, got: {code}");
|
||||
for word in &parts {
|
||||
assert!(!word.is_empty(), "empty word in code: {code}");
|
||||
assert!(
|
||||
WORDS.contains(word),
|
||||
"word '{word}' not in word list, code: {code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify randomness: 20 samples should produce at least 10 distinct codes.
|
||||
#[test]
|
||||
fn auth_codes_are_varied() {
|
||||
let codes: HashSet<String> = (0..20).map(|_| generate_auth_code()).collect();
|
||||
assert!(
|
||||
codes.len() > 10,
|
||||
"expected >10 unique codes in 20 samples, got {}",
|
||||
codes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</implementation>
|
||||
|
||||
<constraints>
|
||||
- `generate_auth_code()` must live in `src/lib.rs` (shared code, not bin-specific)
|
||||
- Use `rand::rngs::OsRng` — do NOT use `rand::thread_rng()` (thread-local, unsafe on WASM)
|
||||
- Do not use `std::fs`, thread-based RNG, or any crate that requires file-system access
|
||||
- All public items must have rustdoc comments with doc-examples (per project style)
|
||||
- `getrandom = { version = "0.4", features = ["wasm_js"] }` must be in the wasm32 cfg section only,
|
||||
not in `[dependencies]`, to avoid pulling wasm-bindgen into native builds
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write the unit tests (step 4) before implementing (step 3).
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement WASM-compatible 4-word passphrase auth_code generator`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,44 @@
|
||||
+++
|
||||
title = "Implement ui/src/main.rs — Yew app shell, BrowserRouter, route definitions for all 5 pages"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "93515e", "dc3d2b"]
|
||||
dependencies = ["93515e", "dc3d2b", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The five frontend routes are:
|
||||
- `/` — Home (random quote)
|
||||
- `/browse` — Paginated quote list
|
||||
- `/quotes/:id` — Single quote view/edit/delete
|
||||
- `/author/:name` — All quotes by an author
|
||||
- `/submit` — New quote form
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement `src/bin/ui/main.rs` — the Yew app shell and router:
|
||||
1. Set up `BrowserRouter` (from yew-router)
|
||||
2. Define a `Route` enum for all five routes
|
||||
3. Render each route to its respective page component (stubs are fine initially)
|
||||
4. Mount the app to the `#app` div in `index.html`
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Resolve TRIAGE ticket 166996 (Yew/yew-router version) before starting.
|
||||
- The `Route` enum must be exhaustive — all five routes listed above.
|
||||
- Page components can be stubs (`html! { <p>"Home"</p> }`) in this ticket; full implementation is in separate tickets.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement Yew app shell and BrowserRouter with all 5 routes`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,55 @@
|
||||
+++
|
||||
title = "Implement PUT /api/quotes — create quote, generate NanoID, generate auth_code if not provided, return 201 with auth_code"
|
||||
title = "Implement PUT /api/quotes — create quote, generate UUID v4 ID, generate auth_code if not provided, return 201 with auth_code"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d", "d792e2", "03bb91", "175382", "6f2e18"]
|
||||
dependencies = ["a5049d", "d792e2", "03bb91", "175382", "7a0d9f"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
`PUT /api/quotes` creates a new quote. The request body is JSON; `auth_code` is optional — if omitted, one is generated. The response is 201 with the full quote object and the `auth_code` (always returned so the user can save it).
|
||||
|
||||
Request body: `{ text, author, source?, tags?, date?, auth_code? }`
|
||||
Response 201: `{ quote: {...}, auth_code: "word-word-word-word" }`
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `PUT /api/quotes` handler:
|
||||
1. Deserialise and validate the request body (text and author are required)
|
||||
2. Generate a UUID v4 ID for the quote by calling `generate_id()` from `src/lib.rs`
|
||||
3. Generate an auth_code if not provided in the request
|
||||
4. INSERT the quote into the `quotes` table
|
||||
5. INSERT any tags into `quote_tags`
|
||||
6. Return 201 with the created quote and auth_code
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Return 422 if `text` or `author` is missing or empty.
|
||||
- Use `generate_id()` from `src/lib.rs` for the quote ID — returns a UUID v4 string (36 chars). TRIAGE 6f2e18 resolved this: nanoid is not WASM-safe; uuid v4 is used instead. See ticket 7a0d9f.
|
||||
- Use the shared `generate_auth_code()` function from `src/lib.rs`.
|
||||
- Tag insertion must use the shared `replace_tags_for_quote()` logic (ticket 175382).
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write tests for: auto-generated auth_code, custom auth_code, missing required fields 422.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement PUT /api/quotes — create quote with UUID v4 and auth_code`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
+++
|
||||
title = "quotesdb/infra: Cloudflare rate limiting (WAF rules or Workers rate limiting per IP)"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Summary
|
||||
Investigate and implement rate limiting for the quotesdb API and frontend using Cloudflare's native tooling.
|
||||
|
||||
## Options to Evaluate
|
||||
|
||||
### 1. Cloudflare WAF Rate Limiting Rules (Recommended starting point)
|
||||
- Available on free tier with limits; full control on Pro+
|
||||
- Rules can match on IP, path, method
|
||||
- Configure via OpenTofu (cloudflare_ruleset resource, phase: http_ratelimit)
|
||||
- Example: max 10 POST/PUT/DELETE requests per IP per minute to /api/*
|
||||
|
||||
### 2. Cloudflare Workers Rate Limiting API
|
||||
- workers-rs has bindings for the Rate Limiting API (available on paid plans)
|
||||
- More fine-grained: can key on IP + user-defined keys (e.g., quote ID)
|
||||
- Useful for per-resource rate limits (e.g., max N reports per IP per quote)
|
||||
|
||||
### 3. KV-based rate limiting in the Worker
|
||||
- Manual implementation using Cloudflare KV as a counter store
|
||||
- Works on free tier but adds latency and KV cost
|
||||
- Last resort if WAF rules are insufficient
|
||||
|
||||
## Suggested Limits (to start)
|
||||
- POST /api/quotes (create): 5 per IP per 10 minutes
|
||||
- POST /api/quotes/:id/report: 3 per IP per hour
|
||||
- POST /api/quotes/:id (update): 10 per IP per minute
|
||||
- DELETE /api/quotes/:id: 10 per IP per minute
|
||||
- GET endpoints: more generous or no limit (Cloudflare CDN caches anyway)
|
||||
|
||||
## Tasks
|
||||
- [ ] Research which Cloudflare plan features are available for this project
|
||||
- [ ] Implement WAF rate limiting rules in OpenTofu (infra/main.tf or new infra/rate-limits.tf)
|
||||
- [ ] If Workers Rate Limiting API is needed, add workers-rs bindings and implement in api/main.rs
|
||||
- [ ] Document the approach and any plan requirements in docs/ARCHITECTURE.md
|
||||
- [ ] Verify rules are applied with a test script (curl loop)
|
||||
|
||||
## Notes
|
||||
- CAPTCHA on the report endpoint (ticket 354276) provides an additional layer of bot protection
|
||||
- Rate limiting should complement, not replace, CAPTCHA
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
# from infra/
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
@ -1,7 +1,68 @@
|
||||
+++
|
||||
title = "[TRIAGE] D1 binding chicken-and-egg — D1 ID not known until after apply, but Worker needs it at plan time"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu apply`, but the Worker resource needs the D1 ID at plan time. How do we break this circular dependency?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Two-phase apply** — apply D1 resource first, capture the output ID, then apply the Worker with the ID. Requires splitting `tofu apply` into two steps.
|
||||
2. **`data` source lookup** — use a `cloudflare_d1_database` data source to look up an already-existing D1 database by name. Requires D1 to be created manually first or in a prior apply.
|
||||
3. **OpenTofu `depends_on`** — express the dependency explicitly and let OpenTofu plan the two resources in the correct order. May work if the Cloudflare provider handles the reference correctly.
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**Option 3 (attribute reference) — and there is no chicken-and-egg problem.**
|
||||
|
||||
This is a common Terraform/OpenTofu misconception. Writing `database_id = cloudflare_d1_database.db.id` in the Worker resource creates an **implicit dependency** via the attribute reference. OpenTofu:
|
||||
1. Sees that `cloudflare_workers_script.api` depends on `cloudflare_d1_database.db` (via the `.id` reference)
|
||||
2. Plans D1 creation first; shows Worker `database_id` as `(known after apply)` — this is **expected and correct**
|
||||
3. During `tofu apply`: creates D1 first → gets its ID from state → creates Worker with that ID
|
||||
|
||||
No two-phase apply, no `data` source, no explicit `depends_on`. A single `tofu apply` provisions both resources in the correct order.
|
||||
|
||||
Confirmed from Cloudflare provider v4 source:
|
||||
- D1 resource: `cloudflare_d1_database` — outputs `id` (String)
|
||||
- Worker resource: `cloudflare_workers_script` (plural) — `d1_database_binding` block with `database_id` and `name` fields
|
||||
- This also confirms the answer to TRIAGE efee79: resource name is `cloudflare_workers_script`
|
||||
|
||||
Concrete HCL:
|
||||
```hcl
|
||||
resource "cloudflare_d1_database" "db" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "quotesdb"
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_script" "api" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "quotesdb-api"
|
||||
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
|
||||
|
||||
d1_database_binding {
|
||||
name = "DB"
|
||||
database_id = cloudflare_d1_database.db.id # (known after apply) — resolved automatically
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
API Worker CI/CD deploy ticket: 57fe5e
|
||||
</decision>
|
||||
|
||||
<resolution>
|
||||
1. Research the options above and choose the best approach for this project.
|
||||
2. Update the `infra/worker.tf` and `infra/d1.tf` resources with the chosen approach. Update ticket a23489 and d0da0b with any constraints.
|
||||
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — d1-binding-standard-attribute-reference-no-chicken-and-egg`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,41 @@
|
||||
+++
|
||||
title = "[TRIAGE] OpenTofu state backend — local file (gitignored) vs Terraform Cloud vs Cloudflare R2?"
|
||||
priority = 9
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
OpenTofu state backend: should the `.tfstate` file be stored locally (gitignored), in Terraform Cloud (free tier), or in Cloudflare R2 (S3-compatible backend)?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Local file** — simplest, but state is lost if the machine changes and cannot be shared. Suitable for solo development.
|
||||
2. **Terraform Cloud** — free tier available, remote state with locking. Requires a Terraform Cloud account.
|
||||
3. **Cloudflare R2** — S3-compatible, keeps state within Cloudflare ecosystem. Requires an R2 bucket and API credentials.
|
||||
</options>
|
||||
|
||||
<resolution>
|
||||
**Decision: Local file backend (gitignored).**
|
||||
|
||||
Rationale:
|
||||
- `quotesdb` is a solo developer project — no team, no CI/CD pipeline that needs shared state.
|
||||
- Infrastructure is small (1 Worker + 1 D1 + 1 Pages project). If state is lost, all resources can be recovered via `tofu import`.
|
||||
- Terraform Cloud: unnecessary HashiCorp account dependency with no benefit for solo use.
|
||||
- Cloudflare R2: chicken-and-egg problem — the R2 bucket itself must be manually bootstrapped before it can serve as the OpenTofu backend, adding setup complexity and extra credential scope.
|
||||
- Local file: zero extra accounts, zero extra credentials, immediate to set up.
|
||||
|
||||
Implementation (see ticket 2d1371):
|
||||
- `infra/terraform.tf`: use default local backend (no `backend` block needed — local is the OpenTofu default).
|
||||
- `infra/.gitignore`: ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`, `.terraform.lock.hcl`.
|
||||
- `infra/README.md`: document that state is local and how to recover with `tofu import` if lost.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — opentofu-state-backend-local-file-gitignored-vs-terraform-cl`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,42 @@
|
||||
+++
|
||||
title = "Write api/README.md, api/docs/PLANNING.md, api/docs/ARCHITECTURE.md"
|
||||
priority = 3
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a6bce1"]
|
||||
dependencies = ["a6bce1"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the three documentation files for the API domain:
|
||||
1. `README.md` — what the API does, how to run it (`cargo run`), how to test it, license, Claude Code disclaimer
|
||||
2. `docs/PLANNING.md` — development phases and work log for the API sub-domain
|
||||
3. `docs/ARCHITECTURE.md` — API component overview: router, handlers, database layer, auth, OpenAPI spec
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- README must include the dual Apache-2.0 + MIT license notice.
|
||||
- README must include a disclaimer that the software was written with Claude Code (model: claude-sonnet-4-6).
|
||||
- ARCHITECTURE.md must describe how the API binary wires together (router → handlers → db layer).
|
||||
- PLANNING.md must reflect the actual work done (link to ticket IDs where appropriate).
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`docs(quotesdb): write api README, PLANNING.md, and ARCHITECTURE.md`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,55 @@
|
||||
+++
|
||||
title = "[TRIAGE] Auth code storage strategy — localStorage persistence vs component-only state?"
|
||||
priority = 7
|
||||
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>
|
||||
|
||||
<question>
|
||||
Auth code storage strategy for the UI: should the auth code be stored in localStorage (persisted across sessions) or kept only in component state (lost on page reload)?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Component state only** — auth code is lost on page reload. User must re-enter it each time. Simpler and more secure.
|
||||
2. **localStorage per quote ID** — store `auth_code_{id}` in localStorage so the user doesn't need to re-enter it for quotes they created. Risk: plaintext in localStorage.
|
||||
3. **Session storage** — same as localStorage but cleared when the tab closes. Middle ground.
|
||||
</options>
|
||||
|
||||
<resolution>
|
||||
**Chosen approach: Option 3 — session storage per quote ID.**
|
||||
|
||||
The auth code is stored in `sessionStorage` under the key `auth_code_{quote_id}`. It is
|
||||
automatically cleared when the browser tab closes. No manual clear-on-delete is strictly
|
||||
required, but is implemented as good practice (after a successful DELETE, the code is no
|
||||
longer useful and should not linger).
|
||||
|
||||
Why not localStorage (option 2): the app explicitly tells users to store their auth code
|
||||
externally ("Store this — it cannot be recovered later"). localStorage is indefinite and
|
||||
has a wider XSS exposure window; session storage provides the same in-session convenience
|
||||
without the long-term risk.
|
||||
|
||||
Why not component state (option 1): the code would be lost on every page navigation or
|
||||
reload, making the edit/delete flow frustrating in practice.
|
||||
|
||||
Session storage covers the primary use case — "I just created this quote and want to edit
|
||||
it" — without adding unnecessary persistence complexity.
|
||||
|
||||
Implementation ticket: **5379eb** — creates `src/bin/ui/storage.rs` with `get_auth_code`,
|
||||
`set_auth_code`, `clear_auth_code` utilities wrapping `web_sys::window().session_storage()`,
|
||||
plus the `initial_value: Option<String>` prop addition to `AuthModal` and the parent-component
|
||||
integration pattern (read on modal open, write on success, clear on 403 or DELETE).
|
||||
|
||||
Tickets updated:
|
||||
- **f850c6** (AuthModal): triage dependency replaced with 5379eb; goal updated with
|
||||
`initial_value` prop; constraints updated with resolved storage approach.
|
||||
- **c3503b** (UI sub-project): 0bc655 removed, 5379eb added.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — auth-code-storage-strategy-localstorage-persistence-vs-compo`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
+++
|
||||
title = "quotesdb/api: POST /api/admin/lock and /api/admin/unlock endpoints"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["69a2c5"]
|
||||
+++
|
||||
## POST /api/admin/lock and /api/admin/unlock endpoints
|
||||
|
||||
Add the two admin-protected endpoints that toggle the global submissions lock. Both require `X-Admin-Code` and return the current lock state after the operation.
|
||||
|
||||
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait and seeds the `submissions_locked` row in the database. Complete 35685a first.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/bin/api/handlers/mod.rs` (or `src/bin/api/handlers/admin.rs`) — add `lock_submissions` and `unlock_submissions` handlers
|
||||
- `src/bin/api/main.rs` — register the two new routes
|
||||
|
||||
No new DB trait methods are needed; both handlers reuse `set_submissions_locked(bool)` introduced in 35685a.
|
||||
|
||||
---
|
||||
|
||||
## Handlers
|
||||
|
||||
```rust
|
||||
/// POST /api/admin/lock
|
||||
/// Requires X-Admin-Code header. Sets submissions_locked = true.
|
||||
/// Response: 200 { "submissions_locked": true } or 403 on bad code.
|
||||
pub async fn lock_submissions(
|
||||
State(repo): State<Arc<dyn QuoteRepository>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let admin_code = extract_admin_code(&headers);
|
||||
if !verify_admin_code(&repo, admin_code).await { ... }
|
||||
match repo.set_submissions_locked(true).await {
|
||||
Ok(()) => Json(json!({ "submissions_locked": true })).into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/unlock
|
||||
/// Requires X-Admin-Code header. Sets submissions_locked = false.
|
||||
/// Response: 200 { "submissions_locked": false } or 403 on bad code.
|
||||
pub async fn unlock_submissions(
|
||||
State(repo): State<Arc<dyn QuoteRepository>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// same pattern, locked = false
|
||||
}
|
||||
```
|
||||
|
||||
Implement a shared helper `verify_admin_code(repo, code) -> bool` (or extract inline) that fetches the stored admin code from `admin_config` and compares it. Use constant-time comparison if possible.
|
||||
|
||||
---
|
||||
|
||||
## Route registration (src/bin/api/main.rs)
|
||||
|
||||
```rust
|
||||
.route("/api/admin/lock", post(handlers::lock_submissions))
|
||||
.route("/api/admin/unlock", post(handlers::unlock_submissions))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- `POST /api/admin/lock` with correct `X-Admin-Code` → `200 { "submissions_locked": true }`
|
||||
- `POST /api/admin/unlock` with correct `X-Admin-Code` → `200 { "submissions_locked": false }`
|
||||
- `POST /api/admin/lock` with wrong code → `403`
|
||||
- `POST /api/admin/unlock` with missing header → `403`
|
||||
- Lock/unlock idempotent: locking when already locked still returns `200 { "submissions_locked": true }`
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints
|
||||
```
|
||||
@ -1,7 +1,44 @@
|
||||
+++
|
||||
title = "[TRIAGE] HTTP client selection for integration tests — reqwest vs hyper vs ureq (tokio vs blocking)"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
HTTP client for integration tests: should we use reqwest (async, tokio), hyper (low-level), or ureq (synchronous/blocking)?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **reqwest** — most ergonomic, async, works well with tokio::test. Adds a heavier dependency but is widely used.
|
||||
2. **hyper** — low-level, minimal dependencies. More verbose.
|
||||
3. **ureq** — synchronous, no async runtime needed. Simple but requires spawning a background thread to run the server.
|
||||
</options>
|
||||
|
||||
<resolution>
|
||||
**Chosen approach: Option 1 — `reqwest` with `#[tokio::test]`.**
|
||||
|
||||
The API server is Axum + Tokio. `reqwest` is the idiomatic async HTTP client in this stack:
|
||||
- `#[tokio::test]` + `reqwest` is the standard Rust integration-testing pattern for Axum services.
|
||||
- `features = ["json"]` enables ergonomic `.json()` request bodies and `.json::<T>()` response
|
||||
deserialization — essential for testing JSON API endpoints.
|
||||
- Dev-dependency only: the weight of the crate does not affect the production binary size.
|
||||
|
||||
Options 2 (hyper) and 3 (ureq) were ruled out:
|
||||
- hyper 1.x has a complex, low-level API that adds boilerplate with no test-writing benefit.
|
||||
- ureq is synchronous; using it with an async Axum server would require spawning a background
|
||||
thread for the server in every test, adding avoidable setup complexity.
|
||||
|
||||
Implementation ticket **5f5ba0** (already exists and is correctly specified) captures all
|
||||
necessary work: adds `reqwest = { version = "0.12", features = ["json"] }`, `tokio`, `serde_json`,
|
||||
and `tempfile` to `[dev-dependencies]` in `Cargo.toml`. No new ticket is required.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — http-client-selection-for-integration-tests-reqwest-vs-hyper`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,40 @@
|
||||
+++
|
||||
title = "Implement shared QuoteCard component — displays text, author, source, date, tags"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "93515e"]
|
||||
dependencies = ["93515e", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement a shared `QuoteCard` Yew component (`src/bin/ui/components/quote_card.rs`) that displays:
|
||||
- Quote text (styled as a blockquote)
|
||||
- Author name (linked to `/author/:name`)
|
||||
- Optional source and date
|
||||
- Tags as clickable chips (linking to `/browse?tag=X`)
|
||||
|
||||
This component is reused on the Home, Browse, Author, and Quote Detail pages.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Resolve TRIAGE ticket 5e3e37 (CSS/styling approach) before adding class names.
|
||||
- Accept a `Quote` struct as a prop (from shared types in `src/lib.rs`).
|
||||
- Author link must navigate to `/author/:name` using yew-router's `Link` component.
|
||||
- Tags are optional — render nothing if the quote has no tags.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement shared QuoteCard component`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
+++
|
||||
title = "Write src/bin/ui/style.css — full stylesheet for all UI pages and components"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["dc3d2b"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
CSS approach resolved in triage 5e3e37: **plain CSS** — a single `src/bin/ui/style.css` file linked from `index.html` via `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`.
|
||||
|
||||
All UI component tickets use BEM-style class names defined in this file. No CSS-in-Rust crates, no CDN Tailwind.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write `src/bin/ui/style.css` covering all pages and components in the Yew UI.
|
||||
</goal>
|
||||
|
||||
<naming-convention>
|
||||
BEM-style semantic class names. Blocks and elements use lowercase hyphenated names.
|
||||
|
||||
| Component / Page | Block class | Notable element classes |
|
||||
|---|---|---|
|
||||
| Global | `body`, `main`, `nav` | — |
|
||||
| Navigation | `nav` | `nav__link`, `nav__brand` |
|
||||
| QuoteCard | `quote-card` | `quote-card__text`, `quote-card__author`, `quote-card__meta`, `quote-card__tags`, `quote-card__tag` |
|
||||
| Home page | `page-home` | `page-home__random`, `page-home__cta` |
|
||||
| Browse page | `page-browse` | `page-browse__filters`, `page-browse__list` |
|
||||
| Quote detail page | `page-quote` | `page-quote__actions` |
|
||||
| Author page | `page-author` | `page-author__header` |
|
||||
| Submit page | `page-submit` | `page-submit__form`, `page-submit__success` |
|
||||
| Pagination | `pagination` | `pagination__btn`, `pagination__info` |
|
||||
| Tag filter | `tag-filter` | `tag-filter__input`, `tag-filter__list`, `tag-filter__tag` |
|
||||
| Auth modal | `auth-modal` | `auth-modal__overlay`, `auth-modal__dialog`, `auth-modal__input`, `auth-modal__actions` |
|
||||
| Error display | `error-display` | `error-display__message` |
|
||||
| Form elements | `form` | `form__field`, `form__label`, `form__input`, `form__textarea`, `form__error` |
|
||||
| Buttons | `btn` | `btn--primary`, `btn--secondary`, `btn--danger` |
|
||||
| Auth code reveal | `auth-reveal` | `auth-reveal__code`, `auth-reveal__note` |
|
||||
| Loading | `loading` | — |
|
||||
| Empty state | `empty-state` | `empty-state__message` |
|
||||
|
||||
</naming-convention>
|
||||
|
||||
<design-notes>
|
||||
- Clean, minimal typography-focused design appropriate for a quotes site.
|
||||
- Readable body font (system-ui or serif stack for quote text).
|
||||
- Max-width container centered on page: ~720px for readability.
|
||||
- Accessible colour contrast (WCAG AA minimum).
|
||||
- Responsive: readable on mobile without horizontal scroll.
|
||||
- No external font imports — use system fonts.
|
||||
- Light theme only (no dark mode required).
|
||||
</design-notes>
|
||||
|
||||
<yew-usage>
|
||||
In Yew components, use class names as string literals:
|
||||
|
||||
```rust
|
||||
html! {
|
||||
<div class="quote-card">
|
||||
<blockquote class="quote-card__text">{ "e.text }</blockquote>
|
||||
<cite class="quote-card__author">{ "e.author }</cite>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
For conditional classes use the `classes!` macro:
|
||||
|
||||
```rust
|
||||
html! {
|
||||
<button class={classes!("btn", "btn--primary", disabled.then_some("btn--disabled"))}>
|
||||
{ "Submit" }
|
||||
</button>
|
||||
}
|
||||
```
|
||||
</yew-usage>
|
||||
|
||||
<validation>
|
||||
After writing the CSS, verify it is picked up by Trunk:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
|
||||
Inspect the generated `dist/` directory to confirm the CSS file is bundled.
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`style(quotesdb): add UI stylesheet with BEM component classes`
|
||||
</commit>
|
||||
@ -0,0 +1,100 @@
|
||||
+++
|
||||
title = "quotesdb/ui: /submit page locked-state banner"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["161f32"]
|
||||
+++
|
||||
## /submit page locked-state banner (UI)
|
||||
|
||||
Modify the existing /submit page to check the submission lock on mount. When the lock is active, hide the submission form entirely and show a closed-submissions banner in its place.
|
||||
|
||||
Depends on ticket 161f32 (admin API client functions). Complete that ticket first.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/bin/ui/pages/submit.rs` — add status check on mount and conditional rendering
|
||||
|
||||
---
|
||||
|
||||
## Changes to SubmitPage component
|
||||
|
||||
### New state fields
|
||||
|
||||
Add to the existing component state:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `submissions_locked` | `Option<bool>` | `None` while loading, `Some(true/false)` after status check |
|
||||
| `status_error` | `bool` | Set if `get_status()` itself fails (show form as fallback) |
|
||||
|
||||
### On mount
|
||||
|
||||
Spawn an async task that calls `api::get_status()`:
|
||||
|
||||
```rust
|
||||
// In use_effect_with or similar hook, fired once on mount:
|
||||
let submissions_locked = submissions_locked.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::get_status().await {
|
||||
Ok(status) => submissions_locked.set(Some(status.submissions_locked)),
|
||||
Err(_) => {
|
||||
// On error, default to showing the form (fail open).
|
||||
submissions_locked.set(Some(false));
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Render logic
|
||||
|
||||
```
|
||||
if submissions_locked == None:
|
||||
show a loading indicator (or nothing / skeleton)
|
||||
elif submissions_locked == Some(true):
|
||||
show closed banner, hide form
|
||||
else:
|
||||
show form as normal
|
||||
```
|
||||
|
||||
### Closed banner markup (approximate)
|
||||
|
||||
```html
|
||||
<div class="submissions-closed-banner">
|
||||
<p>Submissions are currently closed.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Style the banner to be visually distinct — use a muted/warning colour. Add the `.submissions-closed-banner` CSS class to `src/bin/ui/style.css`.
|
||||
|
||||
### Fail-open behaviour
|
||||
|
||||
If `api::get_status()` returns an error, treat it as unlocked (`Some(false)`) and display the form. Do not block a user from submitting due to a network error on the status check.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
No runtime unit tests (wasm-only). Verify the build:
|
||||
|
||||
```sh
|
||||
cargo check --target wasm32-unknown-unknown --bin ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat(quotesdb): show locked banner on /submit when submissions are closed
|
||||
```
|
||||
@ -0,0 +1,122 @@
|
||||
+++
|
||||
title = "quotesdb/ui: admin API client functions"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Admin API client functions (UI)
|
||||
|
||||
Add four async functions to the UI API client module that cover every admin and status endpoint introduced by the API tickets. These functions are consumed by the /admin page and the /submit page.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/bin/ui/api.rs` — add four new public async functions
|
||||
|
||||
---
|
||||
|
||||
## New types
|
||||
|
||||
Add to `src/bin/ui/api.rs` (or to a shared types module imported by api.rs):
|
||||
|
||||
```rust
|
||||
/// Response from GET /api/status.
|
||||
#[derive(Deserialize, Clone, PartialEq)]
|
||||
pub struct StatusResponse {
|
||||
pub submissions_locked: bool,
|
||||
}
|
||||
|
||||
/// Response from POST /api/admin/reset-auth-code.
|
||||
#[derive(Deserialize)]
|
||||
struct ResetAuthCodeResponse {
|
||||
pub auth_code: String,
|
||||
}
|
||||
|
||||
/// Response from POST /api/admin/lock or /api/admin/unlock.
|
||||
#[derive(Deserialize)]
|
||||
struct LockResponse {
|
||||
pub submissions_locked: bool,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New functions
|
||||
|
||||
```rust
|
||||
/// Fetch the current submission lock state from GET /api/status.
|
||||
/// Returns Ok(StatusResponse) on success or ApiError on failure.
|
||||
pub async fn get_status() -> Result<StatusResponse, ApiError> { ... }
|
||||
|
||||
/// Call POST /api/admin/reset-auth-code.
|
||||
/// Sends X-Admin-Code: admin_code in the request header.
|
||||
/// Body: { "new_code": new_code } (omit field if new_code is None).
|
||||
/// Returns the new auth code string on success, or ApiError on failure.
|
||||
pub async fn admin_reset_auth_code(
|
||||
current: &str,
|
||||
new_code: Option<&str>,
|
||||
admin_code: &str,
|
||||
) -> Result<String, ApiError> { ... }
|
||||
|
||||
/// Call POST /api/admin/lock.
|
||||
/// Sends X-Admin-Code: admin_code in the request header.
|
||||
/// Returns Ok(true) on success, or ApiError (including ApiError::Forbidden on 403).
|
||||
pub async fn admin_lock(admin_code: &str) -> Result<bool, ApiError> { ... }
|
||||
|
||||
/// Call POST /api/admin/unlock.
|
||||
/// Sends X-Admin-Code: admin_code in the request header.
|
||||
/// Returns Ok(false) on success, or ApiError (including ApiError::Forbidden on 403).
|
||||
pub async fn admin_unlock(admin_code: &str) -> Result<bool, ApiError> { ... }
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- Use the same `gloo_net::http::Request` pattern already used in `api.rs` for other endpoints.
|
||||
- Add an `ApiError::Forbidden` variant (or reuse an existing error variant) to communicate `403` responses back to the UI so pages can show "Wrong auth code." without a generic error.
|
||||
- `admin_reset_auth_code`: serialize the body as `{ "new_code": "..." }` when `new_code` is `Some`, or as `{}` when `None`.
|
||||
- `admin_lock` and `admin_unlock` send no request body (empty POST).
|
||||
|
||||
---
|
||||
|
||||
## ApiError extension
|
||||
|
||||
If `ApiError` does not already have a `Forbidden` variant, add one:
|
||||
|
||||
```rust
|
||||
pub enum ApiError {
|
||||
// ... existing variants ...
|
||||
/// The server returned 403 Forbidden (wrong admin code).
|
||||
Forbidden,
|
||||
}
|
||||
```
|
||||
|
||||
Map HTTP 403 → `ApiError::Forbidden` in each new function before returning.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
This module compiles only for `wasm32-unknown-unknown` so no `cargo test` unit tests are practical here. Instead, verify the build compiles cleanly:
|
||||
|
||||
```sh
|
||||
cargo check --target wasm32-unknown-unknown --bin ui
|
||||
```
|
||||
|
||||
Write a brief doc-comment on each function describing its endpoint, required header, and error conditions.
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat(quotesdb): admin API client functions in UI
|
||||
```
|
||||
@ -1,7 +1,32 @@
|
||||
+++
|
||||
title = "[TRIAGE] Yew version selection and yew-router compatibility (0.21+?)"
|
||||
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>
|
||||
|
||||
<question>
|
||||
Yew version selection: which version of Yew and yew-router should be used, and are they compatible with each other and the Nix dev shell?
|
||||
</question>
|
||||
|
||||
<resolution>
|
||||
**Decision: yew = "0.22", yew-router = "0.19"**
|
||||
|
||||
Research findings (2026-03-02):
|
||||
- yew latest stable: 0.22.1
|
||||
- yew-router latest stable: 0.19.0
|
||||
- yew-router 0.19 requires `yew ^0.22.0` — confirmed compatible
|
||||
- Both crates use `wasm-bindgen ^0.2`, compatible with `wasm-bindgen-cli 0.2.108` in the Nix dev shell
|
||||
|
||||
Actions taken:
|
||||
- Updated ticket 93515e with explicit version constraints and serde placement guidance
|
||||
- Documented chosen versions in `docs/ARCHITECTURE.md` under "Key Dependency Versions"
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — yew-version-selection-and-yewrouter-compatibility-021`
|
||||
</commit>
|
||||
@ -1,7 +1,49 @@
|
||||
+++
|
||||
title = "Implement tag join logic — fetch tags per quote, insert/replace tags on create/update"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d"]
|
||||
dependencies = ["a5049d"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
Each quote can have multiple tags stored in the `quote_tags` join table. Tags are not normalised — they are stored as plain strings per quote. On create/update, all tags for the quote are replaced atomically.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement tag fetch and upsert logic used by the API handlers:
|
||||
1. `fetch_tags_for_quote(pool, quote_id) -> Vec<String>` — SELECT from quote_tags
|
||||
2. `replace_tags_for_quote(pool, quote_id, tags: &[String])` — DELETE existing, INSERT new tags in a transaction
|
||||
|
||||
This logic should live in a `db` or `tags` module and be called from the create and update handlers.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Tag replacement must be atomic (use a transaction).
|
||||
- Empty `tags` array means "remove all tags" — this is valid.
|
||||
- Cascade delete on `quote_tags` handles tag cleanup when a quote is deleted — no separate delete-tags step needed.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write unit tests that verify tag insertion, replacement, and empty-tag cases.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement tag join logic — fetch and replace tags per quote`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,45 @@
|
||||
+++
|
||||
title = "[TRIAGE] Cloudflare Workers WASM size limit — free tier 1MB limit may require paid plan for Rust binary"
|
||||
priority = 9
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
Cloudflare Workers WASM size limit: the free tier has a 1MB Worker script size limit. A Rust binary compiled for workers-rs may exceed this. Does this project require a paid Workers plan?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Paid Workers plan** — removes the 1MB limit ($5/month). Simplest solution.
|
||||
2. **Optimise binary size** — use `opt-level = "z"`, `lto = true`, `strip = true`, `wasm-opt`, and minimise dependencies. May bring the binary under 1MB.
|
||||
3. **Split the Worker** — serve static assets from Pages and keep the Worker API-only (fewer dependencies).
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**The 1 MB limit in this ticket is outdated.** The current Cloudflare Workers limits (as of 2026) are:
|
||||
- Workers Free: **3 MB after gzip compression**, 64 MB before compression
|
||||
- Workers Paid: **10 MB after gzip**, 64 MB before compression
|
||||
|
||||
**Chosen approach: Free tier + binary size optimisation (no paid plan required).**
|
||||
|
||||
Rationale:
|
||||
- The API Worker only handles API routes — no Yew/UI code is bundled into it (UI is on Pages).
|
||||
- The database layer uses `workers-rs` native D1 bindings (not SQLx) per ticket e8a330 — this eliminates a heavy dependency.
|
||||
- The `Cargo.toml` release profile already has `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`.
|
||||
- `wrangler` applies `wasm-opt` automatically during deploy, further reducing WASM size.
|
||||
- A simple CRUD API with these optimisations gzips well under 3 MB.
|
||||
- Verification command: `wrangler deploy --outdir bundled/ --dry-run` (shows `gzip: X KiB`).
|
||||
|
||||
**Implementation ticket created: see ticket for binary size verification after Cargo.toml dependencies are added (1f5bb5).**
|
||||
|
||||
If the binary somehow exceeds 3 MB, fallback options are: further dependency pruning, or Workers Paid at $5/month.
|
||||
</decision>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — cloudflare-workers-wasm-size-limit-free-tier-1mb-limit-may-r`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,39 @@
|
||||
+++
|
||||
title = "Implement Home page (/) — fetch and display random quote, 'Browse all' link"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "fc2f51"]
|
||||
dependencies = ["04f865", "1e6a09", "0d987f", "fc2f51", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The Home page is the landing page of the app. It displays a random quote fetched from `GET /api/quotes/random` and a "Browse all" link to `/browse`.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the Home page component (`src/bin/ui/pages/home.rs`):
|
||||
1. On mount, fetch a random quote from the API via the API client module (ticket 1e6a09)
|
||||
2. While loading, show a loading indicator
|
||||
3. On success, render the `QuoteCard` component (ticket 0d987f)
|
||||
4. On error, render the `ErrorDisplay` component (ticket fc2f51)
|
||||
5. Render a "Browse all quotes →" link to `/browse`
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use `use_effect_with` (Yew 0.21+) or the equivalent hook to trigger the fetch on mount.
|
||||
- The random quote endpoint returns 404 if the database is empty — display a friendly "no quotes yet" message in this case.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement Home page — random quote display`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,39 @@
|
||||
+++
|
||||
title = "Implement Submit page (/submit) — quote creation form, display returned auth_code on success"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "04f865", "1e6a09", "fc2f51"]
|
||||
dependencies = ["04f865", "1e6a09", "fc2f51", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The Submit page (`/submit`) provides a form for creating a new quote. On success, it displays the returned auth code prominently so the user can save it.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the Submit page component (`src/bin/ui/pages/submit.rs`):
|
||||
1. Render a form with fields: text (textarea), author, source (optional), date (optional), tags (comma-separated input), auth code (optional)
|
||||
2. On submit, call `PUT /api/quotes` via the API client
|
||||
3. On 201 success: show a success message and display the returned auth code in a copyable box
|
||||
4. On error: render `ErrorDisplay` with the error message
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- The auth code returned must be displayed clearly — it cannot be recovered after the user leaves this page.
|
||||
- Validate client-side: text and author are required (non-empty) before submitting.
|
||||
- Parse the tags input by splitting on commas and trimming whitespace.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement Submit page — new quote form with auth code display`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,54 @@
|
||||
+++
|
||||
title = "Implement API client module — typed fetch wrappers for all quotesdb-api endpoints"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "93515e"]
|
||||
dependencies = ["93515e"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The API client module provides typed fetch wrappers around all quotesdb-api endpoints. The UI calls these functions from page components rather than making raw fetch calls directly.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement `src/bin/ui/api.rs` (or `src/bin/ui/client.rs`) with async functions for each endpoint:
|
||||
- `list_quotes(page, author, tag) -> Result<ListResponse>`
|
||||
- `get_quote(id) -> Result<Quote>`
|
||||
- `get_random_quote() -> Result<Quote>`
|
||||
- `create_quote(body) -> Result<CreateResponse>`
|
||||
- `update_quote(id, auth_code, body) -> Result<Quote>`
|
||||
- `delete_quote(id, auth_code) -> Result<()>`
|
||||
|
||||
Each function sets the appropriate headers (including `X-Auth-Code` where needed) and deserialises the response.
|
||||
</goal>
|
||||
|
||||
<base-url-approach>
|
||||
CORS/proxy resolved in triage a9534d: **Trunk proxy, relative URLs**.
|
||||
|
||||
- Use **relative URLs** (`/api/quotes`, `/api/quotes/random`, etc.) — no base URL configuration needed.
|
||||
- In local dev, Trunk's `[[proxy]]` in `Trunk.toml` forwards `/api/*` to `localhost:3000` transparently.
|
||||
- In production, Cloudflare routes `/api/*` to the Worker at the same domain.
|
||||
- Do NOT use `window.location.origin` as a base URL — relative paths work everywhere.
|
||||
- Do NOT add any CORS headers in the frontend — no cross-origin requests occur.
|
||||
</base-url-approach>
|
||||
|
||||
<constraints>
|
||||
- Use `gloo::net::http` for HTTP requests (not reqwest — not available in WASM).
|
||||
- All API paths are relative: `/api/quotes`, `/api/quotes/{id}`, `/api/quotes/random`.
|
||||
- All functions must be `async` and return `Result` with a meaningful error type.
|
||||
- Do NOT configure a base URL — relative URLs are sufficient and correct.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement typed API client module for all quotesdb-api endpoints`
|
||||
</commit>
|
||||
@ -1,7 +1,53 @@
|
||||
+++
|
||||
title = "Set up api/Cargo.toml with all crate dependencies (axum, tokio, workers-rs, sqlx, serde, nanoid, etc.)"
|
||||
title = "Set up Cargo.toml with all crate dependencies (axum, tokio, workers-rs, rusqlite, serde, uuid, etc.)"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a91260"]
|
||||
dependencies = ["7a0d9f"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Create `Cargo.toml` for the `quotesdb` crate with all API-side dependencies. Include `[[bin]]` entries for both `api` and `ui` binaries, platform-specific dependency sections (`cfg(target_arch = "wasm32")`), dev-dependencies for tests, and the release profile with size optimizations.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- `workers-rs` (`worker` crate) is WASM/Workers-only — gate under `[target.'cfg(target_arch = "wasm32")'.dependencies]`
|
||||
- `tokio`, `axum`, `rusqlite`, `tokio-rusqlite` are native-only — gate under `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`
|
||||
- Yew, wasm-bindgen, and web-sys are UI-only — gate under `[target.'cfg(target_arch = "wasm32")'.dependencies]`
|
||||
(The single-crate structure means API-WASM and UI-WASM deps share the same cfg section — use
|
||||
separate feature flags or bin-specific cfg if they conflict)
|
||||
- Do NOT include `sqlx` — it is incompatible with the Workers target (TRIAGE e8a330 resolved)
|
||||
- The `[profile.release]` block must set `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`
|
||||
- **ID generation (TRIAGE 6f2e18 resolved):** Use `uuid = { version = "1", features = ["v4", "serde"] }` in `[dependencies]`. See ticket 7a0d9f.
|
||||
- **Passphrase generation (TRIAGE 6ed325 resolved):** Use `rand = "0.10"` in `[dependencies]`. Use `rand::rngs::OsRng` (not `thread_rng`). See ticket 03bb91.
|
||||
- **WASM entropy (both ID + passphrase):** Add `getrandom = { version = "0.4", features = ["wasm_js"] }` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. This is required by both `uuid` (v4 feature) and `rand` (OsRng) on wasm32. The `wasm_js` feature (renamed from `js` in getrandom 0.2) enables `crypto.getRandomValues()` for Cloudflare Workers and browsers. Do NOT use getrandom 0.2 or the old `js` feature name.
|
||||
- See ticket 00aff0 for the full list of DB-related dependencies (rusqlite, tokio-rusqlite, async-trait)
|
||||
- **OpenAPI spec (TRIAGE 2ec8b1 resolved):** Add a `[build-dependencies]` section with
|
||||
`serde_json = "1"` and `serde_yaml = "0.9"`. These are used by `build.rs` (ticket 8892d5)
|
||||
to convert `api/openapi.yaml` to JSON at compile time. They must NOT appear in `[dependencies]`.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` after adding dependencies — run `cargo check` to confirm the dependency tree resolves.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): set up Cargo.toml with api and ui dependencies`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,67 @@
|
||||
+++
|
||||
title = "Implement GET /api/ — serve OpenAPI spec as JSON"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "1f5bb5", "2ec8b1"]
|
||||
dependencies = ["1f5bb5", "8892d5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
The `GET /api/` endpoint serves the OpenAPI 3.1.0 specification as JSON. This endpoint requires no authentication and is the entry point for API documentation and client generation.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `GET /api/` handler that returns the OpenAPI spec as `application/json`.
|
||||
|
||||
Strategy resolved in TRIAGE 2ec8b1: **compile-time embed via `build.rs`** (ticket 8892d5).
|
||||
The `build.rs` converts `api/openapi.yaml` to JSON at build time and writes it to
|
||||
`$OUT_DIR/openapi.json`. The handler serves this as a static `&str`:
|
||||
|
||||
```rust
|
||||
// Embedded at compile time by build.rs — no runtime parsing, no serde_yaml in binary.
|
||||
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
|
||||
|
||||
pub async fn get_openapi_spec() -> impl IntoResponse {
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
OPENAPI_JSON,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Register the route in the Axum router as `GET /api/`.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- The response `Content-Type` must be `application/json`.
|
||||
- Do NOT use `serde_yaml` in this handler — the YAML→JSON conversion is done by `build.rs`
|
||||
(ticket 8892d5). The handler only serves a pre-built static string.
|
||||
- Do NOT use `OnceLock` or lazy parsing — `OPENAPI_JSON` is a `const &str` embedded at
|
||||
compile time; no initialisation is needed.
|
||||
- The spec at `api/openapi.yaml` is the source of truth — validate with
|
||||
`redocly lint api/openapi.yaml` after any changes.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write a test that hits `GET /api/` and asserts the response is valid JSON with an `openapi` key.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement GET /api/ to serve OpenAPI spec as JSON`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,46 @@
|
||||
+++
|
||||
title = "[TRIAGE] Test harness: how to import and start quotesdb-api in tests (workers-rs vs native build target)"
|
||||
priority = 9
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
Test harness: how do we import and start the quotesdb-api binary in integration tests when it uses workers-rs, which targets the Cloudflare Workers runtime rather than a native Rust binary?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Native feature flag** — add a `#[cfg(not(target_env = "worker"))]` branch in `main.rs` that exposes a plain Axum server. Integration tests use this branch (compiled for host target).
|
||||
2. **Separate test binary** — create a `src/bin/api_test.rs` that is a native Axum server without workers-rs, used only in tests.
|
||||
3. **Wrangler dev** — run `wrangler dev` in the background and point tests at it. Complex setup, slower CI.
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**Option 1 variant: `cfg(target_arch = "wasm32")` split — no feature flag, no separate binary.**
|
||||
|
||||
The `cfg(target_arch)` approach in Cargo.toml means that when `cargo test` runs on the native
|
||||
host, the workers-rs crate is never pulled in (it is a `[target.'cfg(target_arch = "wasm32")'.dependencies]`
|
||||
entry). The native Axum server path compiles automatically.
|
||||
|
||||
Integration tests in `tests/` start the server by calling a `spawn_test_server()` helper that:
|
||||
1. Opens an in-memory or temp-file rusqlite DB (via `NativeRepository`)
|
||||
2. Calls `router::build_router(repo)` to get the Axum `Router`
|
||||
3. Binds to a random port with `tokio::net::TcpListener::bind("127.0.0.1:0")`
|
||||
4. Spawns the server with `tokio::spawn(axum::serve(listener, app))`
|
||||
5. Returns `(base_url, shutdown_handle)`
|
||||
|
||||
No wrangler dev, no separate binary, no feature flags. Standard `cargo test` workflow.
|
||||
Resolved as part of TRIAGE e8a330 and a91260 (cfg-split architecture decision).
|
||||
|
||||
See implementation ticket 00aff0 for the DB abstraction details and ticket 9b581f for the
|
||||
test harness implementation.
|
||||
</decision>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — test-harness-how-to-import-and-start-quotesdbapi-in-tests-wo`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,38 @@
|
||||
+++
|
||||
title = "Implement pagination component — prev/next buttons, current page indicator, total pages"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "93515e"]
|
||||
dependencies = ["93515e", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement a shared `Pagination` Yew component (`src/bin/ui/components/pagination.rs`) that renders:
|
||||
- A "Previous" button (disabled on page 1)
|
||||
- Current page indicator (e.g. "Page 2 of 5")
|
||||
- A "Next" button (disabled on the last page)
|
||||
|
||||
The component accepts `page`, `total_pages`, and an `on_page_change: Callback<u32>` prop.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Resolve TRIAGE ticket 5e3e37 (CSS/styling approach) before adding class names.
|
||||
- Do not navigate programmatically — call `on_page_change` and let the parent update the URL or state.
|
||||
- Render nothing (or a disabled shell) if `total_pages <= 1`.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement shared Pagination component`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,46 @@
|
||||
+++
|
||||
title = "Implement GET /api/quotes/random — random row query (must be registered before /:id route)"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
|
||||
dependencies = ["a5049d", "d792e2", "175382"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
`GET /api/quotes/random` returns a single random quote from the database. This endpoint **must be registered before** `GET /api/quotes/:id` in the Axum router, or it will never be reached (Axum matches in registration order and ":id" would match the literal string "random").
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `GET /api/quotes/random` handler that selects a random row from the `quotes` table and returns it with its tags. Return 404 if the database is empty.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- **Router ordering is critical** — document the ordering requirement in a comment in `main.rs`.
|
||||
- Use `ORDER BY RANDOM() LIMIT 1` for SQLite random selection.
|
||||
- Include the quote's tags in the response.
|
||||
- Return `404 Not Found` with `{"error": "no quotes available"}` if the table is empty.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write tests for: random quote returned (non-empty DB), 404 when DB is empty.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement GET /api/quotes/random`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,39 @@
|
||||
+++
|
||||
title = "Set up infra/ OpenTofu project — providers.tf, terraform.tf, .gitignore for state"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "07feaa"]
|
||||
dependencies = ["07feaa"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Bootstrap the OpenTofu project in `infra/`:
|
||||
1. Create `infra/providers.tf` — declare the Cloudflare provider with the required version
|
||||
2. `infra/terraform.tf` — **use the local file backend** (07feaa resolved: local file is the correct choice for this solo project). The local backend is OpenTofu's default, so no explicit `backend` block is needed in `terraform.tf`. The file only needs the `required_providers` block (already partially present in `main.tf` — move it to `terraform.tf` and remove from `main.tf`).
|
||||
3. Create `infra/.gitignore` — ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`, `.terraform.lock.hcl`
|
||||
4. Run `tofu init` to initialise the provider
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- State backend is **local file** (resolved by 07feaa). No `backend` block is required — omitting it uses the local default.
|
||||
- The Cloudflare provider requires an API token — document the expected environment variable (`CLOUDFLARE_API_TOKEN`) in a comment in `providers.tf`, do not hardcode it.
|
||||
- Every `resource` and `data` block must have a comment explaining its purpose (per CLAUDE.md).
|
||||
- Note: `infra/main.tf` currently contains the `terraform` block — move it to `infra/terraform.tf` during this task.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
Run from the `infra/` directory:
|
||||
|
||||
```sh
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): bootstrap OpenTofu infra project with Cloudflare provider`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,54 @@
|
||||
+++
|
||||
title = "[TRIAGE] OpenAPI spec serving strategy — embed YAML at compile time vs load at runtime"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
OpenAPI spec serving strategy: should the spec be embedded at compile time (include_str! macro) or loaded at runtime from a file or generated programmatically?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Compile-time embed** — `include_str!("../../api/openapi.yaml")` bakes the YAML into the binary. Simple, no runtime file I/O needed for Workers.
|
||||
2. **Runtime load** — read the file at startup. Does not work in Cloudflare Workers (no filesystem).
|
||||
3. **Programmatic generation** — use a crate like `utoipa` to generate the spec from handler annotations. Most maintainable but adds complexity.
|
||||
</options>
|
||||
|
||||
<resolution>
|
||||
**Chosen approach: Option 1 — compile-time embed, via `build.rs` (not raw `include_str!` of YAML).**
|
||||
|
||||
The refined implementation uses a `build.rs` script rather than embedding the raw YAML and
|
||||
parsing it at runtime. Specifically:
|
||||
|
||||
- `build.rs` reads `api/openapi.yaml`, parses it to `serde_json::Value` with `serde_yaml`,
|
||||
writes compact JSON to `$OUT_DIR/openapi.json`, and emits
|
||||
`cargo:rerun-if-changed=api/openapi.yaml` so the conversion re-runs on every spec change.
|
||||
- The `GET /api/` handler serves the result as:
|
||||
`const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));`
|
||||
- `serde_yaml` is a `[build-dependencies]` entry only — it never enters the Workers binary,
|
||||
keeping binary size minimal.
|
||||
- Zero runtime overhead: no `OnceLock`, no lazy parsing, no heap allocation for the spec.
|
||||
|
||||
Options 2 (runtime load) and 3 (utoipa) were ruled out:
|
||||
- Option 2 is impossible on Cloudflare Workers — there is no filesystem at runtime.
|
||||
- Option 3 (utoipa) would require annotating all 7 handlers with macros and migrating away
|
||||
from the hand-written `api/openapi.yaml` spec, which is already complete and validated.
|
||||
The added complexity is not justified for a project of this size.
|
||||
|
||||
Tickets updated:
|
||||
- **8892d5** (new): implements `build.rs` and adds `[build-dependencies]` to `Cargo.toml`.
|
||||
- **28e7d9**: updated with the concrete `include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))`
|
||||
handler pattern; now depends on 8892d5 instead of this triage ticket.
|
||||
- **1f5bb5**: updated with the `[build-dependencies]` constraint.
|
||||
- **f3dc74** (API sub-project): 8892d5 added as dependency.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — openapi-spec-serving-strategy-embed-yaml-at-compile-time-vs-`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,56 @@
|
||||
+++
|
||||
title = "[TRIAGE] Local dev config: Turso (file SQLite) vs D1 binding selection strategy"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
Local dev config: should the API use Turso (file-backed SQLite via libsql) or a D1 binding (via wrangler dev) for local development? How is the selection made at runtime?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Turso/libsql** — lightweight local SQLite file, no Cloudflare account needed. Connection string via env var. SQLx-compatible.
|
||||
2. **Wrangler D1 local** — `wrangler dev` spins up a local D1 emulator. Closer to production but requires wrangler and a Cloudflare account even locally.
|
||||
3. **Plain SQLite via sqlx** — use sqlx's SQLite driver with a local file. No Turso dependency needed for dev.
|
||||
</options>
|
||||
|
||||
<resolution status="resolved">
|
||||
**Chosen approach: rusqlite with a local SQLite file — a variant of Option 3, but using rusqlite instead of sqlx.**
|
||||
|
||||
This decision is a direct consequence of TRIAGE e8a330 (already resolved): SQLx is NOT compatible
|
||||
with Cloudflare Workers/D1 at all. The chosen architecture is `cfg(target_arch = "wasm32")` compile-time split:
|
||||
- `wasm32` (production) → workers-rs `D1Database` bindings
|
||||
- native (local dev + tests) → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file
|
||||
|
||||
Rationale for rusqlite over Turso:
|
||||
- No additional dependency or service (Turso = libsql client + server/cloud)
|
||||
- `rusqlite` with `features = ["bundled"]` compiles SQLite in — zero system dependencies
|
||||
- `cargo run` just works without any account, credentials, or external tooling
|
||||
- `cargo test` works the same way — tests use the rusqlite path automatically
|
||||
|
||||
Rationale for rusqlite over wrangler D1 local:
|
||||
- No wrangler, no Cloudflare account required for local dev or CI
|
||||
- Eliminates a major developer friction point
|
||||
- Integration tests use `NativeRepository` (rusqlite) directly without spawning wrangler
|
||||
|
||||
Selection mechanism: **compile-time** via `cfg(target_arch = "wasm32")`, not runtime env var.
|
||||
The `DATABASE_URL` env var controls the SQLite file path (default: `./quotesdb.sqlite`).
|
||||
|
||||
Port note: Native API server binds to `localhost:3000` (Trunk UI dev server uses `localhost:8080`).
|
||||
Port conflict found and fixed in ticket 00aff0 (was 8080, corrected to 3000).
|
||||
|
||||
Updated:
|
||||
- Ticket 00aff0 (DB abstraction): corrected native server port 8080 → 3000
|
||||
- Ticket af56a7 (local dev docs): updated title and body to reflect rusqlite approach
|
||||
- Ticket 9c9546 (new): create `.env.example` documenting `DATABASE_URL`
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — local-dev-config-turso-file-sqlite-vs-d1-binding-selection-s`
|
||||
</commit>
|
||||
@ -0,0 +1,43 @@
|
||||
+++
|
||||
title = "quotesdb/ui: report button with modal (reason field + captcha)"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["77237f"]
|
||||
+++
|
||||
## Summary
|
||||
Add a Report button to quote detail pages that opens a modal with an optional reason field and a CAPTCHA to prevent abuse.
|
||||
|
||||
## Details
|
||||
- Report button appears on /quotes/:id page
|
||||
- Clicking opens a modal with:
|
||||
- Optional reason textarea (max 256 characters, show character counter)
|
||||
- CAPTCHA widget (Cloudflare Turnstile — see infra, or hCaptcha as fallback)
|
||||
- Submit button (disabled until CAPTCHA is completed)
|
||||
- Cancel button
|
||||
- On submit:
|
||||
- POST /api/quotes/:id/report with { reason?, captcha_token }
|
||||
- API verifies CAPTCHA server-side before creating report
|
||||
- Show success message on 201
|
||||
- Show error message on failure
|
||||
|
||||
## CAPTCHA
|
||||
Use Cloudflare Turnstile (free, privacy-friendly). Site key stored as an environment variable in Trunk.toml or index.html. The API worker verifies the token via the Turnstile verify endpoint.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Report button visible on /quotes/:id
|
||||
- [ ] Modal opens with reason textarea and CAPTCHA
|
||||
- [ ] Character counter on reason field
|
||||
- [ ] Submit disabled until CAPTCHA solved
|
||||
- [ ] Correct POST request on submit
|
||||
- [ ] Success and error feedback shown
|
||||
- [ ] Modal closes on cancel
|
||||
|
||||
## Depends on
|
||||
- 77237f (reports API endpoint)
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy
|
||||
trunk build
|
||||
```
|
||||
@ -0,0 +1,100 @@
|
||||
+++
|
||||
title = "quotesdb/api: GET /api/status public endpoint"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["69a2c5"]
|
||||
+++
|
||||
## GET /api/status public endpoint
|
||||
|
||||
Add a public status endpoint that exposes whether submissions are currently locked. The UI calls this on mount for both the /submit and /admin pages.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/bin/api/db/mod.rs` — add `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait
|
||||
- `src/bin/api/db/d1.rs` — implement the two new trait methods for the D1 backend; seed `submissions_locked = "0"` alongside `admin_auth_code` in the startup migration if not already present
|
||||
- `src/bin/api/db/native.rs` — implement the two new trait methods for the native/SQLite backend
|
||||
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/status.rs`) — add the `get_status` handler
|
||||
- `src/bin/api/main.rs` — register the new route
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
The `admin_config` key/value table gains a second row. Seed it on startup (alongside `admin_auth_code`) if it does not already exist:
|
||||
|
||||
```sql
|
||||
INSERT OR IGNORE INTO admin_config (key, value) VALUES ('submissions_locked', '0');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New trait methods (src/bin/api/db/mod.rs)
|
||||
|
||||
Add to the `QuoteRepository` trait:
|
||||
|
||||
```rust
|
||||
/// Return whether submissions are currently locked.
|
||||
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
|
||||
|
||||
/// Set the submissions lock state.
|
||||
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
|
||||
```
|
||||
|
||||
Both implementations read/write the `submissions_locked` key in `admin_config`, treating `"1"` as `true` and anything else as `false`.
|
||||
|
||||
---
|
||||
|
||||
## Handler
|
||||
|
||||
Add to the handlers module:
|
||||
|
||||
```rust
|
||||
/// GET /api/status — returns current submission lock state; requires no auth.
|
||||
pub async fn get_status(State(repo): State<Arc<dyn QuoteRepository>>) -> impl IntoResponse {
|
||||
match repo.get_submissions_locked().await {
|
||||
Ok(locked) => Json(json!({ "submissions_locked": locked })).into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route registration (src/bin/api/main.rs)
|
||||
|
||||
Register before the quotes router:
|
||||
|
||||
```rust
|
||||
.route("/api/status", get(handlers::get_status))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
In `src/bin/api/handlers/` (or the relevant test module), add unit tests covering:
|
||||
|
||||
- `GET /api/status` returns `200` with `{ "submissions_locked": false }` when the DB value is `"0"`
|
||||
- `GET /api/status` returns `200` with `{ "submissions_locked": true }` when the DB value is `"1"`
|
||||
- `get_submissions_locked` returns `false` for missing key (graceful default)
|
||||
|
||||
Use a mock or in-memory SQLite repo for all handler tests.
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat(quotesdb): GET /api/status public endpoint
|
||||
```
|
||||
@ -1,7 +1,28 @@
|
||||
+++
|
||||
title = "Write ui/README.md, ui/docs/PLANNING.md, ui/docs/ARCHITECTURE.md"
|
||||
priority = 3
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"]
|
||||
dependencies = ["1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the three documentation files for the UI domain:
|
||||
1. `README.md` — what the UI is, how to run it (`trunk serve`), how to build (`trunk build`), license, Claude Code disclaimer
|
||||
2. `docs/PLANNING.md` — development phases and work log for the UI sub-domain
|
||||
3. `docs/ARCHITECTURE.md` — UI component tree overview, routing, API client, WASM compilation notes
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- README must include the dual Apache-2.0 + MIT license notice.
|
||||
- README must include a disclaimer that the software was written with Claude Code (model: claude-sonnet-4-6).
|
||||
- ARCHITECTURE.md must describe the component hierarchy and how the Yew router maps to page components.
|
||||
</constraints>
|
||||
|
||||
<commit>
|
||||
`docs(quotesdb): write ui README, PLANNING.md, and ARCHITECTURE.md`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
+++
|
||||
title = "quotesdb/ui: admin moderation tab (paginated reports, per-quote modal with delete/hide)"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["6c5904", "cb8de0"]
|
||||
+++
|
||||
## Summary
|
||||
Add a Moderation tab to the admin page showing a paginated list of reported quotes. Clicking a report opens a detail modal with the quote and all reports, plus action buttons.
|
||||
|
||||
## Tab: Moderation
|
||||
- Only visible on the admin page after auth is unlocked
|
||||
- Paginated list (10/page) of quotes with at least one report
|
||||
- Each row shows: quote text (truncated), author, report count, most recent report date
|
||||
- Clicking a row opens a Report Detail Modal
|
||||
|
||||
## Report Detail Modal
|
||||
- Shows the full quote (text, author, source, date, tags)
|
||||
- Lists all reports below: reason (or "No reason given"), reported date
|
||||
- Action buttons:
|
||||
- "Delete Quote" — calls DELETE /api/admin/reports/:quote_id/quote, closes modal, refreshes list
|
||||
- "Hide Quote" — calls POST /api/admin/reports/:quote_id/hide, shows success, refreshes list
|
||||
- "Dismiss Reports" — calls DELETE /api/admin/reports/:quote_id/reports, closes modal, refreshes list
|
||||
- Close/Cancel button
|
||||
|
||||
## Depends on
|
||||
- 6c5904 (admin moderation API endpoints)
|
||||
- cb8de0 (admin auth-first flow)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Moderation tab visible on /admin after unlock
|
||||
- [ ] Paginated list of reported quotes
|
||||
- [ ] Report detail modal with quote + reports
|
||||
- [ ] Delete, Hide, Dismiss actions work and refresh list
|
||||
- [ ] Loading and error states handled
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy
|
||||
trunk build
|
||||
```
|
||||
@ -1,7 +1,45 @@
|
||||
+++
|
||||
title = "Test suite: PUT /api/quotes — create (auto auth_code, custom auth_code, missing fields 422)"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f", "05f8ae"]
|
||||
dependencies = ["9b581f", "05f8ae"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the `PUT /api/quotes` test suite in `tests/test_create_quote.rs` (or similar). Test cases:
|
||||
1. Create with auto-generated auth_code — verify 201, quote object returned, auth_code present in response
|
||||
2. Create with custom auth_code in body — verify the provided code is stored and returned
|
||||
3. Missing `text` field — verify 422 Unprocessable Entity
|
||||
4. Missing `author` field — verify 422 Unprocessable Entity
|
||||
5. Create with tags — verify tags appear in the returned quote
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use the shared test harness from ticket 9b581f.
|
||||
- Auth code in the response must match the pattern `word-word-word-word`.
|
||||
- Verify the created quote is retrievable via `GET /api/quotes/:id` after creation.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add PUT /api/quotes test suite — create quote`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
+++
|
||||
title = "Write .gitea/workflows/deploy-ui.yml — Gitea Actions workflow to build and deploy UI to Cloudflare Pages"
|
||||
priority = 4
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ae886f", "dc3d2b"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Build strategy resolved in triage fc9bfd: pre-built artifact + Gitea Actions + `wrangler pages deploy`.
|
||||
|
||||
The Gitea instance at `gitea.elijah.run` runs Gitea Actions (GitHub Actions-compatible YAML). The workflow must:
|
||||
1. Trigger on push to the `quotesdb` branch
|
||||
2. Build the Yew/Wasm UI with `trunk build --release`
|
||||
3. Deploy the `dist/` output to Cloudflare Pages via `wrangler pages deploy`
|
||||
|
||||
The Cloudflare Pages project (`quotesdb-ui`) is created by OpenTofu (ticket ae886f) and must exist before this workflow can successfully deploy.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Create `.gitea/workflows/deploy-ui.yml` at the repository root (not inside `quotesdb/`).
|
||||
</goal>
|
||||
|
||||
<implementation>
|
||||
```yaml
|
||||
# .gitea/workflows/deploy-ui.yml
|
||||
# Builds the quotesdb Yew/Wasm UI with Trunk and deploys to Cloudflare Pages.
|
||||
# Triggered on push to the quotesdb integration branch.
|
||||
# Requires repository secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
|
||||
|
||||
name: Deploy quotesdb UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- quotesdb
|
||||
paths:
|
||||
- "quotesdb/**"
|
||||
|
||||
jobs:
|
||||
deploy-ui:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: quotesdb
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain with wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
quotesdb/target
|
||||
key: ${{ runner.os }}-cargo-ui-${{ hashFiles("quotesdb/Cargo.lock") }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-ui-
|
||||
|
||||
- name: Install Trunk
|
||||
run: |
|
||||
curl -fsSL https://github.com/trunk-rs/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz \
|
||||
| tar -xz -C ~/.cargo/bin
|
||||
|
||||
- name: Build UI with Trunk
|
||||
run: trunk build --release
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy dist/ --project-name quotesdb-ui --branch main
|
||||
```
|
||||
</implementation>
|
||||
|
||||
<secrets>
|
||||
The following repository secrets must be configured in Gitea (Settings → Secrets):
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Pages:Edit and Account:Read permissions |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (visible in the Cloudflare dashboard URL) |
|
||||
|
||||
Documentation for secrets is tracked in ticket 71b1d4.
|
||||
</secrets>
|
||||
|
||||
<notes>
|
||||
- The workflow file lives at the **repository root** (`.gitea/workflows/`), not inside `quotesdb/`. Gitea Actions discovers workflows from the repo root.
|
||||
- `working-directory: quotesdb` ensures all `run` steps execute from the project directory.
|
||||
- `paths: ["quotesdb/**"]` limits deploys to pushes that actually change the UI project, avoiding spurious rebuilds.
|
||||
- Trunk downloads the latest release binary from GitHub; pin to a specific version for reproducibility once stable.
|
||||
- `wrangler-action@v3` handles `npx wrangler` invocation internally — no separate Node.js/wrangler install needed.
|
||||
- `--branch main` tells Pages this deployment is for the production branch (matches `production_branch = "quotesdb"` in OpenTofu — adjust if Pages branch naming differs).
|
||||
</notes>
|
||||
|
||||
<constraints>
|
||||
- The Cloudflare Pages project (`quotesdb-ui`) must already exist (created by OpenTofu ticket ae886f) before the first deploy succeeds.
|
||||
- `trunk build --release` must succeed locally before this workflow is useful; verify with `trunk build` first.
|
||||
- Do not commit `CLOUDFLARE_API_TOKEN` or any secrets to the repository.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
After creating the workflow file:
|
||||
1. Push to the `quotesdb` branch
|
||||
2. Confirm the Gitea Actions run succeeds (Actions tab in Gitea UI)
|
||||
3. Confirm the deployment appears in the Cloudflare Pages dashboard under `quotesdb-ui`
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`ci(quotesdb): add Gitea Actions workflow to build and deploy UI to Cloudflare Pages`
|
||||
</commit>
|
||||
@ -0,0 +1,114 @@
|
||||
+++
|
||||
title = "Implement auth code session storage — utility module and AuthModal pre-fill integration"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
Resolved from TRIAGE ticket 0bc655. The auth code (4-word passphrase) that authorises edit and
|
||||
delete operations must be available to the UI without forcing the user to re-enter it on every
|
||||
interaction within a browsing session.
|
||||
|
||||
Chosen strategy: **session storage per quote ID**. The code is stored in the browser's
|
||||
`sessionStorage` under the key `auth_code_{id}` when first entered. It is automatically cleared
|
||||
when the tab closes. No explicit clear-on-delete is required (session storage is short-lived by
|
||||
design), but it is good practice and should be included.
|
||||
|
||||
Options considered:
|
||||
- localStorage: ruled out — indefinite persistence is unnecessary; the app tells users to store
|
||||
the code externally anyway, and localStorage has a wider XSS exposure window.
|
||||
- Component state only: ruled out — code is lost on any page navigation or reload, making the
|
||||
edit/delete flow unusable in practice.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
**Part 1 — Storage utility (`src/bin/ui/storage.rs`)**
|
||||
|
||||
Create a module with three public functions that wrap the browser's `sessionStorage` API:
|
||||
|
||||
```rust
|
||||
use web_sys::window;
|
||||
|
||||
/// Retrieve the stored auth code for a given quote ID, if any.
|
||||
pub fn get_auth_code(quote_id: &str) -> Option<String> {
|
||||
let storage = window()?.session_storage().ok()??;
|
||||
storage.get_item(&format!("auth_code_{quote_id}")).ok()?
|
||||
}
|
||||
|
||||
/// Persist the auth code for a quote ID in sessionStorage.
|
||||
pub fn set_auth_code(quote_id: &str, code: &str) {
|
||||
if let Some(Ok(storage)) = window().map(|w| w.session_storage()) {
|
||||
if let Some(storage) = storage {
|
||||
let _ = storage.set_item(&format!("auth_code_{quote_id}"), code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the auth code for a quote ID from sessionStorage (call after DELETE).
|
||||
pub fn clear_auth_code(quote_id: &str) {
|
||||
if let Some(Ok(storage)) = window().map(|w| w.session_storage()) {
|
||||
if let Some(storage) = storage {
|
||||
let _ = storage.remove_item(&format!("auth_code_{quote_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expose this module from the UI binary root: add `mod storage;` to `src/bin/ui/main.rs`.
|
||||
|
||||
**Part 2 — AuthModal pre-fill**
|
||||
|
||||
Update the `AuthModal` component (ticket f850c6) to accept an `initial_value: Option<String>`
|
||||
prop. Pre-populate the `<input>` value from this prop when the modal opens. The parent
|
||||
component is responsible for reading from storage and passing the value in.
|
||||
|
||||
```rust
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AuthModalProps {
|
||||
pub on_submit: Callback<String>,
|
||||
pub on_cancel: Callback<()>,
|
||||
pub initial_value: Option<String>, // pre-fill if auth code is already stored
|
||||
}
|
||||
```
|
||||
|
||||
**Part 3 — SingleQuotePage integration**
|
||||
|
||||
In the SingleQuotePage (or whichever component renders edit/delete for a quote), integrate
|
||||
storage around the `AuthModal`:
|
||||
|
||||
- Before opening the modal: read `storage::get_auth_code("e.id)` and pass it as
|
||||
`initial_value` to `AuthModal`.
|
||||
- After a successful **edit** (POST /api/quotes/:id returns 200): call
|
||||
`storage::set_auth_code("e.id, &submitted_code)`.
|
||||
- After a successful **delete** (DELETE /api/quotes/:id returns 204): call
|
||||
`storage::clear_auth_code("e.id)`.
|
||||
- If the API returns 403 (wrong code): do NOT store the code; clear any existing stored value
|
||||
with `storage::clear_auth_code("e.id)` so a stale code is not re-offered.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- The storage utility must compile only for `wasm32-unknown-unknown` — `web_sys::window()` is
|
||||
not available on the host target. Gate the module under `#[cfg(target_arch = "wasm32")]` or
|
||||
ensure it is only imported by the `ui` binary, which is always compiled for wasm32.
|
||||
- `web_sys` must be available with the `Window`, `Storage` features — confirm these are included
|
||||
in the `web_sys` dependency in `Cargo.toml` (ticket 93515e covers UI Cargo.toml setup).
|
||||
- Do NOT use `gloo-storage` — it wraps localStorage by default and the API difference matters.
|
||||
Use `web_sys` directly as shown above.
|
||||
- The key pattern is `auth_code_{quote_id}` (underscore separator, not slash or dot).
|
||||
- Session storage is tab-scoped: no cross-tab contamination is possible — no additional
|
||||
scoping by domain or user is needed.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement auth code session storage utility and AuthModal pre-fill`
|
||||
</commit>
|
||||
|
||||
<domain>quotesdb/ui</domain>
|
||||
@ -0,0 +1,124 @@
|
||||
+++
|
||||
title = "Write .gitea/workflows/deploy-api.yml — Gitea Actions workflow to build and deploy API Worker via OpenTofu"
|
||||
priority = 4
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["a23489", "2d1371"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The API Worker is a workers-rs Wasm binary deployed to Cloudflare Workers. The OpenTofu resource (`infra/worker.tf`) reads the compiled Wasm via `filebase64("../target/wasm32-unknown-unknown/release/api.wasm")` and uploads it on `tofu apply`. This means the CI workflow must compile the Wasm before running `tofu apply`.
|
||||
|
||||
Counterpart to ticket 5137d7 (UI deploy via wrangler pages deploy).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Create `.gitea/workflows/deploy-api.yml` at the repository root. The workflow must:
|
||||
1. Compile the `api` binary for `wasm32-unknown-unknown`
|
||||
2. Run `tofu apply` from `quotesdb/infra/` to upload the Worker and provision/update all infra
|
||||
|
||||
Triggered on push to `quotesdb` branch when files under `quotesdb/src/bin/api/` or `quotesdb/infra/` change.
|
||||
</goal>
|
||||
|
||||
<implementation>
|
||||
```yaml
|
||||
# .gitea/workflows/deploy-api.yml
|
||||
# Builds the quotesdb API Worker Wasm binary and applies OpenTofu infra.
|
||||
# Triggered on push to the quotesdb integration branch when API or infra files change.
|
||||
# Requires repository secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, TF_STATE_* (if using remote state).
|
||||
|
||||
name: Deploy quotesdb API
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- quotesdb
|
||||
paths:
|
||||
- "quotesdb/src/bin/api/**"
|
||||
- "quotesdb/src/lib.rs"
|
||||
- "quotesdb/infra/**"
|
||||
- "quotesdb/Cargo.toml"
|
||||
- "quotesdb/Cargo.lock"
|
||||
|
||||
jobs:
|
||||
deploy-api:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: quotesdb
|
||||
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain with wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
quotesdb/target
|
||||
key: ${{ runner.os }}-cargo-api-${{ hashFiles("quotesdb/Cargo.lock") }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-api-
|
||||
|
||||
- name: Build API Worker Wasm binary
|
||||
run: cargo build --release --target wasm32-unknown-unknown --bin api
|
||||
|
||||
- name: Install OpenTofu
|
||||
uses: opentofu/setup-opentofu@v1
|
||||
|
||||
- name: OpenTofu init
|
||||
working-directory: quotesdb/infra
|
||||
run: tofu init
|
||||
|
||||
- name: OpenTofu apply
|
||||
working-directory: quotesdb/infra
|
||||
run: tofu apply -auto-approve
|
||||
```
|
||||
</implementation>
|
||||
|
||||
<secrets>
|
||||
The following repository secrets must be configured in Gitea (Settings → Secrets):
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Workers:Edit, D1:Edit, Account:Read permissions |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID |
|
||||
|
||||
Remote state credentials (if applicable) — see ticket 71b1d4.
|
||||
</secrets>
|
||||
|
||||
<notes>
|
||||
- `opentofu/setup-opentofu@v1` is the official GitHub/Gitea Action for OpenTofu installation.
|
||||
- The `env:` block at job level makes credentials available to both `tofu init` and `tofu apply` via the Cloudflare provider environment variable convention.
|
||||
- The Wasm binary at `target/wasm32-unknown-unknown/release/api.wasm` is read by `filebase64()` in `infra/worker.tf` at apply time — the file must exist before `tofu apply` runs.
|
||||
- `tofu apply -auto-approve` is safe in CI because the plan is deterministic and the repo is the source of truth.
|
||||
- OpenTofu state: the `infra/` directory needs a configured backend. If using local state, the state file must be committed or a remote backend (e.g. Cloudflare R2) configured. See ticket 2d1371.
|
||||
- The `paths` filter ensures the workflow only triggers when API code or infra config changes, avoiding spurious runs on UI-only pushes.
|
||||
</notes>
|
||||
|
||||
<constraints>
|
||||
- The Cloudflare infra (D1, Worker script resource) must be defined (ticket a23489, d0da0b) and `infra/` must be initialised (ticket 2d1371) before this workflow is useful.
|
||||
- Do not commit Cloudflare credentials or OpenTofu state files containing secrets.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
After creating the workflow file:
|
||||
1. Push to the `quotesdb` branch with a change to `src/bin/api/`
|
||||
2. Confirm the Gitea Actions run succeeds
|
||||
3. Confirm the Worker appears/updates in the Cloudflare Workers dashboard
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`ci(quotesdb): add Gitea Actions workflow to build and deploy API Worker via OpenTofu`
|
||||
</commit>
|
||||
@ -1,7 +1,46 @@
|
||||
+++
|
||||
title = "[TRIAGE] Database migration strategy for Cloudflare Workers (startup vs wrangler d1 execute)"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
Database migration strategy for Cloudflare Workers: how should the `quotes` and `quote_tags` tables be created? Workers do not have a persistent startup phase like a long-running server.
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Startup migration** — run `CREATE TABLE IF NOT EXISTS` in the Worker fetch handler before processing the first request. Simple but adds latency to the first request.
|
||||
2. **`wrangler d1 execute`** — apply the schema separately using the wrangler CLI. No runtime overhead but requires a separate CI step.
|
||||
3. **SQLx migrate! macro** — embed migrations in the binary and run them at startup. Depends on SQLx compatibility with workers-rs (see TRIAGE e8a330).
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**Option 2: `wrangler d1 execute` as a separate CI/CD step.**
|
||||
|
||||
- Option 3 (SQLx) is ruled out — TRIAGE e8a330 established that SQLx is incompatible with workers-rs/D1.
|
||||
- Option 1 (startup migration from the Workers handler) is impractical: Workers spin up per-request via V8 isolates. Running DDL before every request adds latency and is fragile.
|
||||
- Option 2 is the canonical Cloudflare-recommended approach. It is idempotent (`CREATE TABLE IF NOT EXISTS`), keeps the Workers handler free of DDL overhead, and integrates cleanly into CI/CD as a post-`tofu apply` step.
|
||||
|
||||
**Production:** `wrangler d1 execute quotesdb --file infra/schema.sql --remote` — run once after first `tofu apply`, and again for each incremental migration file.
|
||||
|
||||
**Local dev / tests:** `NativeRepository::run_migrations()` (ticket 00aff0) runs `execute_batch` via rusqlite on native startup. No manual wrangler step needed.
|
||||
|
||||
This decision is co-resolved with TRIAGE 5c0c64, which asked the same question from the OpenTofu angle. Both arrive at the same answer.
|
||||
</decision>
|
||||
|
||||
<resolution>
|
||||
- Co-resolved with TRIAGE 5c0c64.
|
||||
- Ticket a5049d updated with chosen strategy.
|
||||
- Ticket bb1514 created: implementation plan for `infra/schema.sql`.
|
||||
- Ticket 75489a updated: documents the wrangler workflow.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — database-migration-strategy-for-cloudflare-workers-startup-v`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,49 @@
|
||||
+++
|
||||
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>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
D1 migrations in OpenTofu: how do we apply the SQL schema to a newly created D1 database? Options are a null_resource local-exec in OpenTofu, a separate wrangler d1 execute step, or a manual migration step.
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **null_resource local-exec** — run `wrangler d1 execute` as a provisioner in OpenTofu. Ties infra and schema together in one `tofu apply`.
|
||||
2. **Separate wrangler step** — document as a manual step after `tofu apply`. Simpler OpenTofu config, slightly more manual.
|
||||
3. **API startup migration** — the API runs `CREATE TABLE IF NOT EXISTS` on startup. Works but risks schema drift in production.
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**Option 2: Separate wrangler step.**
|
||||
|
||||
- **null_resource local-exec rejected:** `null_resource` provisioners are an OpenTofu anti-pattern. They don't re-run unless tainted, aren't tracked in state, require wrangler installed on the CI runner at `tofu apply` time, and break idempotency. The convenience of a single command is not worth the coupling.
|
||||
|
||||
- **API startup migration rejected:** Cloudflare Workers spin up per-request via V8 isolates. There is no persistent startup phase. Running DDL (`CREATE TABLE IF NOT EXISTS`) before every request adds latency and is fragile. The Workers fetch handler (D1Repository, wasm32 path) does NOT run migrations. This is only viable for the native/local dev path (rusqlite), where `NativeRepository::run_migrations()` is called once at `main()` startup.
|
||||
|
||||
- **Separate wrangler step chosen:** This is Cloudflare's canonical approach. The schema SQL lives at `infra/schema.sql` (ticket bb1514). After `tofu apply`, run once:
|
||||
```sh
|
||||
wrangler d1 execute quotesdb --file infra/schema.sql --remote
|
||||
```
|
||||
Idempotent with `CREATE TABLE IF NOT EXISTS`. Integrates cleanly into CI/CD as a post-apply step. Keeps OpenTofu focused on infrastructure, not data.
|
||||
|
||||
**Note:** TRIAGE 580e66 asks the same question from the Workers runtime angle and arrives at the same answer. Both are now resolved together.
|
||||
</decision>
|
||||
|
||||
<resolution>
|
||||
- Ticket d0da0b updated: constraint clarified (wrangler step, no null_resource).
|
||||
- Ticket a5049d updated: migration strategy constraint updated (580e66 also resolved).
|
||||
- Ticket 75489a updated: dependency on bb1514 added; goal updated to reference infra/schema.sql.
|
||||
- Ticket 580e66 resolved as co-decided.
|
||||
- New ticket bb1514 created: full implementation plan for `infra/schema.sql`.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — d1-migrations-in-opentofu-nullresource-localexec-vs-separate`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,41 @@
|
||||
+++
|
||||
title = "Implement Browse page (/browse) — paginated quote list with author/tag filter controls"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"]
|
||||
dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The Browse page displays a paginated list of all quotes with optional author and tag filters.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the Browse page component (`src/bin/ui/pages/browse.rs`):
|
||||
1. Read `?page`, `?author`, `?tag` from the URL query string
|
||||
2. Fetch quotes from `GET /api/quotes` with the query parameters
|
||||
3. Render each quote with the `QuoteCard` component
|
||||
4. Render the `Pagination` component with prev/next navigation (update URL query params on page change)
|
||||
5. Render the `TagFilter` component and an author text input for filtering
|
||||
6. Render `ErrorDisplay` on error
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- URL query parameters are the source of truth for current page and filters — use yew-router location hooks to read/write them.
|
||||
- Changing a filter should reset to page 1.
|
||||
- The author filter is a free-text input (case-insensitive match on the API side).
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement Browse page — paginated list with filters`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,52 @@
|
||||
+++
|
||||
title = "Implement POST /api/quotes/:id — partial update, verify X-Auth-Code header, update updated_at"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
|
||||
dependencies = ["a5049d", "d792e2", "175382"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
`POST /api/quotes/:id` performs a partial update of a quote. The caller must provide the correct auth code via the `X-Auth-Code` request header. Only fields present in the request body are updated; absent fields are left unchanged. Optional fields (`source`, `date`) can be explicitly set to `null` to clear them.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `POST /api/quotes/:id` handler:
|
||||
1. Extract `:id` from the path
|
||||
2. Verify the `X-Auth-Code` header matches the stored `auth_code` — return 403 on mismatch
|
||||
3. Apply a partial UPDATE to the `quotes` row (only update supplied fields)
|
||||
4. Update `updated_at` timestamp
|
||||
5. If `tags` is present in the body, replace all tags for the quote
|
||||
6. Return 200 with the updated quote
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Return 404 if the quote ID does not exist.
|
||||
- Return 403 (not 401) on auth code mismatch; do not reveal whether the ID exists to unauthenticated callers.
|
||||
- Setting a field to `null` in the request body should clear it (for `source` and `date`).
|
||||
- `updated_at` must be set to `CURRENT_TIMESTAMP` on every update.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write tests for: valid auth 200, wrong auth 403, not found 404, partial update, null-to-clear.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement POST /api/quotes/:id — partial update with auth verification`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,45 @@
|
||||
+++
|
||||
title = "Implement GET /api/quotes/:id — fetch by NanoID, return 404 if not found"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
|
||||
dependencies = ["a5049d", "d792e2", "175382"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
`GET /api/quotes/:id` returns a single quote by its NanoID. Returns 404 if no quote with that ID exists.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `GET /api/quotes/:id` handler that looks up a quote by NanoID, fetches its tags, and returns the full quote JSON. Return 404 if the ID is not found.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Extract the `:id` path parameter using Axum's `Path` extractor.
|
||||
- Include the quote's tags in the response.
|
||||
- Return `404 Not Found` with `{"error": "not found"}` if the ID doesn't match any row.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write tests for: 200 with quote object, 404 not found.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement GET /api/quotes/:id`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,47 @@
|
||||
+++
|
||||
title = "[TRIAGE] CSS/styling approach for Wasm — plain CSS, CDN Tailwind, or Wasm-compatible crate?"
|
||||
priority = 8
|
||||
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>
|
||||
|
||||
<question>
|
||||
CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Tailwind (loaded in index.html), or a Wasm-compatible Rust styling crate?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Plain CSS** — write a `style.css` file, include it in `index.html`. No build complexity. Simple and portable.
|
||||
2. **CDN Tailwind** — add Tailwind CDN `<script>` to `index.html`. No build step needed. Larger page load; fine for small apps.
|
||||
3. **Stylist or yew-style** — Rust crates for CSS-in-Wasm. More idiomatic but less documentation.
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**Plain CSS** — Option 1.
|
||||
|
||||
Rationale:
|
||||
- CDN Tailwind's JIT scanner reads DOM/HTML for class names to generate utility CSS on-the-fly. In a Yew Wasm app, class names are Rust strings compiled into the Wasm binary — they are never present in the HTML that Tailwind's scanner reads. The result is a non-functional Tailwind build with no utility classes.
|
||||
- Stylist/yew-style add a Wasm dependency, sparse documentation, and binary bloat for a 5-page app where co-location of styles provides no real benefit.
|
||||
- Plain CSS + Trunk: Trunk natively bundles CSS via `<link data-trunk rel="css" href="src/bin/ui/style.css"/>` in `index.html`. Zero additional dependencies, no build complexity, easy maintainability.
|
||||
|
||||
Implementation:
|
||||
- CSS file: `src/bin/ui/style.css` (Trunk discovers files relative to `index.html`)
|
||||
- index.html link: `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`
|
||||
- Naming convention: BEM-style semantic names — `quote-card`, `quote-card__text`, `quote-card__author`, `page-browse`, etc.
|
||||
- Yew usage: `class={"quote-card"}` or `classes!["quote-card", conditional]`
|
||||
- Dedicated implementation ticket: 0fbdd5
|
||||
</decision>
|
||||
|
||||
<resolution>
|
||||
1. Research the options above and choose the best approach for this project.
|
||||
2. Update ticket dc3d2b (Trunk.toml + index.html) and all UI component tickets with the chosen CSS class strategy.
|
||||
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — css-styling-approach-plain-css`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,42 @@
|
||||
+++
|
||||
title = "Implement Quote detail page (/quotes/:id) — view, edit form with auth prompt, delete with auth prompt"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "f850c6", "fc2f51"]
|
||||
dependencies = ["04f865", "1e6a09", "0d987f", "f850c6", "fc2f51", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The Quote Detail page (`/quotes/:id`) shows a single quote. It also provides edit and delete actions, each guarded by the `AuthModal` component that prompts for the auth code.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the Quote Detail page component (`src/bin/ui/pages/quote_detail.rs`):
|
||||
1. Extract `:id` from the route
|
||||
2. Fetch the quote from `GET /api/quotes/:id`
|
||||
3. Render the quote with `QuoteCard`
|
||||
4. Render Edit and Delete buttons
|
||||
5. Edit: show `AuthModal`, then show an edit form pre-filled with current values; on submit call `POST /api/quotes/:id`
|
||||
6. Delete: show `AuthModal`, then call `DELETE /api/quotes/:id`; on success navigate to `/`
|
||||
7. Show 403 error message on wrong auth code
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- 404 from the API should display a user-friendly "quote not found" message.
|
||||
- After successful edit, re-fetch the quote to show updated data.
|
||||
- After successful delete, navigate to `/browse`.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement Quote Detail page — view, edit, delete with auth`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,55 @@
|
||||
+++
|
||||
title = "Set up tests/Cargo.toml with integration test dependencies (reqwest/hyper, tokio, serde_json)"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "0d84fa"]
|
||||
dependencies = ["0d84fa", "fba598"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment.
|
||||
|
||||
Triage decisions:
|
||||
- 0d84fa: HTTP client → `reqwest` with `tokio::test`
|
||||
- fba598: Isolation strategy → per-test temp SQLite file via `tempfile` crate
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Add integration test dev-dependencies to `Cargo.toml` under `[dev-dependencies]`:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
```
|
||||
|
||||
Confirm `cargo check` passes after adding these.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Dev-dependencies do not need WASM compatibility — they are host-only.
|
||||
- Use `#[tokio::test]` for async test functions.
|
||||
- `tempfile` is required by the test harness isolation strategy (ticket fba598 / 9b581f).
|
||||
- `reqwest` must include the `json` feature for `.json()` request body and `.json::<T>()` response deserialization.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` — run `cargo check` after adding deps to confirm they resolve.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): add integration test dev-dependencies (reqwest, tokio, serde_json, tempfile)`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,39 @@
|
||||
+++
|
||||
title = "Configure custom domain quotes.elijah.run → Cloudflare Pages (DNS record + Pages domain binding)"
|
||||
priority = 6
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "ae886f"]
|
||||
dependencies = ["ae886f"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
|
||||
|
||||
The frontend is served at the custom domain `quotes.elijah.run`. This requires a DNS record pointing to Cloudflare Pages and a custom domain binding on the Pages project.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Configure the custom domain in `infra/pages.tf` (or `infra/dns.tf`):
|
||||
1. `cloudflare_pages_domain` resource — binds `quotes.elijah.run` to the Pages project
|
||||
2. `cloudflare_record` resource — DNS CNAME record pointing `quotes` → the Pages `*.pages.dev` domain
|
||||
|
||||
Every block must have a comment.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- The Cloudflare zone ID for `elijah.run` must be provided as a variable or looked up via a `data` source.
|
||||
- SSL is handled automatically by Cloudflare — no certificate resources needed.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
Run from the `infra/` directory:
|
||||
|
||||
```sh
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): configure custom domain quotes.elijah.run for Cloudflare Pages`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
+++
|
||||
title = "Add workers-rs WASM entry point to api binary"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Goal
|
||||
|
||||
Enable `cargo build --release --bin api --target wasm32-unknown-unknown` so the api binary deploys as a Cloudflare Worker (see `infra/worker.tf`).
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. `Cargo.toml`
|
||||
Add axum to `[target.'cfg(target_arch = "wasm32")'.dependencies]` with tokio disabled:
|
||||
```toml
|
||||
axum = { version = "0.8", default-features = false, features = ["json"] }
|
||||
```
|
||||
All axum types used in handlers (Router, Path, Query, State, Json, etc.) are available without the tokio feature.
|
||||
|
||||
### 2. `src/bin/api/main.rs`
|
||||
- Wrap existing native code (`mod handlers;`, `#[tokio::main] async fn main()`) in `#[cfg(not(target_arch = "wasm32"))]`
|
||||
- Add `#[cfg(target_arch = "wasm32")] mod handlers;` (no change to handlers themselves)
|
||||
- Add `#[event(fetch)]` workers-rs entry point that:
|
||||
1. Gets D1 binding from env: `env.d1("DB")`
|
||||
2. Creates `D1Repository::new(db)` — see companion ticket for D1 implementation
|
||||
3. Calls `repo.run_migrations()`
|
||||
4. Wraps repo in `Arc<dyn QuoteRepository + Send + Sync>`
|
||||
5. Builds Axum router via existing `handlers::router(repo)`
|
||||
6. Converts `worker::Request` → `http::Request<axum::body::Body>` (method, uri, headers, body bytes)
|
||||
7. Calls router via `tower_service::Service::call()`
|
||||
8. Converts `http::Response<axum::body::Body>` → `worker::Response` (status, headers, body bytes)
|
||||
|
||||
`tower_service` is already a transitive dep of axum.
|
||||
|
||||
## Dependencies
|
||||
- Companion ticket for D1Repository implementation must be done first (or in parallel).
|
||||
D1Repository must have `unsafe impl Send` and `unsafe impl Sync` for the Arc<dyn ... + Send + Sync> wrapper to work.
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
cargo build --release --bin api --target wasm32-unknown-unknown
|
||||
cargo build --release --bin api # native must still work
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
@ -0,0 +1,121 @@
|
||||
+++
|
||||
title = "quotesdb/api: POST /api/admin/reset-auth-code endpoint"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["69a2c5"]
|
||||
+++
|
||||
## POST /api/admin/reset-auth-code endpoint
|
||||
|
||||
Add the admin-protected endpoint that replaces the stored admin auth code. The caller must supply the current code via `X-Admin-Code`. A new code may be provided in the request body; if omitted, the server generates a fresh 4-word passphrase.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/bin/api/db/mod.rs` — add `update_admin_auth_code` to the `QuoteRepository` trait
|
||||
- `src/bin/api/db/d1.rs` — implement `update_admin_auth_code` for D1
|
||||
- `src/bin/api/db/native.rs` — implement `update_admin_auth_code` for native SQLite
|
||||
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/admin.rs`) — add the `reset_auth_code` handler
|
||||
- `src/bin/api/main.rs` — register the new route
|
||||
|
||||
---
|
||||
|
||||
## New trait method (src/bin/api/db/mod.rs)
|
||||
|
||||
Add to the `QuoteRepository` trait:
|
||||
|
||||
```rust
|
||||
/// Replace the admin auth code if `current` matches the stored value.
|
||||
/// If `new_code` is `None`, generates a fresh 4-word passphrase.
|
||||
/// Returns the new auth code string on success, or `DbError::Unauthorized`
|
||||
/// if `current` does not match.
|
||||
async fn update_admin_auth_code(
|
||||
&self,
|
||||
current: &str,
|
||||
new_code: Option<&str>,
|
||||
) -> Result<String, DbError>;
|
||||
```
|
||||
|
||||
Implementation steps:
|
||||
1. Fetch the stored `admin_auth_code` from `admin_config`.
|
||||
2. If it does not match `current`, return `DbError::Unauthorized` (or a dedicated variant).
|
||||
3. Determine the new code: use `new_code` if provided, otherwise call the existing passphrase-generation utility.
|
||||
4. Write the new value to `admin_config` with `UPDATE`.
|
||||
5. Return the new code string.
|
||||
|
||||
---
|
||||
|
||||
## Request / response types
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize)]
|
||||
struct ResetAuthCodeRequest {
|
||||
new_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResetAuthCodeResponse {
|
||||
auth_code: String,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handler
|
||||
|
||||
```rust
|
||||
/// POST /api/admin/reset-auth-code
|
||||
/// Requires X-Admin-Code header matching the stored admin passphrase.
|
||||
/// Body: { "new_code": "optional-string" }
|
||||
/// Response: 200 { "auth_code": "new-code" } or 403 on mismatch.
|
||||
pub async fn reset_auth_code(
|
||||
State(repo): State<Arc<dyn QuoteRepository>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<ResetAuthCodeRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let admin_code = match headers.get("x-admin-code").and_then(|v| v.to_str().ok()) {
|
||||
Some(c) => c.to_owned(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
match repo.update_admin_auth_code(&admin_code, payload.new_code.as_deref()).await {
|
||||
Ok(new_code) => Json(ResetAuthCodeResponse { auth_code: new_code }).into_response(),
|
||||
Err(DbError::Unauthorized) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route registration (src/bin/api/main.rs)
|
||||
|
||||
```rust
|
||||
.route("/api/admin/reset-auth-code", post(handlers::reset_auth_code))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no body `new_code` → `200`, response contains a non-empty `auth_code`
|
||||
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and explicit `new_code` → `200`, `auth_code` equals the supplied value
|
||||
- `POST /api/admin/reset-auth-code` with wrong `X-Admin-Code` → `403`
|
||||
- `POST /api/admin/reset-auth-code` with missing `X-Admin-Code` header → `403`
|
||||
- After a successful reset, subsequent calls with the old code return `403` and with the new code return `200`
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat(quotesdb): POST /api/admin/reset-auth-code endpoint
|
||||
```
|
||||
@ -0,0 +1,64 @@
|
||||
+++
|
||||
title = "quotesdb/api: DB layer — add submissions_locked + update_admin_auth_code"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Goal
|
||||
Extend the DB abstraction layer with three new trait methods and seed on startup.
|
||||
|
||||
## New trait methods (add to `src/bin/api/db/mod.rs`)
|
||||
|
||||
```rust
|
||||
/// Replace the admin auth code if `current` matches.
|
||||
/// If `new_code` is None, generates a fresh 4-word passphrase.
|
||||
/// Returns the new auth code on success.
|
||||
/// Returns Err(DbError::Forbidden) if `current` does not match.
|
||||
async fn update_admin_auth_code(
|
||||
&self,
|
||||
current: &str,
|
||||
new_code: Option<&str>,
|
||||
) -> Result<String, DbError>;
|
||||
|
||||
/// Return whether submissions are currently locked.
|
||||
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
|
||||
|
||||
/// Persist the submissions lock state.
|
||||
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
|
||||
```
|
||||
|
||||
## Implementations
|
||||
|
||||
Implement in both:
|
||||
- `src/bin/api/db/native.rs` (NativeRepository — rusqlite)
|
||||
- `src/bin/api/db/d1.rs` (D1Repository — Cloudflare Workers WASM)
|
||||
|
||||
## Seeding (startup)
|
||||
|
||||
In `src/bin/api/main.rs` (both native and wasm32 paths), after seeding
|
||||
`admin_auth_code`, also seed `submissions_locked = '0'` using
|
||||
`INSERT OR IGNORE` (use `set_submissions_locked` only when the key is absent,
|
||||
or add a dedicated `seed_submissions_locked` helper).
|
||||
|
||||
## Testing
|
||||
|
||||
Add unit/integration tests in `src/bin/api/handlers/mod.rs` test module
|
||||
or `tests/` covering:
|
||||
- get_submissions_locked returns false by default
|
||||
- set_submissions_locked(true) then get_submissions_locked returns true
|
||||
- update_admin_auth_code with correct current succeeds
|
||||
- update_admin_auth_code with wrong current returns Forbidden
|
||||
|
||||
## Validation
|
||||
|
||||
Run from `quotesdb/`:
|
||||
```
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
## Commit scope
|
||||
`feat(quotesdb): ...`
|
||||
|
||||
## Design reference
|
||||
`docs/plans/2026-03-04-admin-features-design.md`
|
||||
@ -0,0 +1,48 @@
|
||||
+++
|
||||
title = "quotesdb/api: admin moderation endpoints (list reports, delete/hide from report)"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["8a7fba", "77237f"]
|
||||
+++
|
||||
## Summary
|
||||
Add admin endpoints for viewing and acting on reported quotes.
|
||||
|
||||
## Endpoints
|
||||
GET /api/admin/reports?page=N
|
||||
- Returns paginated list of reported quotes (10/page)
|
||||
- Each entry: quote summary + report count + most recent report date
|
||||
- Requires X-Admin-Auth-Code header (admin auth)
|
||||
- Returns 403 on auth mismatch
|
||||
|
||||
GET /api/admin/reports/:quote_id
|
||||
- Returns the full quote + all reports for that quote (id, reason, created_at)
|
||||
- Requires X-Admin-Auth-Code header
|
||||
|
||||
DELETE /api/admin/reports/:quote_id/quote
|
||||
- Deletes the quote (cascades to reports via FK)
|
||||
- Requires X-Admin-Auth-Code header
|
||||
|
||||
POST /api/admin/reports/:quote_id/hide
|
||||
- Sets hidden=1 on the quote
|
||||
- Requires X-Admin-Auth-Code header
|
||||
|
||||
DELETE /api/admin/reports/:quote_id/reports
|
||||
- Clears all reports for a quote (dismiss reports without acting on the quote)
|
||||
- Requires X-Admin-Auth-Code header
|
||||
|
||||
## Notes
|
||||
- Admin auth is validated against the admin_auth_code in the DB (same as existing admin endpoints)
|
||||
- Depends on: 8a7fba (hidden flag), 77237f (reports table)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All endpoints return correct data
|
||||
- [ ] All endpoints require and validate admin auth
|
||||
- [ ] Pagination works for GET /api/admin/reports
|
||||
- [ ] Delete cascades correctly
|
||||
- [ ] Unit tests for each endpoint
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
@ -1,7 +1,43 @@
|
||||
+++
|
||||
title = "Set up api/src/main.rs — Cloudflare Workers entry point and Axum router wiring"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "1f5bb5"]
|
||||
dependencies = ["1f5bb5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement `src/bin/api/main.rs` — the Cloudflare Workers entry point and Axum router wiring. Set up the router with all seven API routes in the correct order (`GET /api/quotes/random` before `GET /api/quotes/:id`), connect the SQLx database pool, and wire in the workers-rs event handler.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Route registration order is critical: `GET /api/quotes/random` must be registered **before** `GET /api/quotes/:id` or the random endpoint will never match.
|
||||
- Provide a `#[cfg(not(target_env = "worker"))]` conditional for running the API as a plain Axum server during local `cargo run`, alongside the workers-rs event macro for Cloudflare deployment.
|
||||
- Database pool initialisation must handle both Turso (local) and D1 (worker) connection strings.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write integration test stubs in `tests/` before wiring up handlers.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): wire Axum router and workers-rs entry point in api main.rs`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,78 @@
|
||||
+++
|
||||
title = "[TRIAGE] 4-word passphrase crate selection for WASM target (no_std/wasm32 constraints)"
|
||||
priority = 9
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
4-word passphrase crate selection: which crate generates 4-word passphrases and compiles for wasm32-unknown-unknown without std thread-local RNG or filesystem access?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **passphrase-wordlist** — small crate, check WASM compatibility.
|
||||
2. **bip39** — BIP-39 mnemonic words, widely available. Returns 12-word phrases by default; can take first 4 words.
|
||||
3. **Custom word list** — embed a static word list in `src/lib.rs` and select 4 random words using `getrandom` with the `js` feature for WASM-compatible randomness.
|
||||
</options>
|
||||
|
||||
<resolution>
|
||||
1. Research the options above and choose the best approach for this project.
|
||||
2. Update ticket 03bb91 (auth_code generator) and `Cargo.toml` (ticket 1f5bb5) with the chosen crate.
|
||||
3. Mark this ticket done with a note on the chosen approach in the body or a comment.
|
||||
</resolution>
|
||||
|
||||
<decision>
|
||||
|
||||
## Chosen: Option 3 — Custom embedded word list + `rand 0.10` + `getrandom 0.4` (wasm_js)
|
||||
|
||||
### Research findings
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| `passphrase-wordlist` | **Does not exist** on crates.io. Eliminated. |
|
||||
| `bip39` | WASM-compatible (8.8M downloads, used in web crypto wallets). Rejected: carries BIP-39 cryptocurrency semantics; word list (2048 entries) is tuned for phonetic distinctness, not general memorability; introduces unnecessary complexity. |
|
||||
| Custom word list | **Chosen.** Minimal deps, full control, idiomatic Rust. |
|
||||
|
||||
### Implementation approach
|
||||
|
||||
**Word list:** EFF Short Word List 1 — 1296 common English words designed for memorable passphrases.
|
||||
Source: `https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt`
|
||||
Generate the Rust array: `curl -s <url> | awk '{print $2}'`
|
||||
|
||||
**RNG:** `rand::rngs::OsRng` from `rand = "0.10"`.
|
||||
- Does NOT use thread-local storage (safe for wasm32)
|
||||
- Uses `getrandom` as its entropy backend
|
||||
- For WASM targets: add `getrandom = { version = "0.4", features = ["wasm_js"] }` in the wasm32 cfg section so Cloudflare Workers (which expose `crypto.getRandomValues()`) can seed the RNG
|
||||
|
||||
**Version note:** `rand 0.10` requires `getrandom ^0.4`. The `wasm_js` feature in `getrandom 0.4` replaces the old `js` feature from `getrandom 0.2`. The latest `uuid 1.21.0` also requires `getrandom ^0.4`, so both deps share one getrandom version in the dependency graph.
|
||||
|
||||
### Cargo.toml changes (update ticket 1f5bb5)
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
rand = "0.10"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues())
|
||||
# Required by both rand (OsRng) and uuid (v4) on wasm32 targets
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
```
|
||||
|
||||
**Correction:** Tickets 7a0d9f and 1f5bb5 previously referenced `getrandom = "0.2", features = ["js"]` — this is outdated. uuid 1.21 and rand 0.10 both require getrandom ^0.4, which renamed the feature to `wasm_js`. Both those tickets have been updated.
|
||||
|
||||
### Updated tickets
|
||||
|
||||
- **03bb91** — updated with full implementation plan (code, word list steps, tests)
|
||||
- **1f5bb5** — corrected getrandom version to 0.4/wasm_js; added rand 0.10 dep
|
||||
- **7a0d9f** — corrected getrandom version to 0.4/wasm_js (was 0.2/js)
|
||||
|
||||
</decision>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — 4word-passphrase-crate-selection-for-wasm-target-nostdwasm32`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,53 @@
|
||||
+++
|
||||
title = "[TRIAGE] NanoID crate WASM compatibility with workers-rs target"
|
||||
priority = 9
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
NanoID crate WASM compatibility: does the chosen nanoid crate compile for wasm32-unknown-unknown with the workers-rs target? Some crates use thread-local RNG which is not available in WASM.
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **nanoid crate** — check if it supports `getrandom` with `js` feature for WASM.
|
||||
2. **uuid v4** — widely compatible, UUIDs are slightly longer than NanoIDs but universally supported.
|
||||
3. **Custom NanoID** — implement NanoID generation using `getrandom` + custom alphabet. ~20 lines of code, no extra dependency.
|
||||
</options>
|
||||
|
||||
<resolution>
|
||||
**RESOLVED 2026-03-02 — Use UUID v4 (`uuid` crate).**
|
||||
|
||||
### Decision: Option 2 — uuid v4
|
||||
|
||||
The `nanoid` crate (v0.4.0) depends on `rand ^0.8`, which uses `thread_rng()` (thread-local RNG).
|
||||
Thread-local storage is unreliable in wasm32-unknown-unknown, and the underlying `getrandom`
|
||||
`wasm_js` feature is explicitly discouraged in libraries by the getrandom maintainers.
|
||||
|
||||
`uuid = { version = "1", features = ["v4", "serde"] }` with `getrandom = { version = "0.2",
|
||||
features = ["js"] }` (wasm32 cfg section only) is the proven, zero-risk approach for
|
||||
Cloudflare Workers. UUID v4 produces 36-char hyphenated IDs — slightly longer than NanoID's 21
|
||||
chars, but negligible in practice and universally supported.
|
||||
|
||||
### Created ticket
|
||||
|
||||
7a0d9f — "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs"
|
||||
- `generate_id()` in `src/lib.rs` returns `uuid::Uuid::new_v4().to_string()`
|
||||
- Cargo.toml adds `uuid` to `[dependencies]` and `getrandom/js` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`
|
||||
- Ticket 05f8ae (PUT handler) and 1f5bb5 (Cargo.toml) updated to reference this approach
|
||||
|
||||
### Updated tickets
|
||||
|
||||
- 05f8ae: "Generate a NanoID for the quote ID" → use `generate_id()` from lib.rs (UUID v4)
|
||||
- 1f5bb5: Add `uuid = { version = "1", features = ["v4", "serde"] }` to [dependencies]; add
|
||||
`getrandom = { version = "0.2", features = ["js"] }` under wasm32 cfg
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — nanoid-crate-wasm-compatibility-with-workersrs-target`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,28 @@
|
||||
+++
|
||||
title = "Document secrets management — Cloudflare API token, account ID, how to supply to OpenTofu and local dev"
|
||||
priority = 6
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "2d1371"]
|
||||
dependencies = ["2d1371"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write documentation in `infra/README.md` or `docs/SECRETS.md` covering:
|
||||
1. What secrets/credentials are required (Cloudflare API token, account ID)
|
||||
2. How to provide them for local OpenTofu runs (environment variables or `.env` file — never commit)
|
||||
3. How to provide them in CI/CD (GitHub Actions secrets or equivalent)
|
||||
4. What permissions the Cloudflare API token needs (Workers, D1, Pages, DNS)
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Do not commit any actual secrets or tokens — document the variable names only.
|
||||
- Cross-reference the `.gitignore` for infra secrets files.
|
||||
</constraints>
|
||||
|
||||
<commit>
|
||||
`docs(quotesdb): document secrets management for Cloudflare credentials`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,39 @@
|
||||
+++
|
||||
title = "Document D1 schema migration workflow — how to apply SQL schema changes to D1 in CI/CD"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "d0da0b"]
|
||||
dependencies = ["d0da0b", "bb1514"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
|
||||
|
||||
Cloudflare D1 uses SQL migrations. Because the Worker runs in the Cloudflare runtime (not a standard server), migrations must be applied via a separate mechanism.
|
||||
|
||||
TRIAGE 5c0c64 resolved: the chosen strategy is **Option 2 — separate wrangler step**. Schema SQL lives at `infra/schema.sql` (ticket bb1514). No `null_resource`, no startup migration from the Workers handler.
|
||||
|
||||
For local dev/tests, `NativeRepository::run_migrations()` (ticket 00aff0) calls `execute_batch` via rusqlite — no manual step needed there.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Document the D1 schema migration workflow in `infra/README.md`:
|
||||
1. The canonical schema file location: `infra/schema.sql`
|
||||
2. How to apply the initial schema SQL to D1 after first `tofu apply`:
|
||||
`wrangler d1 execute quotesdb --file infra/schema.sql --remote`
|
||||
3. How to apply incremental migrations (numbered files under `infra/migrations/`)
|
||||
4. How to apply migrations in CI/CD (two-step: `tofu apply` then `wrangler d1 execute`)
|
||||
5. How local dev/tests work (NativeRepository handles this automatically, no manual step)
|
||||
6. Cross-reference: TRIAGE decisions from 5c0c64 and 580e66
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- TRIAGE 5c0c64 is resolved — the strategy is a separate wrangler step. Document accordingly.
|
||||
- `infra/schema.sql` must exist (ticket bb1514) before writing the exact wrangler command.
|
||||
- D1 resource must be defined (ticket d0da0b) to confirm the database name "quotesdb".
|
||||
- Do NOT document `null_resource` or startup migrations from the Workers handler.
|
||||
</constraints>
|
||||
|
||||
<commit>
|
||||
`docs(quotesdb): document D1 schema migration workflow in infra/README.md`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,24 @@
|
||||
+++
|
||||
title = "Write tests/README.md"
|
||||
priority = 3
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f"]
|
||||
dependencies = ["9b581f"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write `tests/README.md` explaining:
|
||||
1. What the integration test suite covers
|
||||
2. How to run the tests (`cargo test` from the `quotesdb/` directory)
|
||||
3. How the test harness works (temporary SQLite DB, port binding, cleanup)
|
||||
4. Any prerequisites for running tests locally
|
||||
5. License notice
|
||||
</goal>
|
||||
|
||||
<commit>
|
||||
`docs(quotesdb): write integration tests README`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
+++
|
||||
title = "quotesdb/api: reports table and POST /api/quotes/:id/report endpoint"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Summary
|
||||
Create a reports table and a public endpoint for reporting quotes for moderation review.
|
||||
|
||||
## Schema
|
||||
```sql
|
||||
CREATE TABLE reports (
|
||||
id TEXT PRIMARY KEY, -- NanoID
|
||||
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
reason TEXT, -- optional, max 256 chars
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## API Endpoint
|
||||
POST /api/quotes/:id/report
|
||||
- Body: { reason?: string } — reason is optional, max 256 chars
|
||||
- Validates reason length (400 if > 256 chars)
|
||||
- Creates a report record
|
||||
- Returns 201 on success
|
||||
- Returns 404 if quote not found
|
||||
|
||||
## Rate Limiting Note
|
||||
Rate limiting will be handled separately at the Cloudflare layer (see infra ticket 06d304). No application-level rate limiting needed here.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] reports table created in migration
|
||||
- [ ] POST /api/quotes/:id/report works
|
||||
- [ ] reason is optional and validated (max 256 chars)
|
||||
- [ ] 404 on unknown quote_id
|
||||
- [ ] Unit tests cover success, missing quote, reason too long
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
@ -1,7 +1,44 @@
|
||||
+++
|
||||
title = "Test suite: GET /api/ — OpenAPI spec returned as valid JSON with expected structure"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f", "28e7d9"]
|
||||
dependencies = ["9b581f", "28e7d9"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the `GET /api/` test suite in `tests/test_openapi.rs` (or similar). Assert that the endpoint:
|
||||
1. Returns HTTP 200
|
||||
2. Returns `Content-Type: application/json`
|
||||
3. Returns a body that is valid JSON
|
||||
4. The JSON object contains an `openapi` key with value `"3.1.0"` (or `"3.0.x"`)
|
||||
5. The JSON object contains `paths` and `info` keys
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use the shared test harness from ticket 9b581f.
|
||||
- No authentication required for this endpoint.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add GET /api/ test suite — OpenAPI spec endpoint`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
+++
|
||||
title = "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
Resolved from TRIAGE ticket 6f2e18. The `nanoid` crate is not suitable for wasm32-unknown-unknown
|
||||
because it depends on `rand`, which relies on thread-local RNG — unavailable in WASM. The safe,
|
||||
WASM-compatible choice is UUID v4 via the `uuid` crate.
|
||||
|
||||
On the wasm32 target, `uuid`'s `v4` feature depends on `getrandom`, which requires the `wasm_js` feature
|
||||
(renamed from `js` in getrandom 0.2; uuid 1.21+ requires getrandom ^0.4) to source entropy from the
|
||||
Web Crypto API (`crypto.getRandomValues()`). This must be declared as a direct dependency in the
|
||||
application's `Cargo.toml` at the wasm32 cfg section.
|
||||
|
||||
UUID v4 produces 36-character hyphenated strings (e.g. `550e8400-e29b-41d4-a716-446655440000`).
|
||||
The design doc originally specified NanoID (~21 chars); UUID v4 is slightly longer but universally
|
||||
supported and zero-risk on the Workers target. The DB schema comment should be updated accordingly.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Add a `generate_id()` public function to `src/lib.rs` that:
|
||||
- Returns a new UUID v4 as a `String`
|
||||
- Compiles correctly for both the native host target AND `wasm32-unknown-unknown`
|
||||
- Has a rustdoc comment with a doc-example (which also serves as a doctest)
|
||||
</goal>
|
||||
|
||||
<implementation>
|
||||
|
||||
## 1. Cargo.toml changes
|
||||
|
||||
Add `uuid` to the shared (all-targets) dependencies section:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
```
|
||||
|
||||
Add `getrandom` with the `wasm_js` feature under the wasm32 cfg section (so native builds don't pull
|
||||
in wasm-bindgen). **uuid 1.21+ requires getrandom ^0.4**; getrandom 0.4 renamed the `js` feature
|
||||
to `wasm_js`. Also shared with the passphrase generator (ticket 03bb91 / TRIAGE 6ed325):
|
||||
|
||||
```toml
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
```
|
||||
|
||||
## 2. src/lib.rs — generate_id()
|
||||
|
||||
```rust
|
||||
/// Generates a new UUID v4 string for use as a database primary key.
|
||||
///
|
||||
/// Returns a 36-character hyphenated UUID string. Compatible with both
|
||||
/// native and `wasm32-unknown-unknown` targets (uses Web Crypto API via
|
||||
/// `getrandom/wasm_js` on WASM).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let id = quotesdb::generate_id();
|
||||
/// assert_eq!(id.len(), 36);
|
||||
/// assert_eq!(id.chars().filter(|&c| c == '-').count(), 4);
|
||||
/// ```
|
||||
pub fn generate_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Callers
|
||||
|
||||
- `PUT /api/quotes` handler (ticket 05f8ae): call `generate_id()` to produce the new quote's `id`
|
||||
- No other callers at this stage
|
||||
|
||||
## 4. DB schema comment update
|
||||
|
||||
In `docs/plans/2026-02-27-quotesdb-design.md` and `CLAUDE.md` design reference, update the schema
|
||||
comment from:
|
||||
|
||||
```sql
|
||||
id TEXT PRIMARY KEY, -- NanoID (~21 chars)
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```sql
|
||||
id TEXT PRIMARY KEY, -- UUID v4 (36 chars), generated by generate_id()
|
||||
```
|
||||
|
||||
</implementation>
|
||||
|
||||
<constraints>
|
||||
- `generate_id()` must be in `src/lib.rs` (shared code, not bin-specific)
|
||||
- UUID v4 is the only correct choice — do NOT use `nanoid`, `rand::thread_rng`, or any
|
||||
crate that pulls in thread-local RNG primitives for WASM
|
||||
- `getrandom = { version = "0.4", features = ["wasm_js"] }` must be in the wasm32 cfg section only,
|
||||
not in `[dependencies]`, to avoid pulling wasm-bindgen into native builds
|
||||
- Do NOT use getrandom 0.2 or the old `js` feature name — uuid 1.21+ requires getrandom ^0.4
|
||||
- All public items must have rustdoc comments (per project style)
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write a unit test verifying length (36) and hyphen
|
||||
count (4) before implementing.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): add generate_id() using UUID v4 — WASM-compatible ID generation`
|
||||
</commit>
|
||||
@ -0,0 +1,168 @@
|
||||
+++
|
||||
title = "Admin super auth code: delete any quote regardless of per-quote auth"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Feature
|
||||
|
||||
Add an **admin super auth code** — a single global passphrase that can delete (and update) any quote, bypassing the per-quote `auth_code` check. This allows the operator to moderate content without needing the original submitter's code.
|
||||
|
||||
The admin code is:
|
||||
- Generated once on first startup using the same 4-word passphrase generator (`generate_auth_code` in `src/lib.rs`).
|
||||
- Stored in the database in a new `admin_config` table.
|
||||
- Printed prominently to stderr on every startup so the operator can note it.
|
||||
- Never exposed via the API.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Database: new migration
|
||||
|
||||
Add a new migration constant in `src/bin/api/db/migrations.rs`:
|
||||
|
||||
```rust
|
||||
/// Creates the admin_config key/value table for storing global configuration.
|
||||
///
|
||||
/// Stores a single row for the admin auth code under the key `admin_auth_code`.
|
||||
pub const CREATE_ADMIN_CONFIG: &str = "\
|
||||
CREATE TABLE IF NOT EXISTS admin_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)";
|
||||
```
|
||||
|
||||
Run this migration in `QuoteRepository::run_migrations` after the existing migrations. The implementation then seeds the admin auth code if absent (see Part 2).
|
||||
|
||||
Update `infra/schema.sql` to include:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS admin_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Repository trait: `db/mod.rs`
|
||||
|
||||
Add two new methods to `QuoteRepository`:
|
||||
|
||||
```rust
|
||||
/// Retrieve the admin super auth code from `admin_config`.
|
||||
///
|
||||
/// Returns `Ok(None)` if the table is empty (should not happen after migrations).
|
||||
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
|
||||
|
||||
/// Insert the admin auth code into `admin_config` if it is not already set.
|
||||
///
|
||||
/// Called once during startup, after `run_migrations`.
|
||||
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
|
||||
```
|
||||
|
||||
The startup sequence (in `main.rs`) becomes:
|
||||
```rust
|
||||
repo.run_migrations().await?;
|
||||
// Seed admin code on first run
|
||||
if repo.get_admin_auth_code().await?.is_none() {
|
||||
let code = quotesdb::generate_auth_code();
|
||||
repo.seed_admin_auth_code(&code).await?;
|
||||
}
|
||||
// Always print the admin code at startup
|
||||
let admin_code = repo.get_admin_auth_code().await?.unwrap();
|
||||
eprintln!("╔══════════════════════════════════════════════╗");
|
||||
eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}║");
|
||||
eprintln!("╚══════════════════════════════════════════════╝");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Native implementation: `db/native.rs`
|
||||
|
||||
Implement `get_admin_auth_code` and `seed_admin_auth_code` using rusqlite.
|
||||
|
||||
**Extend `delete_quote`** to accept the admin code as a fallback:
|
||||
|
||||
```rust
|
||||
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
|
||||
// ... existing logic ...
|
||||
// Before returning Forbidden, check admin auth code
|
||||
let admin_code = self.get_admin_auth_code().await?;
|
||||
if Some(auth_code) == admin_code.as_deref() {
|
||||
// Admin code matches — delete unconditionally
|
||||
// ... execute DELETE without checking quotes.auth_code ...
|
||||
return Ok(DeleteResult::Deleted);
|
||||
}
|
||||
Ok(DeleteResult::Forbidden)
|
||||
}
|
||||
```
|
||||
|
||||
Similarly extend `update_quote` to allow admin override.
|
||||
|
||||
The cleanest approach is to refactor `delete_quote` and `update_quote` to first attempt the per-quote auth check, and if it fails, check against the admin code.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — D1 implementation: `db/d1.rs`
|
||||
|
||||
Apply the same changes as Part 3 for the WASM/Cloudflare D1 path.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — API startup: `src/bin/api/main.rs`
|
||||
|
||||
Update the startup sequence as shown in Part 2. The admin code print must be clearly visible in logs.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Mock repo in tests: `handlers/mod.rs`
|
||||
|
||||
Add stub implementations of `get_admin_auth_code` and `seed_admin_auth_code` to `MockRepo`:
|
||||
```rust
|
||||
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
|
||||
Ok(None) // no admin code in tests by default
|
||||
}
|
||||
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design notes
|
||||
|
||||
- The admin code is **never returned by any API endpoint** — there is no way to discover it via HTTP.
|
||||
- The admin code is stored plaintext in `admin_config`, consistent with per-quote auth codes. This is acceptable given the stated security model (simple passphrase, no user accounts).
|
||||
- Only `delete_quote` and `update_quote` check the admin code. Read operations are unaffected.
|
||||
- The admin code is **not rotatable** via the API — an operator who needs to rotate it must manually update the database row.
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/bin/api/db/migrations.rs` — `CREATE_ADMIN_CONFIG` constant
|
||||
- `src/bin/api/db/mod.rs` — two new trait methods + updated docstrings for `delete_quote`/`update_quote`
|
||||
- `src/bin/api/db/native.rs` — implementations + admin fallback logic
|
||||
- `src/bin/api/db/d1.rs` — same for D1
|
||||
- `src/bin/api/handlers/mod.rs` — `MockRepo` stubs
|
||||
- `src/bin/api/main.rs` — seed + print admin code on startup
|
||||
- `infra/schema.sql` — `admin_config` table
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
# From quotesdb/ root
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
Manual test:
|
||||
1. Start the server: `cargo run`
|
||||
2. Observe admin code printed to stderr.
|
||||
3. Create a quote: `curl -X PUT http://localhost:3000/api/quotes -H 'Content-Type: application/json' -d '{"text":"Test","author":"A","tags":[]}'`
|
||||
4. Try deleting with wrong code: should return 403.
|
||||
5. Try deleting with admin code: should return 204.
|
||||
6. Restart the server: same admin code should be printed (not regenerated).
|
||||
|
||||
## Commit scope
|
||||
|
||||
`feat(quotesdb): admin super auth code for quote moderation`
|
||||
@ -1,7 +1,51 @@
|
||||
+++
|
||||
title = "Implement GET /api/quotes — paginated list with author filter (case-insensitive) and tag filter"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d", "d792e2", "175382"]
|
||||
dependencies = ["a5049d", "d792e2", "175382"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
`GET /api/quotes` returns a paginated list of quotes. Query parameters:
|
||||
- `page` (default 1): page number (1-indexed)
|
||||
- `author`: case-insensitive author filter (partial match acceptable)
|
||||
- `tag`: filter to quotes that have this tag
|
||||
|
||||
Response shape: `{"quotes": [...], "page": N, "total_pages": N, "total_count": N}`. Page size is 10.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `GET /api/quotes` handler with pagination, optional author filter, and optional tag filter. Each quote in the response must include its tags (fetched from `quote_tags`). Return the pagination metadata in the response envelope.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Author filter should be case-insensitive (`LIKE lower(?)` or `COLLATE NOCASE`).
|
||||
- Tag filter requires a JOIN with `quote_tags` — ensure the query doesn't return duplicate quotes when a quote has multiple tags.
|
||||
- Out-of-range page numbers should return an empty `quotes` array, not a 404.
|
||||
- Tags must be fetched for each returned quote — either via a JOIN or N+1 queries (N+1 is acceptable for now given small dataset size).
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write tests for: page=1 default, page=2 with 15 quotes, author filter, tag filter, combined filters.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement GET /api/quotes — paginated list with author and tag filters`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
+++
|
||||
title = "Add build.rs — convert api/openapi.yaml to JSON at compile time for Workers embed"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
Resolved from TRIAGE ticket 2ec8b1. The `GET /api/` endpoint must serve the OpenAPI spec as JSON.
|
||||
|
||||
The three strategies were:
|
||||
1. Compile-time embed (chosen)
|
||||
2. Runtime load from filesystem — impossible on Cloudflare Workers (no filesystem)
|
||||
3. utoipa programmatic generation — significant complexity; spec already exists and is complete
|
||||
|
||||
The chosen approach: a `build.rs` script reads `api/openapi.yaml`, parses it to a
|
||||
`serde_json::Value`, serialises it as compact JSON, and writes the result to
|
||||
`$OUT_DIR/openapi.json`. The `GET /api/` handler then serves this via:
|
||||
|
||||
```rust
|
||||
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
|
||||
```
|
||||
|
||||
This means:
|
||||
- `serde_yaml` ships only as a `[build-dependencies]` entry — it never enters the Workers binary.
|
||||
- The handler is a zero-overhead static string response with no runtime parsing.
|
||||
- `cargo:rerun-if-changed=api/openapi.yaml` ensures the conversion re-runs whenever the spec
|
||||
is edited — no manual JSON regeneration step needed.
|
||||
- `api/openapi.yaml` remains the single source of truth; the JSON output is ephemeral (in
|
||||
`$OUT_DIR`, not committed to the repository).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
1. Create `build.rs` at the `quotesdb/` project root containing:
|
||||
|
||||
```rust
|
||||
use std::{env, fs, path::Path};
|
||||
|
||||
fn main() {
|
||||
// Re-run this script whenever the OpenAPI spec changes.
|
||||
println!("cargo:rerun-if-changed=api/openapi.yaml");
|
||||
|
||||
let yaml =
|
||||
fs::read_to_string("api/openapi.yaml").expect("api/openapi.yaml not found");
|
||||
|
||||
// Parse YAML to a generic JSON value, then re-serialise as compact JSON.
|
||||
// serde_yaml is a build-only dependency — it does not appear in the final binary.
|
||||
let value: serde_json::Value =
|
||||
serde_yaml::from_str(&yaml).expect("api/openapi.yaml is invalid YAML");
|
||||
let json = serde_json::to_string(&value).expect("JSON serialisation failed");
|
||||
|
||||
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
|
||||
let out_path = Path::new(&out_dir).join("openapi.json");
|
||||
fs::write(&out_path, json).expect("failed to write openapi.json");
|
||||
}
|
||||
```
|
||||
|
||||
2. Add the following to `Cargo.toml` (ticket 1f5bb5 should also include this):
|
||||
|
||||
```toml
|
||||
[build-dependencies]
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
```
|
||||
|
||||
3. Verify the build succeeds and `$OUT_DIR/openapi.json` is produced:
|
||||
|
||||
```sh
|
||||
cargo check
|
||||
# $OUT_DIR is typically target/debug/build/quotesdb-*/out/openapi.json
|
||||
```
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- `serde_yaml` must be a `[build-dependencies]` entry only — NOT in `[dependencies]`.
|
||||
Adding it to `[dependencies]` would bloat the Workers WASM binary.
|
||||
- Do NOT commit `$OUT_DIR/openapi.json` — it is generated automatically at build time.
|
||||
- The `build.rs` file lives at the crate root (same level as `Cargo.toml`), not in `src/`.
|
||||
- `api/openapi.yaml` is the source of truth; do not create or commit an `api/openapi.json`.
|
||||
</constraints>
|
||||
|
||||
<downstream>
|
||||
Ticket 28e7d9 (GET /api/ handler) depends on this ticket. The handler uses
|
||||
`include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))` to serve the spec — see 28e7d9
|
||||
for the Axum handler implementation.
|
||||
</downstream>
|
||||
|
||||
<validation>
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): add build.rs to convert api/openapi.yaml to JSON at compile time`
|
||||
</commit>
|
||||
|
||||
<domain>quotesdb/api</domain>
|
||||
@ -1,7 +1,43 @@
|
||||
+++
|
||||
title = "Test suite: tag operations — create with tags, list by tag filter, update replaces all tags"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f", "175382"]
|
||||
dependencies = ["9b581f", "175382"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the tag operations test suite in `tests/test_tags.rs` (or similar). Test cases:
|
||||
1. Create quote with tags — verify tags appear in the response
|
||||
2. List quotes filtered by tag — `?tag=motivation` returns only tagged quotes
|
||||
3. Update quote replaces all tags — old tags gone, new tags present
|
||||
4. Delete quote cascades — no orphaned rows in `quote_tags`
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use the shared test harness from ticket 9b581f.
|
||||
- Seed quotes with distinct tag sets to avoid test interference.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add tag operations test suite`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
+++
|
||||
title = "quotesdb/api: hidden flag for quotes (schema migration + endpoints)"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Summary
|
||||
Add a boolean hidden flag to quotes. Hidden quotes are excluded from listing endpoints and require direct URL access. Changing a quote from hidden to public requires the auth code.
|
||||
|
||||
## Schema Migration
|
||||
Add column to quotes table:
|
||||
```sql
|
||||
ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
## API Changes
|
||||
- GET /api/quotes — filter out hidden=1 quotes by default
|
||||
- GET /api/quotes/random — exclude hidden quotes
|
||||
- GET /api/quotes/:id — return hidden quotes (direct access allowed)
|
||||
- PUT /api/quotes — new quotes default to hidden=0 (not hidden)
|
||||
- POST /api/quotes/:id — allow toggling hidden field; requires X-Auth-Code header
|
||||
- Changing hidden=1 → hidden=0 (unhide) requires valid auth code
|
||||
- Changing hidden=0 → hidden=1 (hide) also requires valid auth code
|
||||
- The quote response body should include the hidden field
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Schema migration applied
|
||||
- [ ] Listing endpoints exclude hidden quotes
|
||||
- [ ] Direct quote access (/api/quotes/:id) works for hidden quotes
|
||||
- [ ] Toggle hidden requires valid X-Auth-Code (403 on mismatch)
|
||||
- [ ] All existing tests pass
|
||||
|
||||
## Validation
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
@ -1,7 +1,44 @@
|
||||
+++
|
||||
title = "Test suite: DELETE /api/quotes/:id — valid auth 204 no body, wrong auth 403, not found 404, cascade deletes tags"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f", "b20b5a"]
|
||||
dependencies = ["9b581f", "b20b5a"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the `DELETE /api/quotes/:id` test suite in `tests/test_delete_quote.rs` (or similar). Test cases:
|
||||
1. Valid auth — 204 No Content, no response body
|
||||
2. Wrong auth code — 403 Forbidden
|
||||
3. Not found ID — 404 Not Found
|
||||
4. Cascade deletes tags — verify `GET /api/quotes/:id` returns 404 after deletion and tags are gone
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use the shared test harness from ticket 9b581f.
|
||||
- Create a quote with tags before each test, use its auth_code for valid-auth tests.
|
||||
- After successful delete, verify the quote is no longer retrievable.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add DELETE /api/quotes/:id test suite`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,51 @@
|
||||
+++
|
||||
title = "Set up ui/Cargo.toml with Yew/Wasm dependencies (yew, yew-router, gloo, wasm-bindgen, serde, etc.)"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "166996"]
|
||||
dependencies = ["166996"]
|
||||
+++
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
TRIAGE resolved (ticket 166996): use yew = "0.22", yew-router = "0.19", wasm-bindgen = "0.2" (compatible with wasm-bindgen-cli 0.2.108 in the Nix dev shell).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Add UI-side Yew/Wasm dependencies to `Cargo.toml` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. Use the following pinned versions:
|
||||
|
||||
- `yew = "0.22"` (latest stable: 0.22.1)
|
||||
- `yew-router = "0.19"` (latest stable: 0.19.0, requires yew ^0.22.0)
|
||||
- `gloo` — timers and fetch utilities for Yew
|
||||
- `wasm-bindgen = "0.2"` (compatible with nix-shell wasm-bindgen-cli 0.2.108)
|
||||
- `web-sys` — browser API bindings
|
||||
- `serde` with `derive` feature
|
||||
- `serde_json`
|
||||
- `wasm-bindgen-futures` — for async fetch in Wasm
|
||||
|
||||
Also add `serde` and `serde_json` to `[dependencies]` (non-target-scoped) so shared lib types can use derive macros on both targets.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- All UI-only dependencies must be scoped to the wasm32 target — they must not appear in host builds.
|
||||
- `wasm-bindgen` version must match the `wasm-bindgen-cli` version in the Nix dev shell (currently 0.2.108).
|
||||
- `serde` and `serde_json` are needed on both targets for shared types — add to `[dependencies]` not the wasm target section.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` — run `trunk build` to confirm WASM compilation succeeds.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): set up ui Cargo dependencies for Yew/Wasm`
|
||||
</commit>
|
||||
@ -1,7 +1,46 @@
|
||||
+++
|
||||
title = "Test suite: GET /api/quotes — pagination (page=1, page=N, out-of-range), author filter, tag filter, no results"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f", "886bfd"]
|
||||
dependencies = ["9b581f", "886bfd"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the `GET /api/quotes` test suite in `tests/test_list_quotes.rs` (or similar). Test cases:
|
||||
1. Pagination — page=1 with 15 quotes returns 10, page=2 returns 5; verify total_count, total_pages
|
||||
2. Out-of-range page — returns empty `quotes` array, not 404
|
||||
3. Author filter — `?author=X` returns only quotes by that author (case-insensitive)
|
||||
4. Tag filter — `?tag=X` returns only quotes with that tag
|
||||
5. Combined filters — author + tag
|
||||
6. No results — filters that match nothing return empty array with total_count=0
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use the shared test harness from ticket 9b581f.
|
||||
- Seed multiple quotes before running filter tests.
|
||||
- Page size is 10 — tests that rely on pagination must seed at least 11 quotes.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add GET /api/quotes test suite — pagination and filters`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,120 @@
|
||||
+++
|
||||
title = "Implement test server harness — spawn quotesdb-api with temp SQLite DB, return base URL"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "5f5ba0", "2ab7a8", "fba598"]
|
||||
dependencies = ["5f5ba0", "2ab7a8", "fba598"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment.
|
||||
|
||||
Architecture decided in triage:
|
||||
- 2ab7a8: Server is spawned as a tokio task using the native Axum path (cfg-split, no workers-rs on host)
|
||||
- fba598: Isolation strategy is **per-test temp SQLite file** via `tempfile` crate (transaction rollback cannot intercept server-side pool commits; in-memory SQLite is incompatible with multi-connection SQLx pools)
|
||||
- 0d84fa: HTTP client for tests is `reqwest` with `tokio::test`
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement `tests/helpers.rs` providing a `spawn_test_server()` async function that:
|
||||
1. Creates a temporary SQLite file via `tempfile::TempDir`
|
||||
2. Opens a `SqlitePool` connected to that file
|
||||
3. Runs migrations via `sqlx::migrate!()`
|
||||
4. Builds the Axum router via `build_router(repo)` (same router used by the API binary)
|
||||
5. Binds to a random port with `TcpListener::bind("127.0.0.1:0")`
|
||||
6. Spawns the server with `tokio::spawn(axum::serve(...))`
|
||||
7. Returns a `TestContext` that holds the `TempDir` (RAII cleanup), base URL, and task handle
|
||||
</goal>
|
||||
|
||||
<implementation>
|
||||
```rust
|
||||
// tests/helpers.rs
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct TestContext {
|
||||
_db_dir: TempDir, // deleted on drop
|
||||
pub base_url: String,
|
||||
_server: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub async fn spawn_test_server() -> TestContext {
|
||||
let db_dir = TempDir::new().expect("temp dir");
|
||||
let db_path = db_dir.path().join("test.sqlite");
|
||||
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
|
||||
|
||||
let pool = SqlitePool::connect(&db_url).await.expect("pool");
|
||||
sqlx::migrate!("./migrations").run(&pool).await.expect("migrations");
|
||||
|
||||
let repo = Arc::new(NativeRepository::new(pool));
|
||||
let app = build_router(repo);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
TestContext {
|
||||
_db_dir: db_dir,
|
||||
base_url: format!("http://127.0.0.1:{port}"),
|
||||
_server: server,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in a test:
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_create_quote() {
|
||||
let ctx = spawn_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.put(format!("{}/api/quotes", ctx.base_url))
|
||||
.json(&serde_json::json!({"text": "hello", "author": "world"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201);
|
||||
}
|
||||
```
|
||||
</implementation>
|
||||
|
||||
<constraints>
|
||||
- `build_router` and `NativeRepository` must be pub-accessible from the `quotesdb` crate (may require re-exports in `src/lib.rs`).
|
||||
- `sqlx::migrate!()` macro path is relative to the crate root — migrations must be in `migrations/` at the crate root.
|
||||
- Each test gets a unique `TempDir`, so parallel test execution (`cargo test`) is safe.
|
||||
- Do not set `--test-threads=1`; parallel execution must work.
|
||||
- The `_server` handle is intentionally leaked (tokio runtime drops it when the test ends).
|
||||
</constraints>
|
||||
|
||||
<dependencies-needed>
|
||||
In `[dev-dependencies]` (ticket 5f5ba0):
|
||||
- `tempfile = "3"`
|
||||
- `reqwest = { version = "0.12", features = ["json"] }`
|
||||
- `tokio = { version = "1", features = ["full"] }`
|
||||
- `serde_json = "1"`
|
||||
</dependencies-needed>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — the harness is itself tested by running `cargo test`.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): implement test server harness with per-test temp SQLite DB`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
+++
|
||||
title = "Create .env.example documenting DATABASE_URL and all local dev environment variables"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["33ed29"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
TRIAGE 33ed29 resolved the local dev database strategy: rusqlite with a local SQLite file.
|
||||
The only environment variable required for local development is `DATABASE_URL` (optional — defaults
|
||||
to `./quotesdb.sqlite`). No Turso, no wrangler, no Cloudflare account needed locally.
|
||||
|
||||
A `.env.example` file in the project root serves as self-documenting reference for contributors.
|
||||
The `.env` file itself is gitignored (never committed). `.env.example` is committed and documents
|
||||
all variables with their defaults and a brief description.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Create `quotesdb/.env.example` with the following content:
|
||||
|
||||
```sh
|
||||
# quotesdb local development environment variables
|
||||
# Copy to .env and customise. The .env file is gitignored and must never be committed.
|
||||
#
|
||||
# All variables below have sensible defaults for local development and are OPTIONAL.
|
||||
|
||||
# Path to the local SQLite database file used by `cargo run` (native API server).
|
||||
# The file is created automatically on first run; migrations run on startup.
|
||||
# In production this variable is unused — the Workers runtime uses the D1 binding.
|
||||
DATABASE_URL=./quotesdb.sqlite
|
||||
```
|
||||
|
||||
Also ensure `.gitignore` in the `quotesdb/` root has an entry for `.env`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
```
|
||||
</goal>
|
||||
|
||||
<decisions-reflected>
|
||||
- TRIAGE 33ed29: rusqlite + local SQLite file. `DATABASE_URL` is the only required env var.
|
||||
- No Cloudflare account, no wrangler, no Turso credentials needed for local dev.
|
||||
</decisions-reflected>
|
||||
|
||||
<constraints>
|
||||
- `.env.example` must be committed to the repo. `.env` must be gitignored.
|
||||
- Only document variables that are actually used by the codebase (see ticket 6e829e / 00aff0 for where DATABASE_URL is read).
|
||||
- Do not add placeholder values for production secrets — `.env.example` is for local dev only.
|
||||
- If production-only secrets (e.g., Cloudflare API tokens for infra) are identified later, add them in a separate PR with appropriate comments.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
Verify `.env.example` is tracked and `.env` is gitignored:
|
||||
|
||||
```sh
|
||||
git status # .env.example should appear as a new untracked file
|
||||
echo "test" > .env
|
||||
git status # .env must NOT appear (should be ignored)
|
||||
rm .env
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): add .env.example documenting DATABASE_URL for local dev`
|
||||
</commit>
|
||||
@ -0,0 +1,46 @@
|
||||
+++
|
||||
title = "Home page: show friendly empty state when no quotes in database"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "bug"
|
||||
dependencies = []
|
||||
+++
|
||||
## Bug
|
||||
|
||||
When the database is empty, `GET /api/quotes/random` returns a 404 response. The home page (`src/bin/ui/pages/home.rs`) currently treats all errors (including 404) the same way — it sets the `error` state and displays it via `<ErrorDisplay>`, which results in something like a raw JSON error message being shown to the user.
|
||||
|
||||
## Expected behaviour
|
||||
|
||||
When the API returns a 404 on `/api/quotes/random`, the home page should display a friendly empty-state message instead of a generic error:
|
||||
|
||||
> "Nothing here yet. Submit a quote!"
|
||||
|
||||
The message should include a link to `/submit`.
|
||||
|
||||
## How to fix
|
||||
|
||||
In `src/bin/ui/pages/home.rs`, in the `use_effect_with` block, inspect the `Err` value. The API client returns `ApiError::Server { status, .. }` for HTTP error codes. When `status == 404`, set a dedicated "empty" state (or detect it from the error) and render the friendly message instead of `<ErrorDisplay>`.
|
||||
|
||||
Relevant code: `src/bin/ui/pages/home.rs` — the `use_effect_with` error branch and the HTML render block.
|
||||
|
||||
## File
|
||||
|
||||
- `src/bin/ui/pages/home.rs`
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
# From quotesdb/ root
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
Also manually test with an empty database:
|
||||
```sh
|
||||
rm -f quotesdb.sqlite
|
||||
cargo run &
|
||||
# navigate to http://localhost:3000 — should show the friendly message, not a raw error
|
||||
```
|
||||
|
||||
## Commit scope
|
||||
|
||||
`fix(quotesdb): home page empty state`
|
||||
@ -0,0 +1,64 @@
|
||||
+++
|
||||
title = "Add `_redirects` SPA fallback — create file and include in Trunk build via copy-file"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
Resolved from TRIAGE ticket e2bd9b. Yew uses client-side routing (BrowserRouter), so a direct
|
||||
URL such as `/browse` or `/quotes/abc123` will 404 on Cloudflare Pages unless a fallback is
|
||||
configured. The chosen approach is a `_redirects` file with `/* /index.html 200`, which instructs
|
||||
Cloudflare Pages to serve `index.html` for any path that does not match a static asset — without
|
||||
changing the URL in the browser (HTTP 200 proxy, not a redirect).
|
||||
|
||||
This file must be present in the `dist/` output directory that `wrangler pages deploy` uploads.
|
||||
Trunk handles this via its `copy-file` asset type: adding a `<link data-trunk rel="copy-file"
|
||||
href="_redirects"/>` line to `index.html` causes Trunk to copy the file verbatim into `dist/`
|
||||
on every build.
|
||||
|
||||
The API Worker claims `/api/*` at the Cloudflare routing level before Pages processes the request,
|
||||
so the `/* /index.html 200` catch-all does not interfere with the API.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
1. Create `_redirects` at the `quotesdb/` project root (next to `index.html`) containing exactly:
|
||||
|
||||
```
|
||||
/* /index.html 200
|
||||
```
|
||||
|
||||
2. Add the following line to `index.html` inside `<head>`, alongside the other `data-trunk` links:
|
||||
|
||||
```html
|
||||
<link data-trunk rel="copy-file" href="_redirects"/>
|
||||
```
|
||||
|
||||
3. Run `trunk build` and verify that `dist/_redirects` exists with the correct single-line content.
|
||||
|
||||
4. Commit with:
|
||||
```
|
||||
chore(quotesdb): add _redirects SPA fallback for Cloudflare Pages routing
|
||||
```
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- The `_redirects` file must live at the project root (same level as `index.html` and `Trunk.toml`),
|
||||
not inside `src/` or a `static/` subdirectory.
|
||||
- The line must use a 200 (proxy) code, not 301 or 302 — 200 preserves the URL in the browser,
|
||||
which is required for client-side routing to work correctly.
|
||||
- Do NOT add `/* /index.html 200` to the `_headers` file — headers do not fix routing.
|
||||
- This ticket is scoped to file creation and Trunk build verification only. The CI/CD deploy
|
||||
workflow is handled separately in ticket 5137d7.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
```sh
|
||||
trunk build
|
||||
ls dist/_redirects # must exist
|
||||
cat dist/_redirects # must print: /* /index.html 200
|
||||
```
|
||||
</validation>
|
||||
|
||||
<domain>quotesdb/ui</domain>
|
||||
@ -1,7 +1,74 @@
|
||||
+++
|
||||
title = "Define Cloudflare Workers script resource — WASM artifact, D1 binding, environment variables"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "2d1371", "d0da0b", "07cafb", "efee79"]
|
||||
dependencies = ["2d1371", "d0da0b", "07cafb", "efee79"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider.
|
||||
|
||||
Trages resolved:
|
||||
- 07cafb: D1 binding — use `cloudflare_d1_database.db.id` directly; OpenTofu dependency graph handles ordering. No two-phase apply or data source needed.
|
||||
- efee79: Correct resource name — `cloudflare_workers_script` (plural, confirmed from provider v4 source).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Define the Cloudflare Workers script resource in `infra/worker.tf`.
|
||||
|
||||
Every block must have a comment.
|
||||
</goal>
|
||||
|
||||
<implementation>
|
||||
```hcl
|
||||
# infra/worker.tf
|
||||
|
||||
# Cloudflare Workers script for the quotesdb API.
|
||||
# Compiled from the `api` binary targeting wasm32-unknown-unknown.
|
||||
# The Wasm artifact must be built before running `tofu apply`:
|
||||
# cargo build --release --target wasm32-unknown-unknown --bin api
|
||||
resource "cloudflare_workers_script" "api" {
|
||||
account_id = var.cloudflare_account_id
|
||||
|
||||
# Script name used in Cloudflare dashboard and for routing.
|
||||
name = "quotesdb-api"
|
||||
|
||||
# Compiled Wasm binary content, base64-encoded.
|
||||
# Path is relative to the infra/ directory.
|
||||
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
|
||||
|
||||
# D1 database binding — referenced in workers-rs code as `env.DB`.
|
||||
# `database_id` is resolved at apply time from the D1 resource output.
|
||||
# OpenTofu automatically creates the D1 database before this script
|
||||
# because of the attribute reference below (no explicit depends_on needed).
|
||||
d1_database_binding {
|
||||
name = "DB"
|
||||
database_id = cloudflare_d1_database.db.id
|
||||
}
|
||||
|
||||
# Workers runtime compatibility date.
|
||||
compatibility_date = "2024-09-23"
|
||||
}
|
||||
```
|
||||
</implementation>
|
||||
|
||||
<notes>
|
||||
- The `content` attribute expects base64-encoded script bytes. For a Wasm Worker, this is the raw compiled Wasm file, not a JS bundle.
|
||||
- The binding `name = "DB"` must match exactly what the workers-rs API code uses (`env.DB`). Verify this in `src/bin/api/main.rs`.
|
||||
- `(known after apply)` for `database_id` in `tofu plan` is expected and correct — OpenTofu resolves it at apply time.
|
||||
- The Wasm binary must be compiled before `tofu apply`. This is handled by the Gitea Actions CI/CD workflow (ticket to be created; also see ticket 5137d7 for the UI workflow pattern).
|
||||
</notes>
|
||||
|
||||
<validation>
|
||||
Run from the `infra/` directory:
|
||||
|
||||
```sh
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): define Cloudflare Workers script resource in OpenTofu`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,70 @@
|
||||
+++
|
||||
title = "Implement database connection module and SQLx migrations (quotes + quote_tags schema)"
|
||||
priority = 8
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "1f5bb5", "e8a330", "580e66", "33ed29"]
|
||||
dependencies = ["1f5bb5", "580e66", "33ed29"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
The database schema consists of two tables:
|
||||
- `quotes` — stores id (NanoID), text, author, source, date, auth_code, created_at, updated_at
|
||||
- `quote_tags` — join table for quote-to-tag relationships with cascade delete
|
||||
</context>
|
||||
|
||||
<superseded-by>
|
||||
**This ticket's SQLx-based goal has been superseded by ticket 00aff0.**
|
||||
|
||||
TRIAGE e8a330 concluded that SQLx is incompatible with workers-rs/D1. The new approach uses:
|
||||
- workers-rs `D1Database` bindings for the WASM/production target
|
||||
- `rusqlite` + `tokio-rusqlite` for the native/test target
|
||||
- A `QuoteRepository` async trait as the shared interface
|
||||
- `cfg(target_arch = "wasm32")` for compile-time target selection
|
||||
|
||||
See ticket 00aff0 for the full implementation plan.
|
||||
|
||||
This ticket remains open as tracking context but its implementation is covered by 00aff0.
|
||||
</superseded-by>
|
||||
|
||||
<goal>
|
||||
~~Implement `src/bin/api/db.rs` (or equivalent module) providing:~~
|
||||
~~1. A database connection pool constructor (Turso/SQLite locally, D1 in production)~~
|
||||
~~2. SQLx migrations that create the `quotes` and `quote_tags` tables if they don't exist~~
|
||||
~~3. Re-export the pool type for use by handlers~~
|
||||
|
||||
**Updated goal (see ticket 00aff0):** Implement `src/bin/api/db/` module with:
|
||||
1. `QuoteRepository` trait in `db/mod.rs`
|
||||
2. `D1Repository` in `db/d1.rs` (`#[cfg(target_arch = "wasm32")]`)
|
||||
3. `NativeRepository` in `db/native.rs` (`#[cfg(not(target_arch = "wasm32"))]`)
|
||||
4. SQL migration strings in `db/migrations.rs`
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- TRIAGE 580e66 resolved (same decision as 5c0c64): D1 production schema is applied via `wrangler d1 execute` (separate CI step). The Workers fetch handler does NOT run migrations. Native `main()` calls `repo.run_migrations()` via rusqlite on startup.
|
||||
- Schema must exactly match the design: NanoID primary key, `auth_code` stored plaintext, optional `source` and `date` fields, cascade delete on `quote_tags`.
|
||||
- SQLx is NOT used. Use workers-rs D1 bindings (wasm32) and rusqlite (native). See 00aff0.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write a test that verifies migration runs and tables exist.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement database connection module and SQLx migrations`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
+++
|
||||
title = "quotesdb/api: enforce submission lock on PUT /api/quotes"
|
||||
priority = 6
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = ["69a2c5"]
|
||||
+++
|
||||
## Enforce submission lock on PUT /api/quotes
|
||||
|
||||
Modify the quote-creation handler to check the submissions lock before accepting a new quote. If locked, return `423 Locked` with a JSON error body.
|
||||
|
||||
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` to the `QuoteRepository` trait. Complete 35685a first.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/bin/api/handlers/mod.rs` — modify the `create_quote` handler (the handler for `PUT /api/quotes`) to add a lock pre-flight check
|
||||
|
||||
No new DB methods, routes, or types are needed.
|
||||
|
||||
---
|
||||
|
||||
## Change to create_quote handler
|
||||
|
||||
At the top of the handler body, before any other logic, add:
|
||||
|
||||
```rust
|
||||
// Pre-flight: reject new submissions when locked.
|
||||
match repo.get_submissions_locked().await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::LOCKED,
|
||||
Json(json!({ "error": "submissions are closed" })),
|
||||
).into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
```
|
||||
|
||||
HTTP 423 is `StatusCode::LOCKED` in axum/hyper.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- `PUT /api/quotes` while `submissions_locked = false` → `201` (existing behaviour unchanged)
|
||||
- `PUT /api/quotes` while `submissions_locked = true` → `423` with body `{ "error": "submissions are closed" }`
|
||||
- After unlocking (`submissions_locked = false`), `PUT /api/quotes` succeeds again → `201`
|
||||
|
||||
Use the in-memory/mock repo already used by other handler tests; expose a method to toggle the lock state on the test double.
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat(quotesdb): enforce submission lock on PUT /api/quotes
|
||||
```
|
||||
@ -0,0 +1,144 @@
|
||||
+++
|
||||
title = "Cloudflare Turnstile CAPTCHA on quote submission"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Feature
|
||||
|
||||
Add Cloudflare Turnstile CAPTCHA to protect the `PUT /api/quotes` endpoint (and the submit form in the UI) from bots and spam. This is a three-part change: infra, API, and UI.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Infra: Turnstile widget resource
|
||||
|
||||
Create `infra/turnstile.tf` with a `cloudflare_turnstile_widget` resource.
|
||||
|
||||
```hcl
|
||||
# Turnstile CAPTCHA widget protecting the quote submission form.
|
||||
# Provides a site_key (public, embedded in the UI) and secret_key
|
||||
# (private, used by the API to verify tokens server-side).
|
||||
resource "cloudflare_turnstile_widget" "submit" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "quotesdb-submit"
|
||||
# "managed" mode: Turnstile decides whether to show a visible challenge.
|
||||
mode = "managed"
|
||||
# Restrict the widget to the production domain.
|
||||
domains = [var.domain]
|
||||
}
|
||||
|
||||
output "turnstile_site_key" {
|
||||
description = "Turnstile site key — safe to embed in the UI."
|
||||
value = cloudflare_turnstile_widget.submit.id
|
||||
}
|
||||
|
||||
output "turnstile_secret_key" {
|
||||
description = "Turnstile secret key — inject into Workers via wrangler secret."
|
||||
value = cloudflare_turnstile_widget.submit.secret
|
||||
sensitive = true
|
||||
}
|
||||
```
|
||||
|
||||
The `var.domain` variable should already exist or be added alongside `var.cloudflare_account_id` in `providers.tf` / `variables.tf`.
|
||||
|
||||
After `tofu apply`, inject the secret into the Worker:
|
||||
```sh
|
||||
wrangler secret put TURNSTILE_SECRET_KEY
|
||||
# paste the value from `tofu output -raw turnstile_secret_key`
|
||||
```
|
||||
|
||||
**Validate:**
|
||||
```sh
|
||||
# From infra/ directory
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — API: Verify Turnstile token in create handler
|
||||
|
||||
The API must verify the Turnstile token before creating a quote.
|
||||
|
||||
### Changes
|
||||
|
||||
**`src/lib.rs` (or a new `turnstile` module in `src/bin/api/`):**
|
||||
|
||||
Add a `verify_turnstile(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool, Error>` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
|
||||
|
||||
**`quotesdb::CreateQuoteInput` in `src/lib.rs`:**
|
||||
|
||||
Add a `cf_turnstile_token: Option<String>` field. It is optional so that local/test environments can skip verification when no secret is configured.
|
||||
|
||||
**`src/bin/api/handlers/mod.rs` — `create_handler`:**
|
||||
|
||||
Before calling `repo.create_quote(input)`, check:
|
||||
1. Read `TURNSTILE_SECRET_KEY` from the environment.
|
||||
2. If the env var is set:
|
||||
- Extract `cf_turnstile_token` from the request body.
|
||||
- If the token is absent, return `400 Bad Request`.
|
||||
- Call `verify_turnstile(token, secret, remote_ip)`.
|
||||
- If verification fails, return `403 Forbidden` with `{"error": "CAPTCHA verification failed"}`.
|
||||
3. If the env var is absent (local dev), skip verification.
|
||||
|
||||
**HTTP client:** Add `reqwest` (with `default-features = false, features = ["json"]`) as a non-wasm32 dependency for the Turnstile API call. On wasm32 the create handler does not exist, so no conflict.
|
||||
|
||||
**Important:** Strip `cf_turnstile_token` from the `CreateQuoteInput` before passing it to the repository — the DB doesn't store it.
|
||||
|
||||
**Validation:**
|
||||
```sh
|
||||
# From quotesdb/ root
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — UI: Embed Turnstile widget in submit form
|
||||
|
||||
### `index.html`
|
||||
|
||||
Add the Turnstile JS script tag to the `<head>`:
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
```
|
||||
|
||||
### `src/bin/ui/pages/submit.rs`
|
||||
|
||||
1. Add a `turnstile_token: UseStateHandle<Option<String>>` state handle.
|
||||
2. Add the Turnstile widget div in the form, before the submit button:
|
||||
```html
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="TURNSTILE_SITE_KEY_HERE"
|
||||
data-callback="turnstile_callback">
|
||||
</div>
|
||||
```
|
||||
The `data-callback` JS function name must be registered in `window`. Use `web_sys::window()` and `js_sys::Function` to expose a Rust closure that sets `turnstile_token` state.
|
||||
3. Include the token in the `CreateQuoteInput` sent to the API.
|
||||
|
||||
**Site key:** The Turnstile site key is public and safe to hardcode in the UI source. Retrieve it from `tofu output -raw turnstile_site_key` after applying infra. Add a note in `docs/LOCAL_DEV.md` that local dev skips CAPTCHA (no env var set).
|
||||
|
||||
**Validation:**
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
trunk build
|
||||
```
|
||||
|
||||
Manually verify: the submit form shows the Turnstile widget and submission is blocked if the challenge is not completed.
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
- `infra/turnstile.tf` (new)
|
||||
- `src/lib.rs` — `CreateQuoteInput` + `verify_turnstile`
|
||||
- `src/bin/api/handlers/mod.rs` — `create_handler`
|
||||
- `src/bin/ui/pages/submit.rs` — widget embed + token state
|
||||
- `index.html` — Turnstile JS script
|
||||
- `Cargo.toml` — `reqwest` dependency (non-wasm32)
|
||||
- `api/openapi.yaml` — document `cf_turnstile_token` field
|
||||
- `docs/LOCAL_DEV.md` — note on local dev CAPTCHA bypass
|
||||
|
||||
## Commit scope
|
||||
|
||||
`feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit`
|
||||
@ -1,7 +1,45 @@
|
||||
+++
|
||||
title = "Write unit tests in api/src/tests.rs covering all handlers, auth logic, and pagination"
|
||||
priority = 6
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"]
|
||||
dependencies = ["2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write unit tests in `src/bin/api/tests.rs` (or a `#[cfg(test)]` module) covering all API handlers, the auth logic, and pagination calculations. Unit tests should test handler logic in isolation using mock or in-memory databases where possible.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Unit tests must run with `cargo test` on the host target — no WASM or browser context required.
|
||||
- Test auth code matching logic (correct code → pass, wrong code → 403).
|
||||
- Test pagination edge cases: page 1, last page, out-of-range page (empty array).
|
||||
- Test tag insertion and replacement (correct rows added/removed).
|
||||
- Aim for 80%+ code coverage of the API handler module.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` throughout implementation — unit tests should already exist from prior tickets.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add unit tests for api handlers, auth logic, and pagination`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
+++
|
||||
title = "quotesdb/ui: collapsible filter panel on browse page"
|
||||
priority = 5
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
## Summary
|
||||
|
||||
On the Browse Quotes page (`/browse`), move all filter controls (author, tag, date range) behind a collapsible "Filters" button. Filters are hidden by default and expand when the button is clicked.
|
||||
|
||||
## Details
|
||||
|
||||
### Filter Button
|
||||
- A single "Filters" button (or "Filters ▼" / "Filters ▲" to indicate state) sits above the quote list.
|
||||
- Clicking it toggles the filter panel open or closed.
|
||||
- The panel is collapsed by default when the page loads.
|
||||
|
||||
### Filter Panel Layout
|
||||
Each filter occupies its own line inside the panel:
|
||||
|
||||
1. **Author** — a text input labelled "Author:"
|
||||
2. **Tag** — a text input labelled "Tag:"
|
||||
3. **Date range** — displayed as:
|
||||
`Date: after [date input], before [date input]`
|
||||
Both date inputs are on the same line, after the "Date:" label.
|
||||
|
||||
### Styling
|
||||
- All three filter rows must be visually consistent with each other (same label width, same input style, same spacing).
|
||||
- The panel and button should fit the existing site theme.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Filter controls are hidden by default on `/browse`
|
||||
- [ ] A "Filters" button toggles the panel open/closed
|
||||
- [ ] Author, tag, and date range each appear on their own line
|
||||
- [ ] Date range is labelled `Date: after [input], before [input]`
|
||||
- [ ] All three filter rows share consistent visual styling
|
||||
- [ ] Existing filter functionality (querying the API with author/tag/date params) is unchanged
|
||||
|
||||
## Validation
|
||||
Run from `quotesdb/` root:
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy
|
||||
trunk build
|
||||
```
|
||||
@ -1,7 +1,53 @@
|
||||
+++
|
||||
title = "[TRIAGE] workers-rs compatibility with native Rust test binaries (may need separate native feature flag)"
|
||||
priority = 9
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74"]
|
||||
dependencies = []
|
||||
+++
|
||||
|
||||
<context>
|
||||
This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed.
|
||||
</context>
|
||||
|
||||
<question>
|
||||
workers-rs compatibility with native Rust test binaries: the workers-rs crate targets the Cloudflare Workers runtime, not native Linux/macOS. Can the API code be compiled as a native binary for `cargo test`?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Conditional compilation** — use `#[cfg(target_env = "worker")]` to switch between workers-rs entry point and a plain Axum server. The native build is used for testing.
|
||||
2. **Feature flags** — add a `native` feature that enables the Axum server path. `cargo test` uses `--features native`.
|
||||
3. **Separate test binary** — integration tests spawn a separately compiled native test server binary.
|
||||
</options>
|
||||
|
||||
<decision>
|
||||
**Option 1 variant: `cfg(target_arch = "wasm32")` — no feature flag needed.**
|
||||
|
||||
Note: `target_env = "worker"` is incorrect. The right discriminant is `target_arch = "wasm32"`.
|
||||
|
||||
The `cfg(target_arch)` split is cleaner than a feature flag because it is tied to the actual
|
||||
build target, not an opt-in flag that could be forgotten:
|
||||
|
||||
- `[target.'cfg(target_arch = "wasm32")'.dependencies]` → workers-rs (pulled in only for WASM)
|
||||
- `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` → tokio, axum, rusqlite (native only)
|
||||
|
||||
In `main.rs`:
|
||||
```rust
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[worker::event(fetch)]
|
||||
pub async fn main(...) { /* workers-rs entry point */ }
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main]
|
||||
async fn main() { /* native Axum server on :8080 */ }
|
||||
```
|
||||
|
||||
`cargo test` (native host) automatically compiles the native path. No special flags.
|
||||
No feature flag pollution. Resolved as part of TRIAGE e8a330 (DB strategy decision).
|
||||
|
||||
See implementation ticket 00aff0 for full details.
|
||||
</decision>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — workersrs-compatibility-with-native-rust-test-binaries-may-n`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,42 @@
|
||||
+++
|
||||
title = "[TRIAGE] Local dev CORS and Trunk API proxy config (trunk serve proxying to api on different port)"
|
||||
priority = 8
|
||||
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>
|
||||
|
||||
<question>
|
||||
Local dev CORS and Trunk proxy config: during `trunk serve`, the UI runs on one port and the API on another. How do we handle cross-origin API calls in development?
|
||||
</question>
|
||||
|
||||
<options>
|
||||
1. **Trunk proxy** — configure Trunk to proxy `/api/*` requests to the API server. No CORS needed. Add to `Trunk.toml`.
|
||||
2. **CORS middleware on API** — add `tower-http` CORS middleware to the Axum router, allowing localhost origins in development.
|
||||
3. **Same-origin in production** — in production, both are served from the same Cloudflare account; in dev, use the Trunk proxy.
|
||||
</options>
|
||||
|
||||
<resolution status="resolved">
|
||||
**Chosen approach: Option 1 — Trunk proxy.**
|
||||
|
||||
Rationale:
|
||||
- Mirrors the production architecture: Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site. No CORS configuration is needed in production either.
|
||||
- Frontend uses **relative URLs** (`/api/quotes`, not `http://localhost:3000/api/quotes`). The same paths work in both dev (Trunk proxies them) and production (Cloudflare routes them).
|
||||
- Zero CORS configuration: no `tower-http` CORS middleware, no `Access-Control-Allow-Origin` headers. Simpler API, smaller attack surface.
|
||||
- Port: API runs on `localhost:3000` via `cargo run` (plain Axum/Tokio for local dev).
|
||||
|
||||
Implementation:
|
||||
- `Trunk.toml` — add `[[proxy]] rewrite = "/api" backend = "http://localhost:3000"`. See ticket 00d6d7.
|
||||
- `src/bin/ui/api.rs` — use relative URLs only. See ticket 1e6a09.
|
||||
|
||||
Updated tickets: dc3d2b (Trunk.toml setup), 1e6a09 (API client module).
|
||||
Created ticket: 00d6d7 (dedicated implementation task for the proxy config).
|
||||
</resolution>
|
||||
|
||||
<commit>
|
||||
`chore(quotesdb): resolve triage — local-dev-cors-and-trunk-api-proxy-config-trunk-serve-proxyi`
|
||||
</commit>
|
||||
@ -1,7 +1,42 @@
|
||||
+++
|
||||
title = "Test suite: GET /api/quotes/random — 200 with quote, 404 when database is empty"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ce1e4f", "9b581f", "2ce22e"]
|
||||
dependencies = ["9b581f", "2ce22e"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write the `GET /api/quotes/random` test suite in `tests/test_random_quote.rs` (or similar). Test cases:
|
||||
1. 200 with a valid quote object when the database has quotes
|
||||
2. 404 with `{"error": "..."}` when the database is empty
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Use the shared test harness from ticket 9b581f.
|
||||
- Test the 404 case against a fresh empty database (no seeded quotes).
|
||||
- For the 200 case, seed at least one quote first.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`test(quotesdb): add GET /api/quotes/random test suite`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,40 @@
|
||||
+++
|
||||
title = "Define Cloudflare Worker route/domain — worker.dev subdomain or custom route for API"
|
||||
priority = 6
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "a23489"]
|
||||
dependencies = ["a23489"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
|
||||
|
||||
The API Worker needs a publicly accessible route. This can be the default `*.workers.dev` subdomain or a custom route under `elijah.run`.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Define the Cloudflare Worker route or subdomain in OpenTofu. Options:
|
||||
1. Use the default `quotesdb.your-account.workers.dev` URL (no DNS record needed)
|
||||
2. Define a `cloudflare_worker_route` resource for a custom subdomain (e.g. `api.quotes.elijah.run`)
|
||||
|
||||
Choose the simpler option first. Document the final API base URL in the project README.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- The UI API client must know the API base URL — if a custom route is used, update the UI to point to it.
|
||||
- If using a custom route, a `cloudflare_record` DNS entry may be needed.
|
||||
- Every block must have a comment.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
Run from the `infra/` directory:
|
||||
|
||||
```sh
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): define Cloudflare Worker route/domain for API`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,68 @@
|
||||
+++
|
||||
title = "Define Cloudflare Pages project resource — build config, output dir, git repo connection or artifact upload"
|
||||
priority = 7
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["25c413", "2d1371", "fc9bfd", "e2bd9b"]
|
||||
dependencies = ["2d1371", "fc9bfd"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend).
|
||||
|
||||
Build strategy resolved in triage fc9bfd: **pre-built artifact + Gitea Actions + `wrangler pages deploy`**. Pages CI build is not used. The `cloudflare_pages_project` resource is configured for direct upload (no git connection). The actual artifact deployment is handled by `.gitea/workflows/deploy-ui.yml` (ticket 3781c9).
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Define the Cloudflare Pages project resource in `infra/pages.tf`:
|
||||
1. `cloudflare_pages_project` resource named `quotesdb-ui`
|
||||
2. Set `production_branch = "quotesdb"` (the integration branch)
|
||||
3. Configure `deployment_configs` with `production.compatibility_date` and `production.d1_databases` if needed
|
||||
4. Do NOT configure a git source block — this project uses direct upload
|
||||
|
||||
Every block must have a comment explaining its purpose.
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Do NOT add a `source` block to the Pages project (no git-connected build — direct upload only).
|
||||
- SPA routing (triage e2bd9b) is resolved: a `_redirects` file (`/* /index.html 200`) is included
|
||||
in the Trunk build output via `<link data-trunk rel="copy-file" href="_redirects"/>` (ticket 9ef703).
|
||||
No changes are needed in the OpenTofu Pages resource — Cloudflare Pages processes `_redirects`
|
||||
automatically from the uploaded `dist/` directory.
|
||||
- The output directory (`dist/`) is a Trunk convention; it is documented here for reference but not configured in OpenTofu (wrangler handles it at deploy time).
|
||||
- The Pages project name `quotesdb-ui` must match the name used in `wrangler pages deploy --project-name quotesdb-ui`.
|
||||
</constraints>
|
||||
|
||||
<reference>
|
||||
```hcl
|
||||
# infra/pages.tf
|
||||
|
||||
# Cloudflare Pages project for the quotesdb Yew/Wasm frontend.
|
||||
# Uses direct upload — artifacts are deployed via wrangler in Gitea Actions (ticket 3781c9).
|
||||
resource "cloudflare_pages_project" "ui" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "quotesdb-ui"
|
||||
production_branch = "quotesdb"
|
||||
|
||||
# Deployment configuration for the production environment.
|
||||
deployment_configs {
|
||||
production {
|
||||
compatibility_date = "2024-01-01"
|
||||
# SPA routing: handled by dist/_redirects (/* /index.html 200) — see ticket 9ef703.
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</reference>
|
||||
|
||||
<validation>
|
||||
Run from the `infra/` directory:
|
||||
|
||||
```sh
|
||||
tofu validate
|
||||
tofu plan
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): define Cloudflare Pages project resource in OpenTofu`
|
||||
</commit>
|
||||
|
||||
@ -1,7 +1,69 @@
|
||||
+++
|
||||
title = "Document local dev environment — Turso/SQLite instead of D1, any wrangler.toml config required"
|
||||
title = "Write docs/LOCAL_DEV.md — local dev quickstart (cargo run + trunk serve, rusqlite, DATABASE_URL)"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "33ed29"]
|
||||
dependencies = ["33ed29"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file (development).
|
||||
|
||||
TRIAGE 33ed29 resolved the local dev database strategy: **plain rusqlite with a local SQLite file**.
|
||||
No Turso, no wrangler, no Cloudflare account required for local development.
|
||||
|
||||
Selection is **compile-time** via `cfg(target_arch = "wasm32")`:
|
||||
- `wasm32` → workers-rs D1 bindings (production)
|
||||
- native → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file (dev/test)
|
||||
|
||||
The native `main()` (see ticket 6e829e) reads `DATABASE_URL` from the environment, defaulting to
|
||||
`./quotesdb.sqlite`, and calls `repo.run_migrations()` on startup to create tables if they don't exist.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Write `docs/LOCAL_DEV.md` explaining how to set up and run the quotesdb project locally:
|
||||
|
||||
1. **Prerequisites** — Rust (via Nix flake), no Cloudflare account needed
|
||||
2. **Running the API**:
|
||||
- `cargo run` from the `quotesdb/` directory
|
||||
- Listens on `localhost:3000`
|
||||
- Creates `./quotesdb.sqlite` automatically on first run
|
||||
- Override DB path: `DATABASE_URL=/path/to/db.sqlite cargo run`
|
||||
3. **Running the UI**:
|
||||
- `trunk serve` from the `quotesdb/` directory
|
||||
- Listens on `localhost:8080`
|
||||
- Proxies `/api/*` to `localhost:3000` (see Trunk.toml `[[proxy]]` block)
|
||||
4. **Environment variables**:
|
||||
- `DATABASE_URL` — path to SQLite file (optional, default: `./quotesdb.sqlite`)
|
||||
- No other variables required for local dev
|
||||
5. **Local dev workflow** (two terminals):
|
||||
```sh
|
||||
# Terminal 1 — API
|
||||
cargo run
|
||||
# Terminal 2 — UI
|
||||
trunk serve
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
6. **No wrangler required** — `cargo run` uses the native Axum server with rusqlite directly.
|
||||
Wrangler is only needed for Workers deployment (handled by CI/infra).
|
||||
7. **Database notes**:
|
||||
- Schema is applied automatically via `run_migrations()` on first `cargo run`
|
||||
- Delete `./quotesdb.sqlite` to start fresh
|
||||
- `sqlite3 ./quotesdb.sqlite` for manual inspection
|
||||
</goal>
|
||||
|
||||
<decisions-reflected>
|
||||
- TRIAGE 33ed29: rusqlite + local SQLite file (not Turso, not wrangler dev)
|
||||
- TRIAGE a9534d: Trunk proxy for `/api/*` (not CORS middleware)
|
||||
- TRIAGE e8a330: no SQLx, `cfg(target_arch)` split
|
||||
</decisions-reflected>
|
||||
|
||||
<constraints>
|
||||
- Do not document `.env` files directly — list the env vars and their defaults, but note that `.env` is gitignored.
|
||||
- Cross-reference tickets 6e829e (api main.rs), dc3d2b (Trunk.toml), and 00aff0 (DB abstraction).
|
||||
- Keep it concise — it's a quickstart, not exhaustive reference docs.
|
||||
</constraints>
|
||||
|
||||
<commit>
|
||||
`docs(quotesdb): write LOCAL_DEV.md — local dev quickstart for api and ui`
|
||||
</commit>
|
||||
@ -0,0 +1,62 @@
|
||||
+++
|
||||
title = "Fix compiler warnings in api and ui binaries"
|
||||
priority = 7
|
||||
status = "done"
|
||||
ticket_type = "bug"
|
||||
dependencies = []
|
||||
+++
|
||||
## Bug
|
||||
|
||||
Running `cargo build --target wasm32-unknown-unknown` (and `trunk build`) produces compiler warnings in both the `api` and `ui` binaries. All warnings should be resolved so the build is clean.
|
||||
|
||||
## Warnings
|
||||
|
||||
Reproduce with:
|
||||
```sh
|
||||
# From quotesdb/ root
|
||||
cargo build --target wasm32-unknown-unknown 2>&1 | grep -E 'warning\[|warning: (fields|unused|duplicated)'
|
||||
```
|
||||
|
||||
### UI binary (1 warning)
|
||||
|
||||
**`src/bin/ui/api.rs:21:9`** — `fields page and total_count are never read`
|
||||
|
||||
The `QuotesResponse` struct (or equivalent) has `page` and `total_count` fields that are deserialized from the API but never read by any UI code. Either:
|
||||
- Remove the fields if they are genuinely unused, or
|
||||
- Add `#[allow(dead_code)]` with a comment explaining they are reserved for future pagination UI, or
|
||||
- Actually use them (e.g. pass `total_count` to the browse page for "X quotes total" display)
|
||||
|
||||
Preferred fix: use them or remove them. Avoid bare `#[allow(dead_code)]` without justification.
|
||||
|
||||
### API binary (2 warnings)
|
||||
|
||||
**`src/bin/api/db/d1.rs:9:8`** — `duplicated attribute`
|
||||
|
||||
A `#[cfg(...)]` or other attribute is duplicated on the same item. Remove the duplicate.
|
||||
|
||||
**`src/bin/api/db/mod.rs:27:9`** — `unused import: d1::D1Repository`
|
||||
|
||||
`D1Repository` is imported but never used in this module. Remove the import.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/bin/ui/api.rs` (line 21)
|
||||
- `src/bin/api/db/d1.rs` (line 9)
|
||||
- `src/bin/api/db/mod.rs` (line 27)
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
# From quotesdb/ root
|
||||
cargo fmt && cargo check && cargo clippy && cargo test
|
||||
|
||||
# Confirm zero warnings for ui binary
|
||||
cargo build --target wasm32-unknown-unknown 2>&1 | grep 'warning:.*generated'
|
||||
# Expected: no output (or "0 warnings")
|
||||
```
|
||||
|
||||
Also run `trunk build` and confirm no warnings are emitted for the `quotesdb` crate (dependency warnings from third-party crates are acceptable).
|
||||
|
||||
## Commit scope
|
||||
|
||||
`fix(quotesdb): resolve compiler warnings in api and ui`
|
||||
@ -1,7 +1,50 @@
|
||||
+++
|
||||
title = "Implement DELETE /api/quotes/:id — verify X-Auth-Code, cascade delete quote and tags, return 204"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["f3dc74", "a5049d", "d792e2"]
|
||||
dependencies = ["a5049d", "d792e2"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`.
|
||||
|
||||
Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`.
|
||||
|
||||
`DELETE /api/quotes/:id` permanently deletes a quote. The caller must provide the correct auth code via the `X-Auth-Code` header. On success, returns 204 No Content. The `quote_tags` rows cascade-delete automatically via the foreign key constraint.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the `DELETE /api/quotes/:id` handler:
|
||||
1. Extract `:id` from the path
|
||||
2. Verify the `X-Auth-Code` header matches the stored `auth_code` — return 403 on mismatch
|
||||
3. DELETE the quote row (cascade handles tag deletion)
|
||||
4. Return 204 No Content on success
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Return 404 if the quote ID does not exist.
|
||||
- Return 403 on auth code mismatch.
|
||||
- No response body on 204.
|
||||
- The `quote_tags` cascade delete is handled by the schema — do not manually delete tags.
|
||||
</constraints>
|
||||
|
||||
<skills>
|
||||
Use `superpowers:test-driven-development` — write tests for: valid auth 204, wrong auth 403, not found 404, verify cascade deletes tags.
|
||||
Use `superpowers:verification-before-completion` before closing.
|
||||
</skills>
|
||||
|
||||
<validation>
|
||||
Run in order from the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy
|
||||
cargo test
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement DELETE /api/quotes/:id with auth verification`
|
||||
</commit>
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
+++
|
||||
title = "quotesdb/ui: add footer with contact email"
|
||||
priority = 3
|
||||
status = "done"
|
||||
ticket_type = "feature"
|
||||
dependencies = []
|
||||
+++
|
||||
## Summary
|
||||
Add a footer to every page in the quotesdb UI displaying a contact email address.
|
||||
|
||||
## Details
|
||||
- Footer text: "Contact: quotes@elijah.run"
|
||||
- Should appear at the bottom of every page/route
|
||||
- Style consistently with the rest of the site (minimal, unobtrusive)
|
||||
- Make the email a clickable mailto: link
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Footer is visible on all routes (/, /browse, /quotes/:id, /author/:name, /submit, /admin)
|
||||
- [ ] Email is a mailto: link
|
||||
- [ ] Styling is consistent with site theme
|
||||
|
||||
## Validation
|
||||
Run from quotesdb/ root:
|
||||
```sh
|
||||
cargo fmt && cargo check && cargo clippy
|
||||
trunk build
|
||||
```
|
||||
@ -0,0 +1,224 @@
|
||||
+++
|
||||
title = "Refactor to single-crate with api and ui binaries"
|
||||
priority = 8
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["ec118c"]
|
||||
+++
|
||||
|
||||
<goal>
|
||||
Collapse the three separate sub-crates (`api/`, `ui/`, `tests/`) into a single Cargo crate rooted at `quotesdb/`. This simplifies the project structure, enables direct code sharing between the api and ui via `src/lib.rs`, and makes `cargo test` run all tests (unit + integration) in a single invocation.
|
||||
|
||||
**Status: done.** This ticket is kept for historical reference.
|
||||
</goal>
|
||||
|
||||
<current-state>
|
||||
```
|
||||
quotesdb/
|
||||
├── api/ # independent crate "quotesdb-api"
|
||||
│ ├── Cargo.toml
|
||||
│ ├── src/main.rs
|
||||
│ ├── src/tests.rs
|
||||
│ ├── docs/
|
||||
│ └── README.md
|
||||
├── ui/ # independent crate "quotesdb-ui"
|
||||
│ ├── Cargo.toml
|
||||
│ ├── src/main.rs
|
||||
│ ├── src/tests.rs
|
||||
│ ├── index.html
|
||||
│ ├── Trunk.toml
|
||||
│ ├── docs/
|
||||
│ └── README.md
|
||||
├── tests/ # independent crate "quotesdb-tests"
|
||||
│ ├── Cargo.toml
|
||||
│ ├── docs/
|
||||
│ └── README.md
|
||||
├── infra/
|
||||
└── docs/
|
||||
```
|
||||
|
||||
Problems with the old structure:
|
||||
- Shared types/logic must go through `../../common` — no quotesdb-specific shared code.
|
||||
- Running tests requires `cd`ing into each sub-crate separately.
|
||||
- Three `Cargo.toml` files to maintain, three `cargo fmt/check/clippy` invocations.
|
||||
- Trunk must be run from `ui/`, not the project root.
|
||||
</current-state>
|
||||
|
||||
<target-state>
|
||||
```
|
||||
quotesdb/
|
||||
├── Cargo.toml # single crate "quotesdb", default-run = "api"
|
||||
├── src/
|
||||
│ ├── lib.rs # shared code (types, models, auth logic, etc.)
|
||||
│ └── bin/
|
||||
│ ├── api/
|
||||
│ │ └── main.rs # api binary entrypoint
|
||||
│ └── ui/
|
||||
│ └── main.rs # ui binary entrypoint (for Trunk)
|
||||
├── tests/ # integration tests — run by `cargo test`
|
||||
│ └── (*.rs files)
|
||||
├── index.html # Trunk HTML entry (moved from ui/)
|
||||
├── Trunk.toml # updated to point to ui binary
|
||||
├── infra/
|
||||
└── docs/
|
||||
├── PLANNING.md
|
||||
├── ARCHITECTURE.md
|
||||
└── plans/
|
||||
└── 2026-02-27-quotesdb-design.md
|
||||
```
|
||||
|
||||
Developer workflow after refactor (unchanged from user perspective):
|
||||
- `cargo run` — starts the API server (default binary is `api`)
|
||||
- `trunk serve` — compiles ui to Wasm and serves it
|
||||
- `cargo test` — runs unit tests + integration tests
|
||||
</target-state>
|
||||
|
||||
<changes>
|
||||
### 1. Create `quotesdb/Cargo.toml`
|
||||
|
||||
Single crate manifest with:
|
||||
- `name = "quotesdb"`
|
||||
- `default-run = "api"` — ensures `cargo run` launches the api
|
||||
- `edition = "2021"`, `license = "MIT OR Apache-2.0"`
|
||||
- `[profile.release]` block (opt-level z, lto, strip, codegen-units 1)
|
||||
- All api dependencies (axum, tokio, workers-rs, sqlx, nanoid, etc.)
|
||||
- All ui dependencies (yew, wasm-bindgen, web-sys, etc.)
|
||||
- All test dependencies (reqwest, tokio, etc.) under `[dev-dependencies]`
|
||||
- Two `[[bin]]` entries:
|
||||
```toml
|
||||
[[bin]]
|
||||
name = "api"
|
||||
path = "src/bin/api/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ui"
|
||||
path = "src/bin/ui/main.rs"
|
||||
```
|
||||
|
||||
### 2. Create `src/lib.rs`
|
||||
|
||||
Shared module for code used by both binaries:
|
||||
- Domain types: `Quote`, `QuoteTag`, pagination structs, request/response shapes
|
||||
- Auth code generation (4-word passphrase) — shared so ui can display it and api generates it
|
||||
- NanoID generation utility
|
||||
- Any other shared logic
|
||||
|
||||
### 3. Move api source
|
||||
|
||||
- `api/src/main.rs` → `src/bin/api/main.rs`
|
||||
- `api/src/tests.rs` → `src/bin/api/tests.rs` (or inline unit tests within the binary module)
|
||||
- Delete `api/Cargo.toml`
|
||||
|
||||
### 4. Move ui source
|
||||
|
||||
- `ui/src/main.rs` → `src/bin/ui/main.rs`
|
||||
- `ui/src/tests.rs` → `src/bin/ui/tests.rs`
|
||||
- Delete `ui/Cargo.toml`
|
||||
|
||||
### 5. Move Trunk files
|
||||
|
||||
- `ui/index.html` → `quotesdb/index.html`
|
||||
- `ui/Trunk.toml` → `quotesdb/Trunk.toml`
|
||||
|
||||
Update `Trunk.toml` to explicitly name the ui binary:
|
||||
```toml
|
||||
[build]
|
||||
target = "index.html"
|
||||
|
||||
[build.cargo]
|
||||
args = ["--bin", "ui"]
|
||||
```
|
||||
|
||||
### 6. Move integration tests
|
||||
|
||||
- Content from `tests/` sub-crate moves into `quotesdb/tests/` as `.rs` files (standard Cargo integration test layout).
|
||||
- Delete `tests/Cargo.toml`.
|
||||
- Integration tests import from the crate root (`use quotesdb::...`) and from dev-dependencies.
|
||||
- They run with `cargo test` automatically — no separate crate needed.
|
||||
|
||||
### 7. Consolidate docs
|
||||
|
||||
Merge per-sub-crate docs into the project-level `docs/` directory:
|
||||
- `api/docs/PLANNING.md` and `ui/docs/PLANNING.md` → merge into `docs/PLANNING.md`
|
||||
- `api/docs/ARCHITECTURE.md` and `ui/docs/ARCHITECTURE.md` → merge into `docs/ARCHITECTURE.md`
|
||||
- `api/README.md` and `ui/README.md` and `tests/README.md` → consolidate into `README.md`
|
||||
- Delete the now-empty `api/docs/`, `ui/docs/`, `tests/docs/` directories.
|
||||
|
||||
### 8. Update `CLAUDE.md`
|
||||
|
||||
Update `quotesdb/CLAUDE.md` to reflect:
|
||||
- New directory structure (single crate, not three sub-crates)
|
||||
- New validation commands run from `quotesdb/` root, not from sub-directories
|
||||
- Updated branch naming and ticket hierarchy (the sub-project split is now logical, not a file-system split)
|
||||
- Updated agent dispatch instructions (agents work in `src/bin/api/` or `src/bin/ui/`, not separate crates)
|
||||
|
||||
### 9. Delete orphaned sub-crate roots
|
||||
|
||||
After moving all contents:
|
||||
- Delete `api/` directory entirely
|
||||
- Delete `ui/` directory entirely
|
||||
- Delete `tests/` old sub-crate directory (but `quotesdb/tests/` integration test files stay)
|
||||
</changes>
|
||||
|
||||
<constraints>
|
||||
### Compilation targets
|
||||
|
||||
The `api` binary compiles for the **host** target during local dev (`cargo run`). The `ui` binary compiles for `wasm32-unknown-unknown` via Trunk. These are separate compilation invocations — they don't conflict in a single Cargo crate.
|
||||
|
||||
**Shared code in `src/lib.rs` must compile for both targets.** Avoid host-only APIs (threading, filesystem) in `lib.rs`. Use `#[cfg(target_arch = "wasm32")]` and `#[cfg(not(target_arch = "wasm32"))]` guards where needed.
|
||||
|
||||
### `cargo test` and Wasm
|
||||
|
||||
`cargo test` runs on the host target. The `ui` binary's tests cannot use DOM/browser APIs directly. Yew component tests that require a browser context must use `wasm-bindgen-test` and `wasm-pack test` — these cannot be run by `cargo test`. Therefore:
|
||||
- Unit tests in `src/bin/ui/` must be limited to pure logic (routing, data transformations, API client request construction) guarded with `#[cfg(test)]`.
|
||||
- Browser-only tests (component rendering) are out of scope for `cargo test` and remain a future concern.
|
||||
- Integration tests in `tests/` exercise the **api** only and run on the host — these work fine with `cargo test`.
|
||||
|
||||
### workers-rs and local dev
|
||||
|
||||
The api uses `workers-rs` for Cloudflare Workers deployment. For local development `cargo run`, the api should either:
|
||||
- Use a plain Axum server (conditional compilation: `#[cfg(not(target_env = "worker"))]`), OR
|
||||
- Use the workers-rs local dev entrypoint.
|
||||
|
||||
### Dependency conflicts
|
||||
|
||||
Some dependencies may not compile for all targets. Use `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` for api-only deps and `[target.'cfg(target_arch = "wasm32")'.dependencies]` for ui-only deps in `Cargo.toml` where needed.
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From `quotesdb/` root:
|
||||
|
||||
```sh
|
||||
cargo fmt # must pass cleanly
|
||||
cargo check # must pass for host target
|
||||
cargo clippy # must pass with no warnings
|
||||
cargo test # must run and pass all tests (unit + integration)
|
||||
trunk build # must successfully compile the ui binary to wasm
|
||||
```
|
||||
</validation>
|
||||
|
||||
<summary>
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| CREATE | `quotesdb/Cargo.toml` |
|
||||
| CREATE | `quotesdb/src/lib.rs` |
|
||||
| MOVE | `api/src/main.rs` → `src/bin/api/main.rs` |
|
||||
| MOVE | `api/src/tests.rs` → `src/bin/api/tests.rs` |
|
||||
| MOVE | `ui/src/main.rs` → `src/bin/ui/main.rs` |
|
||||
| MOVE | `ui/src/tests.rs` → `src/bin/ui/tests.rs` |
|
||||
| MOVE | `ui/index.html` → `index.html` |
|
||||
| MOVE | `ui/Trunk.toml` → `Trunk.toml` (update `--bin ui`) |
|
||||
| MERGE | `api/docs/` + `ui/docs/` + `tests/docs/` → `docs/` |
|
||||
| MERGE | `api/README.md`, `ui/README.md`, `tests/README.md` → `README.md` |
|
||||
| DELETE | `api/Cargo.toml` |
|
||||
| DELETE | `ui/Cargo.toml` |
|
||||
| DELETE | `tests/Cargo.toml` |
|
||||
| DELETE | `api/` (after moving contents) |
|
||||
| DELETE | `ui/` (after moving contents) |
|
||||
| UPDATE | `Trunk.toml` (add `[build.cargo] args = ["--bin", "ui"]`) |
|
||||
| UPDATE | `quotesdb/CLAUDE.md` (structure, validation paths, agent instructions) |
|
||||
</summary>
|
||||
|
||||
<commit>
|
||||
`refactor(quotesdb): collapse to single crate with api and ui binaries`
|
||||
</commit>
|
||||
@ -1,7 +1,40 @@
|
||||
+++
|
||||
title = "Implement Author page (/author/:name) — paginated list of quotes by a single author"
|
||||
priority = 5
|
||||
status = "todo"
|
||||
status = "done"
|
||||
ticket_type = "task"
|
||||
dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"]
|
||||
dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
|
||||
+++
|
||||
|
||||
<context>
|
||||
The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development.
|
||||
|
||||
The Author page (`/author/:name`) shows all quotes by a specific author, paginated.
|
||||
</context>
|
||||
|
||||
<goal>
|
||||
Implement the Author page component (`src/bin/ui/pages/author.rs`):
|
||||
1. Extract `:name` from the route
|
||||
2. Fetch quotes from `GET /api/quotes?author=:name&page=N`
|
||||
3. Render the author name as a heading
|
||||
4. Render each quote with `QuoteCard`
|
||||
5. Render `Pagination` for prev/next navigation
|
||||
6. Render `ErrorDisplay` on error
|
||||
</goal>
|
||||
|
||||
<constraints>
|
||||
- Author name in the URL may be URL-encoded — decode it before using in the API call and heading.
|
||||
- Page is tracked in the URL query string (`?page=N`).
|
||||
</constraints>
|
||||
|
||||
<validation>
|
||||
From the `quotesdb/` directory:
|
||||
|
||||
```sh
|
||||
trunk build
|
||||
```
|
||||
</validation>
|
||||
|
||||
<commit>
|
||||
`feat(quotesdb): implement Author page — paginated quotes by author`
|
||||
</commit>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue