diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index 20294d8..4ce2685 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -96,6 +96,9 @@ async fn list_events( from: params.from, to: params.to, sources, + // Public timeline only — private events stay in the DB but are never + // surfaced. A future authenticated path can flip this. + include_private: false, limit, }; @@ -107,7 +110,11 @@ async fn list_events( async fn list_sources( State(state): State, ) -> Result>, ApiError> { - let summaries = state.store.source_summaries().await.map_err(internal)?; + let summaries = state + .store + .source_summaries(/* include_private */ false) + .await + .map_err(internal)?; Ok(Json(summaries)) } diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index 46b8f39..7cf96fe 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -17,7 +17,7 @@ pub enum StoreError { #[async_trait] pub trait EventReader: Send + Sync { async fn list_events(&self, query: &EventQuery) -> Result, StoreError>; - async fn source_summaries(&self) -> Result, StoreError>; + async fn source_summaries(&self, include_private: bool) -> Result, StoreError>; } /// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`. diff --git a/crates/moments-core/src/presentation/github.rs b/crates/moments-core/src/presentation/github.rs index 08bd41c..f8324d2 100644 --- a/crates/moments-core/src/presentation/github.rs +++ b/crates/moments-core/src/presentation/github.rs @@ -383,6 +383,7 @@ mod tests { source: Source::Github, action: action.into(), occurred_at: Utc.with_ymd_and_hms(2026, 4, 14, 10, 0, 0).unwrap(), + public: true, payload, } } diff --git a/crates/moments-data/migrations/0003_event_public.sql b/crates/moments-data/migrations/0003_event_public.sql new file mode 100644 index 0000000..2d4e41e --- /dev/null +++ b/crates/moments-data/migrations/0003_event_public.sql @@ -0,0 +1,2 @@ +ALTER TABLE events ADD COLUMN public BOOLEAN NOT NULL DEFAULT true; +CREATE INDEX events_public_occurred_at_desc ON events (public, occurred_at DESC); diff --git a/crates/moments-data/src/github.rs b/crates/moments-data/src/github.rs index 6923853..9dda012 100644 --- a/crates/moments-data/src/github.rs +++ b/crates/moments-data/src/github.rs @@ -58,11 +58,17 @@ impl GithubSource { } fn first_page_url(&self) -> String { - // Public events endpoint: works without auth (60/hr unauth, 5000/hr authed). - // The non-public `/users/{u}/events` endpoint now requires auth and returns - // private-repo activity, which we don't want on a public timeline anyway. + // With a token: hit `/events`, which returns public + private events the + // authenticated user can see. We store everything; the API gates what + // gets surfaced to the public timeline via the `public` column. + // Without a token: fall back to `/events/public` (anonymous-readable). + let endpoint = if self.config.token.is_some() { + "events" + } else { + "events/public" + }; format!( - "https://api.github.com/users/{}/events/public?per_page={}", + "https://api.github.com/users/{}/{endpoint}?per_page={}", self.config.user, self.config.per_page ) } @@ -172,11 +178,17 @@ fn parse_github_event(raw: serde_json::Value) -> Option { let occurred_at = DateTime::parse_from_rfc3339(created_at_str) .ok()? .with_timezone(&Utc); + // GitHub marks each event with a top-level `public` boolean. Events from + // `/events/public` are always true; `/events` may include false. Default + // to true if missing — that matches the safer-of-the-two-mistakes (under- + // expose) and the `/events/public` endpoint behaviour. + let public = raw.get("public").and_then(serde_json::Value::as_bool).unwrap_or(true); Some(Event { id: format!("github:{id}"), source: Source::Github, action: event_type, occurred_at, + public, payload: raw, }) } @@ -208,6 +220,7 @@ mod tests { "id": "12345", "type": "PushEvent", "created_at": "2026-04-15T10:30:00Z", + "public": true, "actor": { "login": "grenade" }, "repo": { "name": "grenade/moments" }, "payload": { "ref": "refs/heads/main" } @@ -216,9 +229,39 @@ mod tests { assert_eq!(ev.id, "github:12345"); assert_eq!(ev.source, Source::Github); assert_eq!(ev.action, "PushEvent"); + assert!(ev.public); assert_eq!(ev.payload, raw); } + #[test] + fn private_event_marked_private() { + let raw = serde_json::json!({ + "id": "67890", + "type": "PushEvent", + "created_at": "2026-04-15T10:30:00Z", + "public": false, + "actor": { "login": "grenade" }, + "repo": { "name": "grenade/private-thing" }, + "payload": {} + }); + let ev = parse_github_event(raw).expect("parses"); + assert!(!ev.public); + } + + #[test] + fn missing_public_field_defaults_to_public() { + let raw = serde_json::json!({ + "id": "11111", + "type": "PushEvent", + "created_at": "2026-04-15T10:30:00Z", + "actor": { "login": "grenade" }, + "repo": { "name": "grenade/x" }, + "payload": {} + }); + let ev = parse_github_event(raw).expect("parses"); + assert!(ev.public); + } + #[test] fn rejects_event_missing_id() { let raw = serde_json::json!({ "type": "PushEvent", "created_at": "2026-01-01T00:00:00Z" }); diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index 5f3c11f..bd274ea 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -43,18 +43,20 @@ impl EventReader for PgStore { let rows = sqlx::query( r#" - SELECT id, source, action, occurred_at, payload + SELECT id, source, action, occurred_at, public, payload FROM events WHERE ($1::timestamptz IS NULL OR occurred_at >= $1) AND ($2::timestamptz IS NULL OR occurred_at < $2) AND ($3::text[] IS NULL OR source = ANY($3)) + AND ($4::bool OR public = true) ORDER BY occurred_at DESC - LIMIT $4 + LIMIT $5 "#, ) .bind(query.from) .bind(query.to) .bind(sources.as_deref()) + .bind(query.include_private) .bind(query.limit as i64) .fetch_all(&self.pool) .await @@ -68,13 +70,14 @@ impl EventReader for PgStore { source: Source::from_str(&source_str).map_err(map_err)?, action: r.try_get("action").map_err(map_err)?, occurred_at: r.try_get("occurred_at").map_err(map_err)?, + public: r.try_get("public").map_err(map_err)?, payload: r.try_get("payload").map_err(map_err)?, }) }) .collect() } - async fn source_summaries(&self) -> Result, StoreError> { + async fn source_summaries(&self, include_private: bool) -> Result, StoreError> { let rows = sqlx::query( r#" SELECT source, @@ -82,10 +85,12 @@ impl EventReader for PgStore { MIN(occurred_at) AS earliest, MAX(occurred_at) AS latest FROM events + WHERE $1::bool OR public = true GROUP BY source ORDER BY source "#, ) + .bind(include_private) .fetch_all(&self.pool) .await .map_err(map_err)?; @@ -187,12 +192,13 @@ impl EventWriter for PgStore { for ev in events { let n = sqlx::query( r#" - INSERT INTO events (id, source, action, occurred_at, payload) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO events (id, source, action, occurred_at, public, payload) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO UPDATE SET source = EXCLUDED.source, action = EXCLUDED.action, occurred_at = EXCLUDED.occurred_at, + public = EXCLUDED.public, payload = EXCLUDED.payload "#, ) @@ -200,6 +206,7 @@ impl EventWriter for PgStore { .bind(ev.source.as_str()) .bind(&ev.action) .bind(ev.occurred_at) + .bind(ev.public) .bind(&ev.payload) .execute(&mut *tx) .await diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index 080ed03..ed433cb 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -54,6 +54,10 @@ pub struct Event { pub source: Source, pub action: String, pub occurred_at: DateTime, + /// True when the upstream marks this event as visible to anyone (e.g. + /// GitHub's top-level `public` flag). The DB stores everything; the API + /// uses this to gate what gets surfaced on the public timeline. + pub public: bool, pub payload: serde_json::Value, } @@ -63,6 +67,9 @@ pub struct EventQuery { pub from: Option>, pub to: Option>, pub sources: Option>, + /// When false (default), only `public = true` rows are returned. The API + /// pins this to false today; a future authenticated path can flip it. + pub include_private: bool, pub limit: u32, }