···4646 ./target/release/pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080
4747 ```
48484949+### CORS Configuration for Self-Hosted Server
5050+5151+The HTTP server supports CORS configuration via `config.toml`. Add a `[cors]` section:
5252+5353+```toml
5454+[cors]
5555+# List of allowed origins for cross-origin requests
5656+allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"]
5757+5858+# Optional development key for local testing
5959+dev_key = "your-secret-dev-key"
6060+```
6161+6262+CORS features:
6363+6464+- **Exact matching**: `http://localhost:4321` only allows that specific origin
6565+- **Same-root-domain**: `https://desertthunder.dev` also allows `https://pai.desertthunder.dev`, `https://api.desertthunder.dev`, etc.
6666+- **Dev key**: Requests with `X-Local-Dev-Key` header matching the configured key bypass origin checks
6767+6868+The PAI server handles CORS automatically - no additional proxy configuration needed. See [README.md](./README.md#cors-configuration) for details.
6969+4970## nginx Deployment
50715172### Host Setup
···309330310331# BearBlog publications (comma-separated id:url pairs)
311332BEARBLOG_URLS = "desertthunder:https://desertthunder.bearblog.dev"
333333+334334+# CORS configuration (optional)
335335+CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321"
336336+CORS_DEV_KEY = "your-secret-dev-key"
312337```
338338+339339+### CORS Configuration
340340+341341+The Worker supports CORS to allow cross-origin requests from your web applications.
342342+343343+#### Environment Variables
344344+345345+Add to `wrangler.toml` under `[vars]`:
346346+347347+- **CORS_ALLOWED_ORIGINS**: Comma-separated list of allowed origins
348348+ - Supports exact matching: `http://localhost:4321` only allows that exact origin
349349+ - Supports same-root-domain: `https://desertthunder.dev` also allows `https://pai.desertthunder.dev`, `https://api.desertthunder.dev`, etc.
350350+351351+- **CORS_DEV_KEY**: Optional development key for local testing
352352+ - When set, requests with the `X-Local-Dev-Key` header matching this value bypass origin checking
353353+ - Useful for testing from different local ports during development
354354+355355+#### Example Configuration
356356+357357+```toml
358358+[vars]
359359+# Allow requests from your main domain and localhost for development
360360+CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321"
361361+362362+# Dev key for local Astro development
363363+CORS_DEV_KEY = "local-dev-secret-123"
364364+```
365365+366366+#### Usage from JavaScript
367367+368368+```javascript
369369+// Production request from https://desertthunder.dev
370370+fetch('https://pai.desertthunder.dev/api/feed', {
371371+ credentials: 'include'
372372+})
373373+374374+// Development request from http://localhost:4321
375375+fetch('http://localhost:8787/api/feed', {
376376+ headers: {
377377+ 'X-Local-Dev-Key': 'local-dev-secret-123'
378378+ }
379379+})
380380+```
381381+382382+#### Same-Root-Domain Support
383383+384384+When you configure `CORS_ALLOWED_ORIGINS = "https://desertthunder.dev"`:
385385+386386+- ✓ `https://desertthunder.dev` (exact match)
387387+- ✓ `https://pai.desertthunder.dev` (subdomain)
388388+- ✓ `https://api.desertthunder.dev` (subdomain)
389389+- ✗ `https://evil.dev` (different root domain)
390390+391391+This allows you to deploy the Worker to `pai.desertthunder.dev` and access it from your main site at `desertthunder.dev` without explicitly listing every subdomain.
313392314393### API Endpoints
315394
+76-1
README.md
···69697070See [config.example.toml](./config.example.toml) for a complete example with all available options.
71717272+<details>
7373+<summary>
7474+CORS Configuration
7575+</summary>
7676+7777+Both the HTTP server and Cloudflare Worker support CORS configuration to allow cross-origin requests from your web applications.
7878+7979+### HTTP Server (config.toml)
8080+8181+ Add a `[cors]` section to your config file:
8282+8383+ ```toml
8484+ [cors]
8585+ allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"]
8686+ dev_key = "your-secret-dev-key"
8787+ ```
8888+8989+ Configuration options:
9090+9191+- **allowed_origins**: List of allowed origins. Supports:
9292+ - Exact match: `http://localhost:4321` only allows that exact origin
9393+ - Same-root-domain: `https://desertthunder.dev` also allows `https://pai.desertthunder.dev`, `https://api.desertthunder.dev`, etc.
9494+- **dev_key**: Optional development key for local testing.
9595+ When set, requests with the `X-Local-Dev-Key` header matching this value are allowed regardless of origin.
9696+9797+### Cloudflare Worker (Environment Variables)
9898+9999+ Configure CORS via environment variables in `wrangler.toml`:
100100+101101+ ```toml
102102+ [vars]
103103+ CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321"
104104+ CORS_DEV_KEY = "your-secret-dev-key"
105105+ ```
106106+107107+- **CORS_ALLOWED_ORIGINS**: Comma-separated list of allowed origins
108108+- **CORS_DEV_KEY**: Optional development key (same behavior as HTTP server)
109109+110110+#### Local Development with X-LOCAL-DEV-KEY
111111+112112+For local development from Astro or other frameworks:
113113+114114+1. Add a `dev_key` to your CORS config:
115115+116116+ ```toml
117117+ [cors]
118118+ allowed_origins = ["http://localhost:4321"]
119119+ dev_key = "local-dev-secret-123"
120120+ ```
121121+122122+2. Include the header in your API requests:
123123+124124+ ```javascript
125125+ fetch('http://localhost:8080/api/feed', {
126126+ headers: {
127127+ 'X-Local-Dev-Key': 'local-dev-secret-123'
128128+ }
129129+ })
130130+ ```
131131+132132+ The dev key header bypasses origin checking, useful for testing from different local ports or during development.
133133+134134+#### Same-Root-Domain Support
135135+136136+ If you configure `allowed_origins = ["https://desertthunder.dev"]`, requests from:
137137+138138+- `https://desertthunder.dev` ✓ (exact match)
139139+- `https://pai.desertthunder.dev` ✓ (subdomain of allowed root)
140140+- `https://api.desertthunder.dev` ✓ (subdomain of allowed root)
141141+- `https://evil.dev` ✗ (different root domain)
142142+143143+ This allows you to deploy the API at `pai.desertthunder.dev` and access it from your main site at `desertthunder.dev` without explicitly listing every subdomain.
144144+145145+</details>
146146+72147## Documentation
7314874149- CLI synopsis: `pai -h`, `pai <command> -h`, or `pai man` for the generated `pai(1)` page.
7575-- `pai man --install [--install-dir DIR]` copies `pai.1` into a MANPATH directory (defaults to `~/.local/share/man/man1`) so `man pai` works like any other UNIX tool.
150150+- `pai man --install [--install-dir DIR]` copies `pai.1` into a MANPATH directory (defaults to `~/.local/share/man/man1`)
76151- Database schema and config reference: [config.example.toml](./config.example.toml).
77152- Deployment topologies: [DEPLOYMENT.md](./DEPLOYMENT.md).
78153
···1919d1_binding = "DB"
2020database_name = "personal_activity_db"
21212222+# CORS configuration for HTTP server (optional)
2323+[cors]
2424+# List of allowed origins for cross-origin requests
2525+# Supports exact match and same-root-domain matching
2626+# Example: "https://desertthunder.dev" allows pai.desertthunder.dev, api.desertthunder.dev, etc.
2727+allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"]
2828+2929+# Optional development key for local testing
3030+# When set, requests with X-Local-Dev-Key header matching this value are allowed
3131+dev_key = "your-secret-dev-key-change-this"
3232+2233# Substack RSS feed source
2334[sources.substack]
2435enabled = true
+199
core/src/lib.rs
···193193 pub bearblog: Vec<BearBlogConfig>,
194194}
195195196196+/// CORS configuration for the HTTP server and Worker
197197+///
198198+/// Supports same-root-domain CORS (e.g., pai.desertthunder.dev from desertthunder.dev)
199199+/// and local development with a dev key header.
200200+#[derive(Debug, Clone, Deserialize, Serialize, Default)]
201201+pub struct CorsConfig {
202202+ /// List of allowed origins (exact match or same-root-domain)
203203+ /// Example: ["https://desertthunder.dev", "http://localhost:4321"]
204204+ #[serde(default)]
205205+ pub allowed_origins: Vec<String>,
206206+207207+ /// Optional development key for local development
208208+ /// When set, requests with X-LOCAL-DEV-KEY header matching this value are allowed
209209+ pub dev_key: Option<String>,
210210+}
211211+212212+impl CorsConfig {
213213+ /// Check if an origin is allowed based on exact match or same-root-domain logic.
214214+ ///
215215+ /// Same-root-domain means extracting the root domain (last two parts) from both
216216+ /// the origin and allowed origins, and checking for a match.
217217+ ///
218218+ /// Examples:
219219+ /// - https://pai.desertthunder.dev is allowed if https://desertthunder.dev is in allowed_origins
220220+ /// - http://localhost:4321 requires exact match
221221+ pub fn is_origin_allowed(&self, origin: &str) -> bool {
222222+ if self.allowed_origins.is_empty() {
223223+ return false;
224224+ }
225225+226226+ let origin_domain = extract_domain(origin);
227227+228228+ for allowed in &self.allowed_origins {
229229+ if origin == allowed {
230230+ return true;
231231+ }
232232+233233+ let allowed_domain = extract_domain(allowed);
234234+ if let (Some(origin_root), Some(allowed_root)) = (
235235+ extract_root_domain(&origin_domain),
236236+ extract_root_domain(&allowed_domain),
237237+ ) {
238238+ if origin_root == allowed_root {
239239+ return true;
240240+ }
241241+ }
242242+ }
243243+244244+ false
245245+ }
246246+247247+ /// Validate if a dev key matches the configured dev key
248248+ pub fn is_dev_key_valid(&self, key: Option<&str>) -> bool {
249249+ match (&self.dev_key, key) {
250250+ (Some(config_key), Some(request_key)) => config_key == request_key,
251251+ _ => false,
252252+ }
253253+ }
254254+}
255255+256256+/// Extract domain from URL (removes protocol and path)
257257+fn extract_domain(url: &str) -> String {
258258+ url.trim_start_matches("https://")
259259+ .trim_start_matches("http://")
260260+ .split('/')
261261+ .next()
262262+ .unwrap_or("")
263263+ .split(':')
264264+ .next()
265265+ .unwrap_or("")
266266+ .to_string()
267267+}
268268+269269+/// Extract root domain (last two parts of domain)
270270+/// Example: "pai.desertthunder.dev" -> Some("desertthunder.dev")
271271+/// Example: "localhost" -> None (single part)
272272+fn extract_root_domain(domain: &str) -> Option<String> {
273273+ let parts: Vec<&str> = domain.split('.').collect();
274274+ if parts.len() >= 2 {
275275+ Some(format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]))
276276+ } else {
277277+ None
278278+ }
279279+}
280280+196281/// Configuration for all sources
197282#[derive(Debug, Clone, Deserialize, Serialize, Default)]
198283pub struct Config {
···202287 pub deployment: DeploymentConfig,
203288 #[serde(default)]
204289 pub sources: SourcesConfig,
290290+ #[serde(default)]
291291+ pub cors: CorsConfig,
205292}
206293207294impl Config {
···458545 let config = Config::from_str(toml).unwrap();
459546 let substack = config.sources.substack.as_ref().unwrap();
460547 assert!(!substack.enabled);
548548+ }
549549+550550+ #[test]
551551+ fn cors_config_exact_match() {
552552+ let cors = CorsConfig {
553553+ allowed_origins: vec![
554554+ "https://desertthunder.dev".to_string(),
555555+ "http://localhost:4321".to_string(),
556556+ ],
557557+ dev_key: None,
558558+ };
559559+ assert!(cors.is_origin_allowed("https://desertthunder.dev"));
560560+ assert!(cors.is_origin_allowed("http://localhost:4321"));
561561+ assert!(!cors.is_origin_allowed("https://evil.com"));
562562+ }
563563+564564+ #[test]
565565+ fn cors_config_same_root_domain() {
566566+ let cors = CorsConfig { allowed_origins: vec!["https://desertthunder.dev".to_string()], dev_key: None };
567567+ assert!(cors.is_origin_allowed("https://pai.desertthunder.dev"));
568568+ assert!(cors.is_origin_allowed("https://api.desertthunder.dev"));
569569+ assert!(cors.is_origin_allowed("https://desertthunder.dev"));
570570+ assert!(!cors.is_origin_allowed("https://evil.dev"));
571571+ }
572572+573573+ #[test]
574574+ fn cors_config_localhost_requires_exact_match() {
575575+ let cors = CorsConfig { allowed_origins: vec!["http://localhost:4321".to_string()], dev_key: None };
576576+ assert!(cors.is_origin_allowed("http://localhost:4321"));
577577+ assert!(!cors.is_origin_allowed("http://localhost:3000"));
578578+ }
579579+580580+ #[test]
581581+ fn cors_config_empty_origins_denies_all() {
582582+ let cors = CorsConfig { allowed_origins: vec![], dev_key: None };
583583+ assert!(!cors.is_origin_allowed("https://desertthunder.dev"));
584584+ assert!(!cors.is_origin_allowed("http://localhost:4321"));
585585+ }
586586+587587+ #[test]
588588+ fn cors_config_dev_key_valid() {
589589+ let cors = CorsConfig { allowed_origins: vec![], dev_key: Some("secret-dev-key".to_string()) };
590590+ assert!(cors.is_dev_key_valid(Some("secret-dev-key")));
591591+ assert!(!cors.is_dev_key_valid(Some("wrong-key")));
592592+ assert!(!cors.is_dev_key_valid(None));
593593+ }
594594+595595+ #[test]
596596+ fn cors_config_dev_key_none() {
597597+ let cors = CorsConfig { allowed_origins: vec![], dev_key: None };
598598+ assert!(!cors.is_dev_key_valid(Some("any-key")));
599599+ assert!(!cors.is_dev_key_valid(None));
600600+ }
601601+602602+ #[test]
603603+ fn extract_domain_https() {
604604+ assert_eq!(
605605+ super::extract_domain("https://desertthunder.dev/path"),
606606+ "desertthunder.dev"
607607+ );
608608+ assert_eq!(
609609+ super::extract_domain("https://pai.desertthunder.dev"),
610610+ "pai.desertthunder.dev"
611611+ );
612612+ }
613613+614614+ #[test]
615615+ fn extract_domain_http() {
616616+ assert_eq!(super::extract_domain("http://localhost:4321/api"), "localhost");
617617+ assert_eq!(super::extract_domain("http://example.com"), "example.com");
618618+ }
619619+620620+ #[test]
621621+ fn extract_root_domain_multi_level() {
622622+ assert_eq!(
623623+ super::extract_root_domain("pai.desertthunder.dev"),
624624+ Some("desertthunder.dev".to_string())
625625+ );
626626+ assert_eq!(
627627+ super::extract_root_domain("api.example.com"),
628628+ Some("example.com".to_string())
629629+ );
630630+ assert_eq!(
631631+ super::extract_root_domain("a.b.c.example.org"),
632632+ Some("example.org".to_string())
633633+ );
634634+ }
635635+636636+ #[test]
637637+ fn extract_root_domain_single_part() {
638638+ assert_eq!(super::extract_root_domain("localhost"), None);
639639+ }
640640+641641+ #[test]
642642+ fn extract_root_domain_two_parts() {
643643+ assert_eq!(
644644+ super::extract_root_domain("example.com"),
645645+ Some("example.com".to_string())
646646+ );
647647+ }
648648+649649+ #[test]
650650+ fn config_parse_cors() {
651651+ let toml = r#"
652652+[cors]
653653+allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"]
654654+dev_key = "my-dev-key"
655655+"#;
656656+ let config = Config::from_str(toml).unwrap();
657657+ assert_eq!(config.cors.allowed_origins.len(), 2);
658658+ assert_eq!(config.cors.allowed_origins[0], "https://desertthunder.dev");
659659+ assert_eq!(config.cors.dev_key, Some("my-dev-key".to_string()));
461660 }
462661}
+3-1
server/Cargo.toml
···5566[dependencies]
77pai-core = { path = "../core" }
88-axum = "0.7"
88+axum = "0.8"
99tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] }
1010rusqlite = { version = "0.37", features = ["bundled"] }
1111serde = { version = "1.0", features = ["derive"] }
···1313owo-colors = "4.1"
1414chrono = "0.4"
1515rss = "2.0"
1616+tower = "0.5"
1717+tower-http = { version = "0.6", features = ["cors"] }
16181719[dev-dependencies]
1820tempfile = "3.13"
···3636# Format: "id1:https://blog1.bearblog.dev,id2:https://blog2.bearblog.dev"
3737BEARBLOG_URLS = "desertthunder:https://desertthunder.bearblog.dev"
38383939+# CORS configuration (optional)
4040+# Comma-separated list of allowed origins for cross-origin requests
4141+# Supports exact match and same-root-domain matching
4242+# Example: "https://desertthunder.dev" allows pai.desertthunder.dev, api.desertthunder.dev, etc.
4343+CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321"
4444+4545+# Optional development key for local testing
4646+# When set, requests with X-Local-Dev-Key header matching this value are allowed
4747+CORS_DEV_KEY = "your-secret-dev-key-change-this"
4848+3949# Optional: Logging level
4050# LOG_LEVEL = "info"
4151